Skip to main content

deps_cargo/
formatter.rs

1use deps_core::lsp_helpers::{
2    DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
3    RequirementMatcher, RequirementResolution, SourcePolicy,
4};
5use deps_core::parser::DependencySource;
6use deps_core::{ConcreteVersion, InvalidPackageName, PackageName, VersionReq};
7
8/// Maximum crate name length this diagnostic accepts.
9///
10/// Deliberately stricter than `sparse::is_safe_crate_name`'s 128-byte cap: that
11/// predicate only needs to guarantee a name is safe to splice into a sparse-index
12/// URL, while this constant approximates crates.io's actual publish-time limit for
13/// the "Invalid package name" diagnostic below — a name between 65 and 128 bytes
14/// passes the shared URL-safety check but still correctly fails this diagnostic's
15/// length check. Do not "fix" the two caps back into lockstep.
16const MAX_NAME_LENGTH: usize = 64;
17
18/// Precise semver `VersionReq` matcher, compiled once per dependency by
19/// [`CargoFormatter::compile_requirement`].
20struct SemverMatcher(semver::VersionReq);
21
22impl RequirementMatcher for SemverMatcher {
23    fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
24        let version = version.as_str();
25        version
26            .parse::<semver::Version>()
27            .ok()
28            .map(|v| self.0.matches(&v))
29    }
30}
31
32pub struct CargoFormatter;
33
34impl PackageNaming for CargoFormatter {
35    /// Validates a crate name against crates.io's naming rules.
36    ///
37    /// crates.io accepts only non-empty names starting with an ASCII letter or `_`,
38    /// followed by ASCII alphanumeric characters plus `-`/`_`, up to `MAX_NAME_LENGTH`
39    /// characters. The base charset+non-empty check reuses
40    /// `sparse::is_safe_crate_name_charset` — the same predicate
41    /// `sparse::is_safe_crate_name` builds on for the sparse-index URL-injection
42    /// gate — rather than duplicating it. This method deliberately calls the
43    /// charset-only variant, not `is_safe_crate_name` itself: that function also
44    /// bundles in a 128-byte URL-safety cap unrelated to crates.io's real naming
45    /// rules, which would make a charset-valid name over 128 bytes report the wrong
46    /// "invalid characters" reason instead of reaching this method's own
47    /// `MAX_NAME_LENGTH` check below. The leading-character rule and
48    /// `MAX_NAME_LENGTH` are layered on top here because they are specific to this
49    /// diagnostic-accuracy question, not to URL-splicing safety (see
50    /// `MAX_NAME_LENGTH`'s doc for why the two length caps intentionally differ). A
51    /// name that fails this can never resolve on crates.io, so this override lets
52    /// the "Invalid package name" diagnostic (deps-core's
53    /// `formatter.validate_package_name` gate) surface the accurate reason instead
54    /// of the generic "Unknown package" a registry-side lookup failure produces
55    /// (#382).
56    ///
57    /// The charset check (via `sparse::is_safe_crate_name_charset`) and the
58    /// leading-character check both run before the length check, so a name that is
59    /// both non-ASCII and longer than `MAX_NAME_LENGTH` chars (e.g. a repeated CJK
60    /// name) reports the charset violation rather than a misleading "too long" —
61    /// the length in bytes of such a name can exceed the limit even when its
62    /// character count does not, and vice versa, so the length check counts
63    /// `chars()`, not bytes.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`InvalidPackageName`] if `name` is empty, starts with a digit or
68    /// `-`, contains a character outside `[A-Za-z0-9_-]` (for example a non-ASCII
69    /// name like `"日本語"`), or exceeds `MAX_NAME_LENGTH` characters.
70    fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
71        if name.is_empty() {
72            return Err(InvalidPackageName::new("name cannot be empty"));
73        }
74        if !crate::sparse::is_safe_crate_name_charset(name) {
75            return Err(InvalidPackageName::new(
76                "name must contain only ASCII letters, digits, '-', or '_'",
77            ));
78        }
79        if !name.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') {
80            return Err(InvalidPackageName::new(
81                "name must start with an ASCII letter or '_'",
82            ));
83        }
84        if name.chars().count() > MAX_NAME_LENGTH {
85            return Err(InvalidPackageName::new(format!(
86                "name cannot exceed {MAX_NAME_LENGTH} characters"
87            )));
88        }
89        Ok(())
90    }
91}
92
93impl PackageRendering for CargoFormatter {
94    fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
95        let version = version.as_str();
96        version.to_string()
97    }
98
99    fn package_url(&self, name: &PackageName) -> String {
100        crate::registry::crate_url(name.as_str())
101    }
102
103    /// Suppresses the hover heading's crates.io link for any source other than plain
104    /// [`DependencySource::Registry`] or a verified crates.io mirror (spec FR-014, F2) — a
105    /// genuinely different `AlternateRegistry` resolves against a different index entirely,
106    /// so [`Self::package_url`]'s crates.io link would point at an unrelated (or simply
107    /// nonexistent) public crate once live version data from the real registry renders
108    /// beside it. A mirror's crates.io link stays correct: it is crates.io content, just
109    /// fetched elsewhere.
110    fn suppress_package_url(&self, source: &DependencySource) -> bool {
111        !self.source_is_public_registry_content(source)
112    }
113}
114
115impl RequirementResolution for CargoFormatter {
116    /// Compiles `requirement` via `semver::VersionReq`, the same crate `deps-cargo`'s
117    /// registry uses for matching — precise range semantics (`^`, `~`, comparator lists),
118    /// unlike the default `version_satisfies_requirement` heuristic this method
119    /// deliberately does not reuse (see that method's docs).
120    fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
121        requirement
122            .as_str()
123            .parse::<semver::VersionReq>()
124            .ok()
125            .map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
126    }
127}
128
129impl DiagnosticMessages for CargoFormatter {}
130
131impl DiagnosticPolicy for CargoFormatter {
132    /// `semver::VersionReq::matches` excludes pre-releases unless `requirement` itself pins
133    /// to the same `X.Y.Z` tuple with a pre-release tag — strict SemVer 2.0.0 semantics (#299).
134    fn strict_semver_prerelease_exclusion(&self) -> bool {
135        true
136    }
137}
138
139impl SourcePolicy for CargoFormatter {
140    /// Extends the default (crates.io-only) resolvability to a resolved
141    /// [`DependencySource::AlternateRegistry`] too — `CargoRegistry` (the value behind
142    /// `CargoEcosystem::registry()`) routes that source to the alternate index's own
143    /// [`crate::sparse::SparseIndexClient`], so it is exactly as resolvable as a plain
144    /// [`DependencySource::Registry`] dependency, just against a different index (spec
145    /// FR-016).
146    fn can_resolve_source(&self, source: &DependencySource) -> bool {
147        matches!(
148            source,
149            DependencySource::Registry | DependencySource::AlternateRegistry { .. }
150        )
151    }
152
153    /// A verified crates.io mirror (`AlternateRegistry { mirrors_crates_io: true, .. }`,
154    /// spec plan-1b §1.3, F1/F1b) counts as public-registry content alongside plain
155    /// [`DependencySource::Registry`] — Cargo verifies per-version checksum equality
156    /// against crates.io for a `[source.crates-io] replace-with` mirror, so its content is
157    /// exactly as trustworthy as crates.io's own for OSV scanning and hover-link purposes,
158    /// even though the fetch itself goes to the mirror's index, not to crates.io.
159    fn source_is_public_registry_content(&self, source: &DependencySource) -> bool {
160        matches!(
161            source,
162            DependencySource::Registry
163                | DependencySource::AlternateRegistry {
164                    mirrors_crates_io: true,
165                    ..
166                }
167        )
168    }
169}
170
171impl OsvNaming for CargoFormatter {}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn test_source_is_public_registry_content_plain_registry() {
179        let formatter = CargoFormatter;
180        assert!(formatter.source_is_public_registry_content(&DependencySource::Registry));
181    }
182
183    #[test]
184    fn test_source_is_public_registry_content_crates_io_mirror() {
185        let formatter = CargoFormatter;
186        assert!(formatter.source_is_public_registry_content(
187            &DependencySource::AlternateRegistry {
188                index: "https://mirror.example".into(),
189                mirrors_crates_io: true,
190            }
191        ));
192    }
193
194    #[test]
195    fn test_source_is_public_registry_content_non_mirror_alternate_is_false() {
196        let formatter = CargoFormatter;
197        assert!(!formatter.source_is_public_registry_content(
198            &DependencySource::AlternateRegistry {
199                index: "https://index.mycorp.dev".into(),
200                mirrors_crates_io: false,
201            }
202        ));
203    }
204
205    #[test]
206    fn test_suppress_package_url_mirror_not_suppressed() {
207        let formatter = CargoFormatter;
208        assert!(
209            !formatter.suppress_package_url(&DependencySource::AlternateRegistry {
210                index: "https://mirror.example".into(),
211                mirrors_crates_io: true,
212            })
213        );
214    }
215
216    #[test]
217    fn test_suppress_package_url_non_mirror_alternate_suppressed() {
218        let formatter = CargoFormatter;
219        assert!(
220            formatter.suppress_package_url(&DependencySource::AlternateRegistry {
221                index: "https://index.mycorp.dev".into(),
222                mirrors_crates_io: false,
223            })
224        );
225    }
226
227    #[test]
228    fn test_format_version() {
229        let formatter = CargoFormatter;
230        assert_eq!(
231            formatter.format_version_for_text_edit(&ConcreteVersion::new("1.0.214")),
232            "1.0.214"
233        );
234        assert_eq!(
235            formatter.format_version_for_text_edit(&ConcreteVersion::new("0.1.0")),
236            "0.1.0"
237        );
238    }
239
240    #[test]
241    fn test_package_url() {
242        let formatter = CargoFormatter;
243        assert_eq!(
244            formatter.package_url(&PackageName::new("serde")),
245            "https://crates.io/crates/serde"
246        );
247        assert_eq!(
248            formatter.package_url(&PackageName::new("tokio-util")),
249            "https://crates.io/crates/tokio-util"
250        );
251    }
252
253    #[test]
254    fn test_validate_package_name_accepts_valid_names() {
255        let formatter = CargoFormatter;
256        for name in [
257            "serde",
258            "tokio-util",
259            "my_crate",
260            "a",
261            "a".repeat(64).as_str(),
262        ] {
263            assert!(
264                formatter.validate_package_name(name).is_ok(),
265                "expected {name:?} to be accepted"
266            );
267        }
268    }
269
270    #[test]
271    fn test_validate_package_name_rejects_empty() {
272        let formatter = CargoFormatter;
273        assert!(formatter.validate_package_name("").is_err());
274    }
275
276    #[test]
277    fn test_validate_package_name_rejects_too_long() {
278        let formatter = CargoFormatter;
279        let too_long = "a".repeat(65);
280        assert!(formatter.validate_package_name(&too_long).is_err());
281    }
282
283    /// #382 repro: a non-ASCII crate name must be reported as an invalid package
284    /// name, not silently forwarded to the registry as an "Unknown package".
285    #[test]
286    fn test_validate_package_name_rejects_non_ascii() {
287        let formatter = CargoFormatter;
288        assert!(formatter.validate_package_name("日本語").is_err());
289    }
290
291    #[test]
292    fn test_validate_package_name_rejects_disallowed_punctuation() {
293        let formatter = CargoFormatter;
294        for name in ["serde.rs", "serde/util", "serde@1.0", "serde util"] {
295            assert!(
296                formatter.validate_package_name(name).is_err(),
297                "expected {name:?} to be rejected"
298            );
299        }
300    }
301
302    /// crates.io's first-character rule: a digit or `-` can never lead a real
303    /// crate name — same "falls through to Unknown package" bug shape as #382,
304    /// on a different invalid-name form.
305    #[test]
306    fn test_validate_package_name_rejects_leading_digit_or_hyphen() {
307        let formatter = CargoFormatter;
308        for name in ["1abc", "9serde", "-abc"] {
309            assert!(
310                formatter.validate_package_name(name).is_err(),
311                "expected {name:?} to be rejected"
312            );
313        }
314    }
315
316    /// A leading underscore is explicitly allowed, unlike a leading digit or `-`.
317    #[test]
318    fn test_validate_package_name_accepts_leading_underscore() {
319        let formatter = CargoFormatter;
320        assert!(formatter.validate_package_name("_private").is_ok());
321    }
322
323    /// The charset check must run before the length check: a non-ASCII name whose
324    /// *byte* length exceeds 64 (but character count does not) must report the
325    /// charset violation, not a misleading "too long" — and vice versa, the length
326    /// check must count `chars()`, not bytes, so it doesn't false-positive here.
327    #[test]
328    fn test_validate_package_name_long_non_ascii_reports_charset_error() {
329        let formatter = CargoFormatter;
330        let name = "日".repeat(30); // 30 chars, 90 bytes: over the byte cap, under the char cap
331        let err = formatter
332            .validate_package_name(&name)
333            .expect_err("non-ASCII name must be rejected");
334        assert!(
335            err.reason().contains("ASCII"),
336            "expected a charset error, got: {}",
337            err.reason()
338        );
339    }
340
341    /// Regression: `validate_package_name` must use `sparse::is_safe_crate_name_charset`
342    /// (charset only), not `sparse::is_safe_crate_name` (charset + a 128-byte
343    /// URL-safety cap unrelated to this diagnostic) — a charset-valid name over 128
344    /// bytes must reach this method's own `MAX_NAME_LENGTH` check and report "too
345    /// long", not the wrong "invalid characters" reason from the bundled-length
346    /// predicate.
347    #[test]
348    fn test_validate_package_name_over_128_bytes_reports_length_error_not_charset() {
349        let formatter = CargoFormatter;
350        let name = "a".repeat(130);
351        let err = formatter
352            .validate_package_name(&name)
353            .expect_err("over-length name must be rejected");
354        assert!(
355            err.reason().contains("exceed"),
356            "expected a length error, got: {}",
357            err.reason()
358        );
359    }
360
361    #[test]
362    fn test_default_normalize_is_identity() {
363        let formatter = CargoFormatter;
364        assert_eq!(
365            formatter.normalize_package_name(&PackageName::new("serde")),
366            "serde"
367        );
368        assert_eq!(
369            formatter.normalize_package_name(&PackageName::new("tokio-util")),
370            "tokio-util"
371        );
372    }
373
374    #[test]
375    fn test_default_yanked_message() {
376        let formatter = CargoFormatter;
377        assert_eq!(formatter.yanked_message(), "This version has been yanked");
378        assert_eq!(formatter.yanked_label(), "*(yanked)*");
379    }
380
381    #[test]
382    fn test_version_satisfies_requirement() {
383        let formatter = CargoFormatter;
384
385        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3"));
386        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "^1.2"));
387        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "~1.2"));
388        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1"));
389        assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2"));
390
391        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "2.0.0"));
392        assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.3"));
393    }
394
395    #[test]
396    fn test_compile_requirement_satisfiable() {
397        let formatter = CargoFormatter;
398        let matcher = formatter
399            .compile_requirement(&VersionReq::new("^1.0"))
400            .expect("valid semver requirement must compile");
401        assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
402        assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
403    }
404
405    #[test]
406    fn test_compile_requirement_unparseable_requirement_returns_none() {
407        let formatter = CargoFormatter;
408        assert!(
409            formatter
410                .compile_requirement(&VersionReq::new("not a semver req"))
411                .is_none()
412        );
413    }
414
415    #[test]
416    fn test_compile_requirement_unparseable_candidate_is_skipped() {
417        let formatter = CargoFormatter;
418        let matcher = formatter
419            .compile_requirement(&VersionReq::new("^1.0"))
420            .unwrap();
421        assert_eq!(
422            matcher.matches(&ConcreteVersion::new("not-a-version")),
423            None
424        );
425    }
426
427    /// §3.1 worked example: an ordinary comparator-list requirement, which
428    /// `version_satisfies_requirement`'s loose heuristic (no `^`/`~` prefix, three dot
429    /// segments so `is_partial_version` is false) incorrectly rejects. The precise
430    /// `compile_requirement` matcher must accept it.
431    #[test]
432    fn test_compile_requirement_comparator_list_satisfiable() {
433        let formatter = CargoFormatter;
434        let matcher = formatter
435            .compile_requirement(&VersionReq::new(">=1.0, <2.0"))
436            .unwrap();
437        assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
438    }
439
440    /// §3.3 case: `~1.0.999` and latest `1.0.214` share major/minor, so the loose
441    /// `is_same_major_minor`-based heuristic (and the removed `status == Outdated` gate)
442    /// would treat this as up to date. The precise matcher must reject it — patch `999`
443    /// is not published.
444    #[test]
445    fn test_compile_requirement_tilde_mistyped_patch_is_unsatisfiable() {
446        let formatter = CargoFormatter;
447        let matcher = formatter
448            .compile_requirement(&VersionReq::new("~1.0.999"))
449            .unwrap();
450        assert_eq!(
451            matcher.matches(&ConcreteVersion::new("1.0.214")),
452            Some(false)
453        );
454    }
455}