Skip to main content

deps_go/
formatter.rs

1use deps_core::VersionReq;
2use deps_core::lsp_helpers::{
3    DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
4    RequirementMatcher, RequirementResolution, SourcePolicy, compile_requirement_unless,
5};
6use deps_core::{ConcreteVersion, Dependency, DepsError, InvalidPackageName, PackageName};
7
8use crate::types::{GoDependency, GoDirective};
9
10/// Exact/pseudo-version comparison shared by `version_satisfies_requirement` and
11/// [`GoFormatter::compile_requirement`]'s matcher — Go module requirements are exact pins
12/// or MVS-selected versions, not ranges, so both call sites need identical semantics:
13///
14/// 1. Exact match: v1.2.3 == v1.2.3
15/// 2. Prefix match for pseudo-versions: v0.0.0-20191109021931-daa7c04131f5 starts with v0.0.0
16/// 3. Prefix match for +incompatible: v2.0.0+incompatible starts with v2.0.0
17fn go_version_matches(version: &str, requirement: &str) -> bool {
18    if version == requirement {
19        return true;
20    }
21
22    // Handle pseudo-versions and +incompatible suffix
23    // Check if version starts with requirement followed by a dot, hyphen, plus, or end
24    // This prevents false positives like v1.2.30 matching v1.2.3
25    if let Some(suffix) = version.strip_prefix(requirement) {
26        return suffix.is_empty()
27            || suffix.starts_with('.')
28            || suffix.starts_with('-')
29            || suffix.starts_with('+');
30    }
31
32    false
33}
34
35/// Exact/pseudo-version matcher, compiled once per dependency by
36/// [`GoFormatter::compile_requirement`]. Always decidable (`Some`) — Go module version
37/// strings need no external parser, just [`go_version_matches`]'s string comparison — so
38/// this never skips a candidate the way ecosystems with a real version parser can.
39struct ExactMatcher(String);
40
41impl RequirementMatcher for ExactMatcher {
42    fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
43        let version = version.as_str();
44        Some(go_version_matches(version, &self.0))
45    }
46}
47
48/// Formatter for Go module version strings and package URLs.
49///
50/// Handles Go-specific version formatting:
51/// - Versions are unquoted in go.mod (v1.2.3)
52/// - Pseudo-versions (v0.0.0-20191109021931-daa7c04131f5)
53/// - +incompatible suffix for v2+ modules without /v2 path
54pub struct GoFormatter;
55
56impl PackageNaming for GoFormatter {
57    /// Reuses `crate::registry::validate_module_path` — the same structural rule that
58    /// gates every registry request — so a malformed module path (empty, too long, or
59    /// containing a `.`/`..` path segment) is reported as "Invalid package name" instead of
60    /// falling through to a registry lookup and rendering the generic "Registry lookup
61    /// failed" diagnostic (#402).
62    ///
63    /// # Errors
64    ///
65    /// Returns [`InvalidPackageName`] carrying `validate_module_path`'s rejection reason.
66    fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
67        let Err(err) = crate::registry::validate_module_path(name) else {
68            return Ok(());
69        };
70        // `validate_module_path` only ever constructs `DepsError::InvalidVersionReq` (#399
71        // documents it as the shared "invalid input" carrier it deliberately reuses for this),
72        // so this is the only reachable arm — matched explicitly rather than a catch-all
73        // `.to_string()` fallback, both to avoid dead code per CLAUDE.md and because
74        // `DepsError`'s `Display` prefixes an unrelated "invalid version requirement: " label
75        // that would misrender the module-path reason here (#402 critique M3).
76        let DepsError::InvalidVersionReq(reason) = err else {
77            unreachable!("validate_module_path only ever returns DepsError::InvalidVersionReq")
78        };
79        Err(InvalidPackageName::new(reason))
80    }
81}
82
83impl PackageRendering for GoFormatter {
84    fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
85        let version = version.as_str();
86        // Go versions in go.mod are unquoted: v1.2.3
87        // Return version as-is since it should already have "v" prefix from registry
88        version.to_string()
89    }
90
91    fn package_url(&self, name: &PackageName) -> String {
92        crate::registry::package_url(name.as_str())
93    }
94
95    /// S4 (spec 034 review): suppresses the `pkg.go.dev` hover link for anything but a plain
96    /// public-registry dependency, reusing `SourcePolicy::source_is_public_registry_content`'s
97    /// default (`Registry` only — Go has no crates.io-style verified-mirror concept for
98    /// `AlternateRegistry` to except, mirroring `deps-pypi`'s identical reasoning). Without
99    /// this, a `GOPRIVATE`-matched module's hover still rendered a clickable
100    /// `pkg.go.dev/<private-path>` link, undermining the confidentiality guarantee FR-008/
101    /// NFR-003(2) exist for — the module path never reaches `pkg.go.dev` over the network
102    /// either way (this is a display link only, see `crate::registry::package_url`'s doc),
103    /// but the link itself named the private path in the rendered hover text.
104    fn suppress_package_url(&self, source: &deps_core::parser::DependencySource) -> bool {
105        !self.source_is_public_registry_content(source)
106    }
107}
108
109impl RequirementResolution for GoFormatter {
110    fn version_satisfies_requirement(&self, version: &ConcreteVersion, requirement: &str) -> bool {
111        let version = version.as_str();
112        go_version_matches(version, requirement)
113    }
114
115    /// Compiles `requirement` into an `ExactMatcher` using the same exact/pseudo-version
116    /// comparison `version_satisfies_requirement` uses — Go's requirement syntax has no
117    /// separate "loose" vs. "precise" distinction, so both share `go_version_matches`. Uses
118    /// [`compile_requirement_unless`] (see that function and
119    /// [`deps_core::lsp_helpers::RequirementResolution::compile_requirement`] for the shared "undecidable" contract).
120    ///
121    /// The undecidable predicate is `crate::version::is_pseudo_version`:
122    /// `proxy.golang.org/<mod>/@v/list` — the source of `available` — never lists
123    /// pseudo-versions (they're derived per-commit, not enumerable), so a pseudo-version pin
124    /// can never be found in `available` even when the exact commit it names is real. A
125    /// `+incompatible`-suffixed *tag* (not a pseudo-version) is a real entry `/@v/list` does
126    /// return, so it needs no such guard.
127    fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
128        compile_requirement_unless(
129            requirement.as_str(),
130            crate::version::is_pseudo_version,
131            ExactMatcher,
132        )
133    }
134
135    fn manifest_requirement_is_resolved_version(&self, dep: &dyn Dependency) -> bool {
136        // go.mod's `require` line is already the module version selected by
137        // Go's MVS, never a range — unlike Cargo/npm. go.sum, by contrast,
138        // only ever gets appended to (`go get`/`go build`; only
139        // `go mod tidy` prunes it), so a stale higher version left over from
140        // a downgrade can still be recorded there and win naive
141        // last-occurrence-wins parsing (#235).
142        //
143        // Restricted to `GoDirective::Require`: `exclude`/`replace`
144        // directives are also surfaced as dependencies, but their
145        // `version_requirement()` is not an in-use version (the excluded
146        // version, or the replaced-from version) — treating those as
147        // resolved would fabricate a "current version" claim for a
148        // dependency that isn't actually pinned there (#235 review).
149        dep.as_any()
150            .downcast_ref::<GoDependency>()
151            .is_some_and(|go_dep| go_dep.directive == GoDirective::Require)
152    }
153}
154
155impl DiagnosticMessages for GoFormatter {}
156
157impl DiagnosticPolicy for GoFormatter {}
158
159impl SourcePolicy for GoFormatter {
160    /// FR-012 (spec 034): accepts `Registry` (default) and `AlternateRegistry` (a `$GOENV`
161    /// `GOPROXY`-chain or `GOPRIVATE`-bypass resolution) so hover/diagnostics/code-actions
162    /// gate correctly; `CustomRegistry` (FR-009's fail-closed state, every hop invalid) is
163    /// deliberately not accepted — falls through to the default `is_version_resolvable() ==
164    /// false`, keeping the existing fail-closed gate intact.
165    fn can_resolve_source(&self, source: &deps_core::parser::DependencySource) -> bool {
166        matches!(
167            source,
168            deps_core::parser::DependencySource::Registry
169                | deps_core::parser::DependencySource::AlternateRegistry { .. }
170        )
171    }
172}
173
174impl OsvNaming for GoFormatter {
175    fn osv_version_to_native(&self, version: &str) -> String {
176        // OSV's `fixed` events for Go are plain semver (`0.3.7`), never
177        // carrying the `v` prefix Go module versions require in go.mod.
178        if version.starts_with('v') {
179            version.to_string()
180        } else {
181            format!("v{version}")
182        }
183    }
184
185    fn osv_version(&self, version: &str) -> String {
186        // Go module versions always carry a mandatory "v" prefix
187        // (golang.org/x/mod/module convention), but OSV.dev's SEMVER range
188        // matching forbids it — strip it before sending on the wire.
189        version.strip_prefix('v').unwrap_or(version).to_string()
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use tower_lsp_server::ls_types::{Position, Range};
197
198    fn go_dep(directive: GoDirective, version: &str) -> GoDependency {
199        GoDependency {
200            module_path: PackageName::new("github.com/gorilla/mux"),
201            module_path_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
202            version: Some(deps_core::VersionReq::new(version)),
203            version_range: Some(Range::new(Position::new(0, 0), Position::new(0, 1))),
204            directive,
205            indirect: false,
206            source: deps_core::parser::DependencySource::Registry,
207        }
208    }
209
210    /// Regression test for critique M1 (`.local/handoff/2026-08-23T20-55-32-critic.md`):
211    /// a `require` directive's version is the exact MVS-selected version (#235), but
212    /// `exclude`/`replace` directives are also surfaced as dependencies whose
213    /// `version_requirement()` is not an in-use version (the excluded version, or the
214    /// replaced-from version) — those must not be reported as resolved.
215    #[test]
216    fn test_manifest_requirement_is_resolved_version_only_for_require_directive() {
217        let formatter = GoFormatter;
218
219        let require_dep = go_dep(GoDirective::Require, "v1.8.0");
220        assert!(formatter.manifest_requirement_is_resolved_version(&require_dep));
221
222        let exclude_dep = go_dep(GoDirective::Exclude, "v0.1.0");
223        assert!(!formatter.manifest_requirement_is_resolved_version(&exclude_dep));
224
225        let replace_dep = go_dep(GoDirective::Replace, "v1.0.0");
226        assert!(!formatter.manifest_requirement_is_resolved_version(&replace_dep));
227
228        let retract_dep = go_dep(GoDirective::Retract, "v1.0.0");
229        assert!(!formatter.manifest_requirement_is_resolved_version(&retract_dep));
230    }
231
232    #[test]
233    fn test_format_version_for_text_edit() {
234        let formatter = GoFormatter;
235
236        // Standard semantic version
237        assert_eq!(
238            formatter.format_version_for_text_edit(&ConcreteVersion::new("v1.2.3")),
239            "v1.2.3"
240        );
241
242        // Pseudo-version
243        assert_eq!(
244            formatter.format_version_for_text_edit(&ConcreteVersion::new(
245                "v0.0.0-20191109021931-daa7c04131f5"
246            )),
247            "v0.0.0-20191109021931-daa7c04131f5"
248        );
249
250        // Version with +incompatible
251        assert_eq!(
252            formatter.format_version_for_text_edit(&ConcreteVersion::new("v2.0.0+incompatible")),
253            "v2.0.0+incompatible"
254        );
255    }
256
257    #[test]
258    fn test_package_url() {
259        let formatter = GoFormatter;
260
261        // Standard package
262        assert_eq!(
263            formatter.package_url(&PackageName::new("github.com/gin-gonic/gin")),
264            "https://pkg.go.dev/github.com/gin-gonic/gin"
265        );
266
267        // Package with version path
268        assert_eq!(
269            formatter.package_url(&PackageName::new("github.com/go-redis/redis/v8")),
270            "https://pkg.go.dev/github.com/go-redis/redis/v8"
271        );
272
273        // Standard library package
274        assert_eq!(
275            formatter.package_url(&PackageName::new("fmt")),
276            "https://pkg.go.dev/fmt"
277        );
278
279        // Package with @ character (should be URL encoded)
280        assert_eq!(
281            formatter.package_url(&PackageName::new("github.com/user@org/package")),
282            "https://pkg.go.dev/github.com/user%40org/package"
283        );
284
285        // Package with space (should be URL encoded)
286        assert_eq!(
287            formatter.package_url(&PackageName::new("github.com/user/pkg name")),
288            "https://pkg.go.dev/github.com/user/pkg%20name"
289        );
290    }
291
292    #[test]
293    fn test_version_satisfies_requirement_exact_match() {
294        let formatter = GoFormatter;
295
296        // Exact version match
297        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("v1.2.3"), "v1.2.3"));
298        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("v0.1.0"), "v0.1.0"));
299    }
300
301    #[test]
302    fn test_version_satisfies_requirement_pseudo_version() {
303        let formatter = GoFormatter;
304
305        // Pseudo-version prefix match
306        assert!(formatter.version_satisfies_requirement(
307            &ConcreteVersion::new("v0.0.0-20191109021931-daa7c04131f5"),
308            "v0.0.0"
309        ));
310
311        // Full pseudo-version match
312        assert!(formatter.version_satisfies_requirement(
313            &ConcreteVersion::new("v0.0.0-20191109021931-daa7c04131f5"),
314            "v0.0.0-20191109021931-daa7c04131f5"
315        ));
316    }
317
318    #[test]
319    fn test_version_satisfies_requirement_incompatible() {
320        let formatter = GoFormatter;
321
322        // +incompatible suffix handling
323        assert!(
324            formatter.version_satisfies_requirement(
325                &ConcreteVersion::new("v2.0.0+incompatible"),
326                "v2.0.0"
327            )
328        );
329
330        // Exact match with +incompatible
331        assert!(formatter.version_satisfies_requirement(
332            &ConcreteVersion::new("v2.0.0+incompatible"),
333            "v2.0.0+incompatible"
334        ));
335    }
336
337    #[test]
338    fn test_version_does_not_satisfy_requirement() {
339        let formatter = GoFormatter;
340
341        // Different versions
342        assert!(
343            !formatter.version_satisfies_requirement(&ConcreteVersion::new("v1.2.3"), "v1.2.4")
344        );
345        assert!(
346            !formatter.version_satisfies_requirement(&ConcreteVersion::new("v2.0.0"), "v1.0.0")
347        );
348
349        // Partial match that doesn't start with requirement
350        assert!(
351            !formatter.version_satisfies_requirement(&ConcreteVersion::new("v1.2.3"), "v1.2.3.4")
352        );
353    }
354
355    #[test]
356    fn test_version_satisfies_requirement_prefix_scenarios() {
357        let formatter = GoFormatter;
358
359        // Version is prefix of requirement (should NOT match)
360        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("v1.2"), "v1.2.3"));
361
362        // Requirement is prefix of version with dot boundary (should match)
363        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("v1.2.3"), "v1.2"));
364
365        // False positive prevention: v1.2.30 should NOT match v1.2.3
366        assert!(
367            !formatter.version_satisfies_requirement(&ConcreteVersion::new("v1.2.30"), "v1.2.3")
368        );
369
370        // But v1.2.3.1 SHOULD match v1.2.3 (if it has dot boundary)
371        assert!(
372            formatter.version_satisfies_requirement(&ConcreteVersion::new("v1.2.3.1"), "v1.2.3")
373        );
374    }
375
376    #[test]
377    fn test_osv_version_to_native_prepends_v_prefix() {
378        let formatter = GoFormatter;
379
380        assert_eq!(formatter.osv_version_to_native("0.3.7"), "v0.3.7");
381        // Already-prefixed input (should not occur in practice, but must
382        // not be double-prefixed) round-trips unchanged.
383        assert_eq!(formatter.osv_version_to_native("v0.3.7"), "v0.3.7");
384    }
385
386    #[test]
387    fn test_osv_version_strips_v_prefix() {
388        let formatter = GoFormatter;
389
390        assert_eq!(formatter.osv_version("v1.2.3"), "1.2.3");
391        assert_eq!(
392            formatter.osv_version("v0.0.0-20191109021931-daa7c04131f5"),
393            "0.0.0-20191109021931-daa7c04131f5"
394        );
395        assert_eq!(
396            formatter.osv_version("v2.0.0+incompatible"),
397            "2.0.0+incompatible"
398        );
399    }
400
401    #[test]
402    fn test_osv_version_unprefixed_is_unaffected() {
403        let formatter = GoFormatter;
404
405        // A version without the "v" prefix (should not normally occur for
406        // Go, but the transform must be a no-op rather than corrupt it).
407        assert_eq!(formatter.osv_version("1.2.3"), "1.2.3");
408    }
409
410    #[test]
411    fn test_compile_requirement_satisfiable() {
412        let formatter = GoFormatter;
413        let matcher = formatter
414            .compile_requirement(&VersionReq::new("v1.9.1"))
415            .expect("an ordinary tagged requirement compiles");
416        assert_eq!(matcher.matches(&ConcreteVersion::new("v1.9.1")), Some(true));
417        assert_eq!(
418            matcher.matches(&ConcreteVersion::new("v1.9.2")),
419            Some(false)
420        );
421    }
422
423    #[test]
424    fn test_compile_requirement_never_skips_a_candidate() {
425        // Go's matcher has no external parser to fail on, so unlike other ecosystems it
426        // never returns `None` for a candidate.
427        let formatter = GoFormatter;
428        let matcher = formatter
429            .compile_requirement(&VersionReq::new("v1.9.1"))
430            .unwrap();
431        assert_eq!(
432            matcher.matches(&ConcreteVersion::new("not-a-version-at-all")),
433            Some(false)
434        );
435    }
436
437    /// S1 regression: `/@v/list` never enumerates pseudo-versions, so a pseudo-version
438    /// requirement (an ordinary `go.mod` commit pin) can never be found in `available` —
439    /// the whole scan must be suppressed (`None`), not scanned to a false "unsatisfiable".
440    #[test]
441    fn test_compile_requirement_pseudo_version_requirement_returns_none() {
442        let formatter = GoFormatter;
443        assert!(
444            formatter
445                .compile_requirement(&VersionReq::new("v0.0.0-20191109021931-daa7c04131f5"))
446                .is_none()
447        );
448    }
449
450    /// A `+incompatible`-suffixed *tag* is a real, enumerable `/@v/list` entry (not a
451    /// pseudo-version), so it must not be caught by the pseudo-version guard above.
452    #[test]
453    fn test_compile_requirement_incompatible_tag_still_compiles() {
454        let formatter = GoFormatter;
455        let matcher = formatter
456            .compile_requirement(&VersionReq::new("v2.0.0+incompatible"))
457            .expect("a +incompatible tag is not a pseudo-version");
458        assert_eq!(
459            matcher.matches(&ConcreteVersion::new("v2.0.0+incompatible")),
460            Some(true)
461        );
462    }
463
464    #[test]
465    fn test_validate_package_name_accepts_valid_module_path() {
466        let formatter = GoFormatter;
467        assert!(
468            formatter
469                .validate_package_name("github.com/gin-gonic/gin")
470                .is_ok()
471        );
472        assert!(formatter.validate_package_name("golang.org/x/mod").is_ok());
473    }
474
475    #[test]
476    fn test_validate_package_name_rejects_empty() {
477        let formatter = GoFormatter;
478        assert!(formatter.validate_package_name("").is_err());
479    }
480
481    /// #402: a `.`/`..` module path segment must be reported as an invalid package name,
482    /// not forwarded to the registry lookup that produces the misleading generic diagnostic.
483    #[test]
484    fn test_validate_package_name_rejects_dot_segment() {
485        let formatter = GoFormatter;
486        assert!(
487            formatter
488                .validate_package_name("github.com/user/..")
489                .is_err()
490        );
491        assert!(formatter.validate_package_name("./evil").is_err());
492    }
493
494    /// FR-012 (spec 034): `can_resolve_source` accepts `Registry`/`AlternateRegistry`,
495    /// rejects `CustomRegistry` (FR-009's fail-closed state).
496    #[test]
497    fn test_can_resolve_source() {
498        let formatter = GoFormatter;
499        assert!(formatter.can_resolve_source(&deps_core::parser::DependencySource::Registry));
500        assert!(formatter.can_resolve_source(
501            &deps_core::parser::DependencySource::AlternateRegistry {
502                index: "go-proxy:deadbeef".to_string(),
503                mirrors_crates_io: false,
504            }
505        ));
506        assert!(!formatter.can_resolve_source(
507            &deps_core::parser::DependencySource::CustomRegistry {
508                url: "not-a-valid-url".to_string(),
509            }
510        ));
511    }
512
513    /// S4 (spec 034 review): the `pkg.go.dev` hover link is suppressed for anything but a
514    /// plain public-registry dependency — a `GOPRIVATE`/`GOPROXY`-resolved module's hover
515    /// must not render a clickable link naming its own (potentially private) module path.
516    #[test]
517    fn test_suppress_package_url() {
518        let formatter = GoFormatter;
519        assert!(!formatter.suppress_package_url(&deps_core::parser::DependencySource::Registry));
520        assert!(formatter.suppress_package_url(
521            &deps_core::parser::DependencySource::AlternateRegistry {
522                index: "go-proxy:deadbeef".to_string(),
523                mirrors_crates_io: false,
524            }
525        ));
526        assert!(formatter.suppress_package_url(
527            &deps_core::parser::DependencySource::CustomRegistry {
528                url: "not-a-valid-url".to_string(),
529            }
530        ));
531    }
532
533    #[test]
534    fn test_validate_package_name_rejects_too_long() {
535        let formatter = GoFormatter;
536        let too_long = "a".repeat(501);
537        assert!(formatter.validate_package_name(&too_long).is_err());
538    }
539}