deps_gradle/formatter.rs
1//! Version formatting for Gradle ecosystem.
2
3use deps_core::lsp_helpers::{
4 DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
5 RequirementMatcher, RequirementResolution, SourcePolicy, compile_requirement_unless,
6};
7use deps_core::{
8 ConcreteVersion, InvalidPackageName, PackageName, VersionReq, is_safe_maven_coordinate_segment,
9};
10
11pub struct GradleFormatter;
12
13/// Unresolved Gradle variable reference (`$var`, `${var}`), or an explicit empty
14/// version-catalog entry (`[versions] foo = ""`) that a `version.ref` could point at.
15fn is_unresolved(requirement: &str) -> bool {
16 requirement.is_empty() || requirement.contains('$')
17}
18
19/// Strips Gradle's rich-version strict/preferred shorthand
20/// (`{strictlyVersion}!!{preferredVersion}`, e.g. the degenerate suffix form `1.2.3!!`
21/// or the full infix form `[1.7,1.8[!!1.7.25`), returning only the `strictlyVersion`
22/// half. Per Gradle's rich-version semantics, `strictly` is the hard constraint and
23/// `preferred` is only a soft conflict-resolution tiebreak among versions that already
24/// satisfy it — a version inside the strict range/pin satisfies the requirement
25/// regardless of whether it matches the preferred pointer, so every "does this version
26/// satisfy the requirement" comparison in [`gradle_version_matches`] and
27/// [`GradleFormatter::compile_requirement`] must operate on the `strictlyVersion`
28/// constraint alone, never the preference. `requirement` is `trim_end`ed before the
29/// marker check: `libs.versions.toml` catalog values reach here un-trimmed
30/// (`catalog::extract_version` returns the raw string verbatim), unlike the DSL
31/// parsers, which already trim their capture.
32fn strip_strict_marker(requirement: &str) -> &str {
33 requirement
34 .trim_end()
35 .split_once("!!")
36 .map_or(requirement, |(strictly, _)| strictly.trim_end())
37}
38
39/// A `-SNAPSHOT` pin (e.g. `7.0.0-SNAPSHOT`) resolves against Maven Central's snapshot
40/// repository, which `deps-maven`'s `MavenCentralRegistry` (reused for Gradle resolution)
41/// never queries — release-repo `maven-metadata.xml` never lists snapshot versions, so
42/// `available` can never contain one. Treated as always satisfied, like an unresolved
43/// variable or `latest.*`.
44fn is_snapshot(requirement: &str) -> bool {
45 requirement.ends_with("-SNAPSHOT")
46}
47
48/// Decides whether `version` satisfies a Gradle `requirement` — shared by
49/// `version_satisfies_requirement` and [`GradleFormatter::compile_requirement`]'s matcher,
50/// since Gradle has no separate "loose" vs. "precise" comparator to distinguish (mirrors
51/// `deps-maven`'s formatter, which shares the same shape for the same reason).
52///
53/// #249 review (M4, root cause of S1): this function's branch order is a separate copy from
54/// `compile_requirement`'s below — the malformed-range guard that function adds ahead of its
55/// own copy of this order has no equivalent here (this function has none; a malformed range
56/// simply falls through to `crate::range::satisfies`'s fail-closed `false`, which is correct
57/// for the "loose satisfies" question this function answers). Reordering the branches here
58/// must be checked against `compile_requirement`'s branch order and guard placement too.
59fn gradle_version_matches(version: &str, requirement: &str) -> bool {
60 // Checked on the raw string *before* stripping the `!!` marker: an unresolved
61 // Gradle variable reference can appear in the `strictlyVersion` half (e.g.
62 // `${r}!!1.7.25`), and stripping first would discard the `$` along with it,
63 // silently treating an unresolved requirement as a concrete one to compare.
64 //
65 // Deliberate, harmless asymmetry with `compile_requirement` below, which has no
66 // equivalent raw-string check: `strip_strict_marker` already returns the
67 // `strictlyVersion` half with any `$` intact, so the post-strip `is_unresolved`
68 // check a few lines down covers the same case on its own — this raw-string check
69 // is pure defense-in-depth for a caller of this loose matcher standalone. Its
70 // only observable effect is over-permissive, never under-permissive: a
71 // `preferredVersion` half containing an unresolved variable (e.g.
72 // `[1.7,1.8[!!${r}`) makes this function report "satisfied" for every version,
73 // including ones outside the strict range, whereas `compile_requirement`'s
74 // matcher (no raw-string check, and never reached with such a requirement in
75 // production since `requirement_is_unsatisfiable` gates on the raw-string
76 // `requirement_is_unresolved` first) would correctly reject an out-of-range
77 // version. Never produces a false "outdated" badge or a spurious edit, so this
78 // is not a bug — just don't "fix" the two functions back into lockstep by
79 // deleting this check without checking C3's post-strip coverage still holds.
80 if is_unresolved(requirement) {
81 return true;
82 }
83 let requirement = strip_strict_marker(requirement);
84 // Unresolved Gradle variable reference (`$var`/`${var}`), or an empty version-catalog
85 // entry (`[versions] foo = ""`) — skip comparison. Re-checked post-strip for the
86 // degenerate case where the `strictlyVersion` half itself is empty (a malformed
87 // bare `"!!"` requirement), which the raw check above does not catch since the raw
88 // string is `"!!"`, not empty.
89 if is_unresolved(requirement) {
90 return true;
91 }
92 if requirement == "latest" || requirement.starts_with("latest.") {
93 return true;
94 }
95 if is_snapshot(requirement) {
96 return true;
97 }
98 if let Some(prefix) = requirement.strip_suffix('+') {
99 return version == prefix.trim_end_matches('.') || version.starts_with(prefix);
100 }
101 // `]` is included alongside `[`/`(` because Gradle's reversed-bracket exclusive
102 // notation (`]1.2,1.5]`) is a leading delimiter in its own right, not just a
103 // trailing one.
104 if requirement.starts_with(['[', '(', ']']) {
105 return crate::range::satisfies(version, requirement);
106 }
107 version == requirement
108}
109
110/// Precise Gradle version/range matcher, compiled once per dependency by
111/// [`GradleFormatter::compile_requirement`] — a bracket-interval range is parsed once into a
112/// [`deps_maven::interval::VersionRange`] here rather than being re-parsed for every
113/// candidate version scanned. `requirement_is_unsatisfiable` already gates on
114/// `requirement_is_unresolved` before calling `compile_requirement`, so the unresolved and
115/// `latest.*` short-circuits are unreachable from that caller in practice; they stay so this
116/// matcher is correct if used standalone.
117enum GradleMatcher {
118 /// Unresolved `$var`/`${var}`, `latest`/`latest.*`, or a `-SNAPSHOT` pin.
119 AlwaysSatisfied,
120 /// A dynamic `1.0.+` prefix — the text before the trailing `+`.
121 DynamicPrefix(String),
122 /// A bracket-interval range, pre-parsed by [`crate::range::parse_range`].
123 Range(deps_maven::interval::VersionRange),
124 /// A bare exact version.
125 Exact(String),
126}
127
128impl RequirementMatcher for GradleMatcher {
129 fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
130 let version = version.as_str();
131 Some(match self {
132 Self::AlwaysSatisfied => true,
133 Self::DynamicPrefix(prefix) => {
134 version == prefix.trim_end_matches('.') || version.starts_with(prefix.as_str())
135 }
136 Self::Range(range) => deps_maven::interval::contains(version, range),
137 Self::Exact(target) => version == target,
138 })
139 }
140}
141
142impl PackageNaming for GradleFormatter {
143 /// Validates a Gradle coordinate's `group:artifact` shape and character set.
144 ///
145 /// Gradle resolves through `deps_maven::MavenCentralRegistry` and shares Maven's
146 /// `groupId:artifactId` coordinate shape (see `deps-gradle/src/ecosystem.rs`), so this
147 /// mirrors [`deps_maven`]'s `MavenFormatter::validate_package_name` exactly, reusing
148 /// [`is_safe_maven_coordinate_segment`] rather than duplicating it — letting the
149 /// "Invalid package name" diagnostic surface the accurate reason instead of the
150 /// generic "Unknown package" a registry-side rejection produces (#375).
151 ///
152 /// Unlike Maven's `${property}`-specific `is_unresolved`, this uses Gradle's own
153 /// `is_unresolved`, which also short-circuits on an unresolved `$var`/`${var}`
154 /// reference or Gradle-catalog-alias placeholder — valid Gradle syntax, not a
155 /// malformed coordinate.
156 ///
157 /// # Errors
158 ///
159 /// Returns [`InvalidPackageName`] if `name` has no `:` separator, or if either the
160 /// `group` or `artifact` segment fails [`is_safe_maven_coordinate_segment`] — but
161 /// never when `name` is unresolved per `is_unresolved`, which is accepted instead.
162 fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
163 if is_unresolved(name) {
164 return Ok(());
165 }
166 let Some((group_id, artifact_id)) = name.split_once(':') else {
167 return Err(InvalidPackageName::new(
168 "coordinate must be in 'group:artifact' form",
169 ));
170 };
171 if !is_safe_maven_coordinate_segment(group_id) {
172 return Err(InvalidPackageName::new("group contains invalid characters"));
173 }
174 if !is_safe_maven_coordinate_segment(artifact_id) {
175 return Err(InvalidPackageName::new(
176 "artifact contains invalid characters",
177 ));
178 }
179 Ok(())
180 }
181}
182
183impl PackageRendering for GradleFormatter {
184 fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
185 let version = version.as_str();
186 version.to_string()
187 }
188
189 /// Preserves Gradle's rich-version strict/preferred shorthand
190 /// (`{strictlyVersion}!!{preferredVersion}`) when `current` carries it — a bare
191 /// `format_version_for_text_edit` replacement would otherwise silently downgrade
192 /// a strict constraint to a normal one.
193 ///
194 /// Only the degenerate suffix form (`1.2.3!!`, no `preferredVersion`) is
195 /// rewritten — to `{version}!!` — since the strict pin itself is what "update
196 /// version" means to bump there, and there is nothing else in the requirement to
197 /// preserve. The full infix form (`[1.7,1.8[!!1.7.25`) is left unchanged rather
198 /// than rewriting the `preferredVersion` half: since Gradle's strict constraint
199 /// always wins conflict resolution, bumping the preference to a version outside
200 /// the hand-written strict range (a likely outcome for "update to latest") would
201 /// silently write a no-op edit that *looks* like an update but changes nothing —
202 /// worse than the original silently-dropped-marker bug, since the manifest now
203 /// reads as though it were updated. There is no single rewrite that is safe in
204 /// general without inspecting the strict range's bounds, which is out of scope
205 /// here. `current` is `trim`med before the marker check for the same
206 /// un-trimmed-catalog-value reason as `strip_strict_marker` above. A no-op
207 /// return here is safely excluded from `deps-core`'s `collect_update_all_edits`
208 /// ("Update N outdated dependencies" lens) by its own no-op guard.
209 fn format_version_replacing(&self, version: &ConcreteVersion, current: &str) -> String {
210 let version = version.as_str();
211 let trimmed = current.trim();
212 match trimmed.split_once("!!") {
213 Some((_, "")) => format!("{version}!!"),
214 Some(_) => trimmed.to_string(),
215 None => self.format_version_for_text_edit(&ConcreteVersion::new(version)),
216 }
217 }
218
219 fn package_url(&self, name: &PackageName) -> String {
220 deps_maven::registry::package_url(name.as_str())
221 }
222}
223
224impl RequirementResolution for GradleFormatter {
225 fn version_satisfies_requirement(&self, version: &ConcreteVersion, requirement: &str) -> bool {
226 let version = version.as_str();
227 gradle_version_matches(version, requirement)
228 }
229
230 fn requirement_is_unresolved(&self, requirement: &VersionReq) -> bool {
231 is_unresolved(requirement.as_str())
232 }
233
234 /// Uses [`compile_requirement_unless`] (see that function and
235 /// [`deps_core::lsp_helpers::RequirementResolution::compile_requirement`] for the shared "undecidable" contract).
236 ///
237 /// The undecidable predicate rejects a malformed range (leading `[`/`(`/`]` but
238 /// `crate::range::parse_range` fails) — checked unconditionally, first, before any
239 /// other branch: without this guard ahead of the `AlwaysSatisfied`/dynamic-prefix
240 /// short-circuits below, a malformed bracket range that also happens to end in `+`
241 /// (e.g. `"[1.0,2.0]+"`) would be misclassified as a dynamic prefix — which decides
242 /// `Some(false)` for every real candidate — instead of correctly suppressing the check.
243 ///
244 /// #249 review (M4): this is a separate branch-order copy from `gradle_version_matches`
245 /// above — see the note on that function before reordering either one.
246 fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
247 // `!!` is Gradle's rich-version strict/preferred shorthand (see
248 // `gradle_version_matches`/`strip_strict_marker`) — stripped once here, first, so
249 // every branch below (the malformed-range guard, dynamic-prefix, range, exact)
250 // operates on the `strictlyVersion` spelling underneath without needing its own
251 // separate strip. Unlike `gradle_version_matches` (re-derives everything from the raw
252 // string on every call), this matcher is pre-parsed once, so the stripped spelling must
253 // be what actually gets stored in the `GradleMatcher` variant — storing the unstripped
254 // string would make e.g. `Exact` compare against a target that includes `"!!"`.
255 let requirement = strip_strict_marker(requirement.as_str());
256 compile_requirement_unless(
257 requirement,
258 |r| r.starts_with(['[', '(', ']']) && crate::range::parse_range(r).is_none(),
259 |r| {
260 if is_unresolved(&r) || r == "latest" || r.starts_with("latest.") {
261 return GradleMatcher::AlwaysSatisfied;
262 }
263 if is_snapshot(&r) {
264 return GradleMatcher::AlwaysSatisfied;
265 }
266 if let Some(prefix) = r.strip_suffix('+') {
267 return GradleMatcher::DynamicPrefix(prefix.to_string());
268 }
269 // `]` is included alongside `[`/`(` because Gradle's reversed-bracket
270 // exclusive notation (`]1.2,1.5]`) is a leading delimiter in its own
271 // right, not just a trailing one. The undecidable guard above already
272 // ensures `parse_range` succeeds here.
273 if r.starts_with(['[', '(', ']'])
274 && let Some(range) = crate::range::parse_range(&r)
275 {
276 return GradleMatcher::Range(range);
277 }
278 GradleMatcher::Exact(r)
279 },
280 )
281 }
282}
283
284impl DiagnosticMessages for GradleFormatter {}
285
286impl DiagnosticPolicy for GradleFormatter {}
287
288impl SourcePolicy for GradleFormatter {}
289
290impl OsvNaming for GradleFormatter {}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use deps_core::lsp_helpers::RequirementStatus;
296
297 #[test]
298 fn test_format_version() {
299 let f = GradleFormatter;
300 assert_eq!(
301 f.format_version_for_text_edit(&ConcreteVersion::new("3.2.0")),
302 "3.2.0"
303 );
304 assert_eq!(
305 f.format_version_for_text_edit(&ConcreteVersion::new("1.0.0-SNAPSHOT")),
306 "1.0.0-SNAPSHOT"
307 );
308 }
309
310 #[test]
311 fn test_format_version_replacing_preserves_strict_marker() {
312 let f = GradleFormatter;
313 assert_eq!(
314 f.format_version_replacing(&ConcreteVersion::new("1.2.4"), "1.2.3!!"),
315 "1.2.4!!"
316 );
317 }
318
319 #[test]
320 fn test_format_version_replacing_no_marker_stays_plain() {
321 let f = GradleFormatter;
322 assert_eq!(
323 f.format_version_replacing(&ConcreteVersion::new("1.2.4"), "1.2.3"),
324 "1.2.4"
325 );
326 }
327
328 /// M1: a version-catalog entry's raw value is not trimmed by the parser
329 /// (unlike the Groovy/Kotlin DSL capture), so trailing whitespace must not
330 /// defeat the suffix-marker check.
331 #[test]
332 fn test_format_version_replacing_preserves_strict_marker_with_trailing_whitespace() {
333 let f = GradleFormatter;
334 assert_eq!(
335 f.format_version_replacing(&ConcreteVersion::new("1.2.4"), "1.2.3!! "),
336 "1.2.4!!"
337 );
338 }
339
340 /// S1/C2: the full `{strictlyVersion}!!{preferredVersion}` shorthand has no
341 /// single version to bump to — rewriting the `preferredVersion` half to a value
342 /// outside the untouched strict range would write a self-contradictory
343 /// constraint (Gradle's strict range always wins, so the bump would silently
344 /// have no effect while the manifest reads as updated). Must return the
345 /// declared text unchanged rather than destroying the range or writing a
346 /// misleading no-op.
347 #[test]
348 fn test_format_version_replacing_infix_shorthand_is_unchanged() {
349 let f = GradleFormatter;
350 assert_eq!(
351 f.format_version_replacing(&ConcreteVersion::new("9.9.9"), "[1.7, 1.8[!!1.7.25"),
352 "[1.7, 1.8[!!1.7.25"
353 );
354 }
355
356 /// M1: same trailing-whitespace tolerance as the suffix form, for the infix form.
357 #[test]
358 fn test_format_version_replacing_infix_shorthand_with_trailing_whitespace() {
359 let f = GradleFormatter;
360 assert_eq!(
361 f.format_version_replacing(&ConcreteVersion::new("9.9.9"), "[1.7,1.8[!!1.7.25 "),
362 "[1.7,1.8[!!1.7.25"
363 );
364 }
365
366 #[test]
367 fn test_package_url() {
368 let f = GradleFormatter;
369 assert_eq!(
370 f.package_url(&PackageName::new(
371 "org.springframework.boot:spring-boot-starter"
372 )),
373 "https://central.sonatype.com/artifact/org.springframework.boot/spring-boot-starter"
374 );
375 }
376
377 #[test]
378 fn test_version_satisfies() {
379 let f = GradleFormatter;
380 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.2.0"), "3.2.0"));
381 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("3.2.0"), "3.1.0"));
382 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("3.2.0"), "3.2.1"));
383 }
384
385 #[test]
386 fn test_version_satisfies_dynamic_prefix() {
387 let f = GradleFormatter;
388 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.0.5"), "1.0.+"));
389 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.0"), "1.0.+"));
390 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.1.0"), "1.0.+"));
391 // Prefix boundary: "2.10.+" must not false-match "2.1.5" via a naive
392 // non-dot-anchored prefix check.
393 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("2.1.5"), "2.10.+"));
394 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("2.10.5"), "2.10.+"));
395 }
396
397 #[test]
398 fn test_version_satisfies_latest_selector() {
399 let f = GradleFormatter;
400 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.2.0"), "latest.release"));
401 assert!(f.version_satisfies_requirement(
402 &ConcreteVersion::new("3.2.0-SNAPSHOT"),
403 "latest.integration"
404 ));
405 }
406
407 #[test]
408 fn test_version_satisfies_range() {
409 let f = GradleFormatter;
410 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.5.0"), "[1.0,2.0)"));
411 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("2.0.0"), "[1.0,2.0)"));
412 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.0.0"), "[1.0.0]"));
413 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.0.1"), "[1.0.0]"));
414 }
415
416 #[test]
417 fn test_version_satisfies_reversed_bracket_range() {
418 let f = GradleFormatter;
419 // `implementation 'com.google.guava:guava:[30.0,31.0['` — Gradle's documented
420 // exclusive-upper-bound notation, leading with `[` but trailing with `[` instead of
421 // `)`/`]`.
422 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("30.5"), "[30.0,31.0["));
423 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("31.0"), "[30.0,31.0["));
424 // Exclusive-lower-bound notation, which leads with `]` rather than `[`/`(`.
425 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.2"), "]1.2,1.5]"));
426 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.3"), "]1.2,1.5]"));
427 }
428
429 /// S2/C1: the full `{strictlyVersion}!!{preferredVersion}` shorthand matches
430 /// against the strict range alone — per Gradle's rich-version semantics,
431 /// `strictly` is the hard constraint and `preferred` only breaks ties among
432 /// versions that already satisfy it. A version inside the range but different
433 /// from the preferred pointer (`1.7.30`) still satisfies the requirement;
434 /// only a version genuinely outside the range (`1.8.0`) does not.
435 #[test]
436 fn test_version_satisfies_strict_range_with_preferred() {
437 let f = GradleFormatter;
438 assert!(
439 f.version_satisfies_requirement(&ConcreteVersion::new("1.7.25"), "[1.7,1.8[!!1.7.25")
440 );
441 assert!(
442 f.version_satisfies_requirement(&ConcreteVersion::new("1.7.30"), "[1.7,1.8[!!1.7.25")
443 );
444 assert!(
445 !f.version_satisfies_requirement(&ConcreteVersion::new("1.8.0"), "[1.7,1.8[!!1.7.25")
446 );
447 }
448
449 /// C3: an unresolved Gradle variable inside the `strictlyVersion` half must
450 /// short-circuit to "satisfied" the same as a bare unresolved variable —
451 /// stripping the `!!` marker before checking must never discard the `$`.
452 #[test]
453 fn test_version_satisfies_unresolved_variable_with_strict_marker() {
454 let f = GradleFormatter;
455 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.7.25"), "${r}!!1.7.25"));
456 }
457
458 /// C3: same guarantee when the unresolved variable sits in the
459 /// `preferredVersion` half instead — the raw-string check runs before
460 /// `strip_strict_marker` discards that half entirely, so it still sees the `$`.
461 #[test]
462 fn test_version_satisfies_unresolved_variable_in_preferred_half() {
463 let f = GradleFormatter;
464 assert!(
465 f.version_satisfies_requirement(&ConcreteVersion::new("1.7.25"), "[1.7,1.8[!!${r}")
466 );
467 }
468
469 /// M3: the discriminating case for the raw-string pre-check's documented
470 /// asymmetry with `compile_requirement` — `1.7.25` above is inside the strict
471 /// range regardless of the pre-check, so it doesn't prove anything on its own.
472 /// `1.8.0` is genuinely outside `[1.7,1.8[`; the loose matcher still reports it
473 /// as satisfied only because the raw-string pre-check short-circuits before the
474 /// range is ever consulted. Deliberately over-permissive and unreachable in
475 /// production (see the pre-check's doc comment); this pins the behavior so a
476 /// future change to the pre-check doesn't silently alter it.
477 #[test]
478 fn test_version_satisfies_unresolved_variable_in_preferred_half_over_permissive() {
479 let f = GradleFormatter;
480 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.8.0"), "[1.7,1.8[!!${r}"));
481 }
482
483 #[test]
484 fn test_version_satisfies_unresolved_bare_variable() {
485 let f = GradleFormatter;
486 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "$someVersion"));
487 }
488
489 #[test]
490 fn test_version_satisfies_unresolved_braced_variable() {
491 let f = GradleFormatter;
492 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "${someVersion}"));
493 }
494
495 #[test]
496 fn test_version_satisfies_unresolved_compound_variable() {
497 let f = GradleFormatter;
498 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("3.14.0"), "1.0.0-$suffix"));
499 }
500
501 #[test]
502 fn test_validate_package_name_accepts_valid_coordinate() {
503 let f = GradleFormatter;
504 assert!(f.validate_package_name("com.google.guava:guava").is_ok());
505 }
506
507 #[test]
508 fn test_validate_package_name_rejects_missing_colon() {
509 let f = GradleFormatter;
510 assert!(f.validate_package_name("com.google.guava").is_err());
511 }
512
513 #[test]
514 fn test_validate_package_name_rejects_invalid_group() {
515 let f = GradleFormatter;
516 assert!(f.validate_package_name("com</group>:guava").is_err());
517 }
518
519 #[test]
520 fn test_validate_package_name_rejects_invalid_artifact() {
521 let f = GradleFormatter;
522 assert!(f.validate_package_name("com.google.guava:..").is_err());
523 }
524
525 /// Gradle's own `is_unresolved` (unresolved `$var`/`${var}` or catalog-alias
526 /// placeholder) is valid Gradle syntax, not a malformed coordinate — must be
527 /// accepted, mirroring Maven's `${property}` treatment.
528 #[test]
529 fn test_validate_package_name_accepts_unresolved_variable() {
530 let f = GradleFormatter;
531 assert!(f.validate_package_name("$group:guava").is_ok());
532 assert!(f.validate_package_name("com.google.guava:${name}").is_ok());
533 }
534
535 #[test]
536 fn test_normalize_is_identity() {
537 let f = GradleFormatter;
538 assert_eq!(
539 f.normalize_package_name(&PackageName::new("com.google.guava:guava")),
540 "com.google.guava:guava"
541 );
542 }
543
544 #[test]
545 fn test_requirement_status_unresolved_bare_variable() {
546 let f = GradleFormatter;
547 assert_eq!(
548 f.requirement_status(
549 &VersionReq::new("$someVersion"),
550 &ConcreteVersion::new("3.14.0")
551 ),
552 RequirementStatus::Unresolved
553 );
554 }
555
556 #[test]
557 fn test_requirement_status_unresolved_braced_variable() {
558 let f = GradleFormatter;
559 assert_eq!(
560 f.requirement_status(
561 &VersionReq::new("${someVersion}"),
562 &ConcreteVersion::new("3.14.0")
563 ),
564 RequirementStatus::Unresolved
565 );
566 }
567
568 #[test]
569 fn test_requirement_status_unresolved_dangling_catalog_ref() {
570 // Synthetic `$alias` produced by `catalog::extract_version` for a `version.ref`
571 // missing from `[versions]` — must be treated the same as an unresolved variable.
572 let f = GradleFormatter;
573 assert_eq!(
574 f.requirement_status(
575 &VersionReq::new("$missing"),
576 &ConcreteVersion::new("3.14.0")
577 ),
578 RequirementStatus::Unresolved
579 );
580 }
581
582 #[test]
583 fn test_requirement_status_up_to_date() {
584 let f = GradleFormatter;
585 assert_eq!(
586 f.requirement_status(&VersionReq::new("3.2.0"), &ConcreteVersion::new("3.2.0")),
587 RequirementStatus::UpToDate
588 );
589 }
590
591 #[test]
592 fn test_requirement_status_outdated() {
593 let f = GradleFormatter;
594 assert_eq!(
595 f.requirement_status(&VersionReq::new("3.1.0"), &ConcreteVersion::new("3.2.0")),
596 RequirementStatus::Outdated
597 );
598 }
599
600 #[test]
601 fn test_compile_requirement_exact() {
602 let f = GradleFormatter;
603 let matcher = f
604 .compile_requirement(&VersionReq::new("3.2.0"))
605 .expect("Gradle requirement always compiles");
606 assert_eq!(matcher.matches(&ConcreteVersion::new("3.2.0")), Some(true));
607 assert_eq!(matcher.matches(&ConcreteVersion::new("3.1.0")), Some(false));
608 }
609
610 #[test]
611 fn test_compile_requirement_range() {
612 let f = GradleFormatter;
613 let matcher = f
614 .compile_requirement(&VersionReq::new("[1.0,2.0)"))
615 .unwrap();
616 assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
617 assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
618 }
619
620 /// S2: `requirement_status` must reach `UpToDate` for a strict pin sitting on
621 /// the latest version, not stay permanently `Outdated` — the round trip through
622 /// `format_version_replacing` (which preserves `!!`) would otherwise leave a
623 /// warning the editor can never clear.
624 #[test]
625 fn test_requirement_status_strict_marker_up_to_date() {
626 let f = GradleFormatter;
627 assert_eq!(
628 f.requirement_status(&VersionReq::new("1.2.3!!"), &ConcreteVersion::new("1.2.3")),
629 RequirementStatus::UpToDate
630 );
631 }
632
633 /// C1: matches against the strict range, not the preferred pointer — a
634 /// registry that never publishes the exact preferred version must not produce
635 /// a false "no published version satisfies this requirement" warning for every
636 /// other version genuinely inside the strict range. See
637 /// `test_version_satisfies_strict_range_with_preferred`'s doc for why.
638 #[test]
639 fn test_compile_requirement_strict_range_with_preferred() {
640 let f = GradleFormatter;
641 let matcher = f
642 .compile_requirement(&VersionReq::new("[1.7,1.8[!!1.7.25"))
643 .unwrap();
644 assert_eq!(matcher.matches(&ConcreteVersion::new("1.7.25")), Some(true));
645 assert_eq!(matcher.matches(&ConcreteVersion::new("1.7.30")), Some(true));
646 assert_eq!(matcher.matches(&ConcreteVersion::new("1.8.0")), Some(false));
647 }
648
649 #[test]
650 fn test_compile_requirement_malformed_range_returns_none() {
651 let f = GradleFormatter;
652 assert!(
653 f.compile_requirement(&VersionReq::new("[1.0,2.0"))
654 .is_none()
655 );
656 }
657
658 /// #268 rebase re-verification: the malformed-range guard runs on the post-strip
659 /// `strictlyVersion` half, same as `crate::range::parse_range` below it — a
660 /// missing closing delimiter must still be rejected even with an infix
661 /// `!!{preferred}` shorthand attached, not misparsed as valid because the `!!`
662 /// suffix confuses the range grammar.
663 #[test]
664 fn test_compile_requirement_malformed_range_with_preferred_returns_none() {
665 let f = GradleFormatter;
666 assert!(
667 f.compile_requirement(&VersionReq::new("[1.0,2.0!!1.5"))
668 .is_none()
669 );
670 }
671
672 /// S6: mirrors deps-maven's snapshot guard — Gradle resolves through the same
673 /// `MavenCentralRegistry`, which never queries the snapshot repository.
674 #[test]
675 fn test_compile_requirement_snapshot_always_satisfied() {
676 let f = GradleFormatter;
677 let matcher = f
678 .compile_requirement(&VersionReq::new("7.0.0-SNAPSHOT"))
679 .unwrap();
680 assert_eq!(matcher.matches(&ConcreteVersion::new("6.9.0")), Some(true));
681 }
682
683 #[test]
684 fn test_version_satisfies_snapshot() {
685 let f = GradleFormatter;
686 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("6.9.0"), "7.0.0-SNAPSHOT"));
687 }
688
689 /// #249 review regression: a malformed bracket range that also happens to end in `+`
690 /// must still be rejected (`None`), not misclassified as a dynamic prefix by checking
691 /// the `strip_suffix('+')` branch before the malformed-range guard — that would make
692 /// every real candidate decide `Some(false)`, a false "unsatisfiable" ERROR for a typo.
693 #[test]
694 fn test_compile_requirement_malformed_range_rejected_even_with_trailing_plus() {
695 let f = GradleFormatter;
696 for malformed in ["[1.0,2.0]+", "[1.0,2.+", "(1.0,2.0)+", "]1.0,2.0]+"] {
697 assert!(
698 f.compile_requirement(&VersionReq::new(malformed)).is_none(),
699 "expected None for {malformed:?}"
700 );
701 }
702 }
703
704 #[test]
705 fn test_version_satisfies_strict_shorthand() {
706 let f = GradleFormatter;
707 assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3!!"));
708 assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("1.2.4"), "1.2.3!!"));
709 }
710
711 #[test]
712 fn test_compile_requirement_strict_shorthand() {
713 let f = GradleFormatter;
714 let matcher = f.compile_requirement(&VersionReq::new("1.2.3!!")).unwrap();
715 assert_eq!(matcher.matches(&ConcreteVersion::new("1.2.3")), Some(true));
716 assert_eq!(matcher.matches(&ConcreteVersion::new("1.2.4")), Some(false));
717 }
718
719 /// M6: `compile_requirement`'s range-validity guard must strip `!!` the same way
720 /// `gradle_version_matches` does — otherwise a valid strict range like
721 /// `"[1.0,2.0)!!"` fails `parse_range` (the suffix isn't range grammar) and the
722 /// guard wrongly suppresses the diagnostic instead of compiling the matcher.
723 #[test]
724 fn test_compile_requirement_strict_range() {
725 let f = GradleFormatter;
726 let matcher = f
727 .compile_requirement(&VersionReq::new("[1.0,2.0)!!"))
728 .expect("strict range must still compile a matcher");
729 assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
730 assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
731 }
732}