Skip to main content

deps_deno/
formatter.rs

1//! `EcosystemFormatter` implementation for Deno manifests (D4).
2//!
3//! Version comparison, package URLs, and yanked-wording all dispatch on the scheme carried
4//! inside the (already scheme-qualified, per D2) package name, mirroring
5//! [`crate::registry`]'s dispatch — `jsr:` requirements compile through the same
6//! `node_semver::Range` grammar JSR itself mandates, and `npm:` requirements delegate to
7//! `deps-npm`'s own rules where a bare name check is enough (validation) or reuse
8//! `deps-npm`'s wording outright (yanked messages, S2).
9
10use crate::specifier::{Scheme, is_dot_prefixed, split_scheme, split_scoped};
11use deps_core::lsp_helpers::{
12    DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
13    RequirementMatcher, RequirementResolution, SourcePolicy, warn_rejected_value,
14};
15use deps_core::{ConcreteVersion, Dependency, InvalidPackageName, PackageName, VersionReq};
16
17/// Precise `node-semver` range matcher, compiled once per dependency by
18/// [`DenoFormatter::compile_requirement`]. Correct for both `jsr:` and `npm:`
19/// requirements: JSR mandates strict semver, and this is the same grammar/crate
20/// `deps-npm` already uses for `npm:` requirements.
21struct NodeSemverMatcher(node_semver::Range);
22
23impl RequirementMatcher for NodeSemverMatcher {
24    fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
25        let version = version.as_str();
26        node_semver::Version::parse(version)
27            .ok()
28            .map(|v| self.0.satisfies(&v))
29    }
30}
31
32/// Conservative cap on a `jsr:` scope/package-name segment's length (S-L1). JSR's own
33/// limits are narrower (32/58); this single cap is all the URL-shape concern below needs —
34/// `validate_package_name` is a diagnostic lint, not a strict mirror of JSR's registration
35/// rules.
36const MAX_JSR_SEGMENT_LENGTH: usize = 64;
37
38/// Validates one `@scope`/`pkg` segment of a `jsr:` specifier (S-L1): rejects a segment
39/// starting with `.` (blocks both `.` and `..`, which would otherwise let
40/// `jsr:@a/..@1`/`jsr:@../x@1` build a `jsr.io` URL that path-normalizes away from the
41/// intended `/@scope/pkg` shape) and caps segment length. `split_scoped` already
42/// guarantees non-empty segments, so emptiness is not re-checked here.
43fn validate_jsr_segment(segment: &str) -> Result<(), InvalidPackageName> {
44    if is_dot_prefixed(segment) {
45        return Err(InvalidPackageName::new(
46            "jsr: scope and package name must not start with '.'",
47        ));
48    }
49    if segment.chars().count() > MAX_JSR_SEGMENT_LENGTH {
50        return Err(InvalidPackageName::new(format!(
51            "jsr: scope/package name cannot exceed {MAX_JSR_SEGMENT_LENGTH} characters"
52        )));
53    }
54    Ok(())
55}
56
57/// `EcosystemFormatter` for Deno manifests (D4).
58pub struct DenoFormatter;
59
60impl PackageNaming for DenoFormatter {
61    /// Validates the scheme-qualified name: `jsr:` requires the scoped `@scope/name`
62    /// form (mirroring JSR's own registration rules); `npm:` delegates to npm's own
63    /// validator on the bare name (`ECOSYSTEM_GUIDE.md` "npm Package Name Validation");
64    /// any other/missing scheme is rejected outright.
65    fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
66        match split_scheme(name) {
67            Some((Scheme::Jsr, rest)) => {
68                let Some((scope, pkg)) = split_scoped(rest) else {
69                    return Err(InvalidPackageName::new(
70                        "jsr: packages must be scoped as @scope/name",
71                    ));
72                };
73                validate_jsr_segment(scope)?;
74                validate_jsr_segment(pkg)?;
75                Ok(())
76            }
77            Some((Scheme::Npm, rest)) => deps_npm::NpmFormatter.validate_package_name(rest),
78            None => Err(InvalidPackageName::new(
79                "unsupported specifier scheme (expected jsr: or npm:)",
80            )),
81        }
82    }
83}
84
85impl PackageRendering for DenoFormatter {
86    fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
87        let version = version.as_str();
88        // `version_range` covers only the version text inside the specifier string — no
89        // quotes, no '@' — exactly like npm's (`deps-npm/src/formatter.rs`).
90        version.to_string()
91    }
92
93    fn package_url(&self, name: &PackageName) -> String {
94        match split_scheme(name.as_str()) {
95            Some((Scheme::Jsr, rest)) => match split_scoped(rest) {
96                Some((scope, pkg)) => crate::registry::jsr_package_url(scope, pkg),
97                None => {
98                    warn_rejected_value(
99                        "split_scoped",
100                        "deno jsr package display formatting",
101                        name.as_str(),
102                    );
103                    String::new()
104                }
105            },
106            Some((Scheme::Npm, rest)) => deps_npm::package_url(rest),
107            None => {
108                warn_rejected_value(
109                    "split_scheme",
110                    "deno package display formatting",
111                    name.as_str(),
112                );
113                String::new()
114            }
115        }
116    }
117}
118
119impl RequirementResolution for DenoFormatter {
120    /// Compiles `requirement` via `node_semver::Range`, the same crate `deps-npm` uses —
121    /// correct for JSR too, since JSR mandates semver.
122    fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
123        node_semver::Range::parse(requirement.as_str())
124            .ok()
125            .map(|req| Box::new(NodeSemverMatcher(req)) as Box<dyn RequirementMatcher>)
126    }
127}
128
129impl DiagnosticMessages for DenoFormatter {
130    /// S2: adopts `deps-npm`'s wording for the *whole* formatter (both `jsr:` and `npm:`
131    /// specifiers), rather than the `deps-core` defaults ("yanked"). Spec.md US-002/
132    /// FR-013 treat cross-ecosystem wording divergence for the same npm package as a
133    /// first-class bug, and `EcosystemFormatter::yanked_message`/`yanked_label` take only
134    /// `&self` (no `&dyn Dependency`), so a per-scheme override is not possible without a
135    /// `deps-core` trait change — this is the zero-cost fix available today.
136    fn yanked_message(&self) -> &'static str {
137        "This version is deprecated"
138    }
139
140    fn yanked_label(&self) -> &'static str {
141        "*(deprecated)*"
142    }
143}
144
145impl DiagnosticPolicy for DenoFormatter {
146    /// Scheme-aware (#448, fixing the #436 M1 divergence): for `npm:` specifiers, returns
147    /// `false` unconditionally, mirroring `NpmFormatter::yanked_diagnostic_applies_to`'s
148    /// post-#436 behavior — an exact-pin `npm:` dependency in `deno.json` (e.g.
149    /// `npm:lodash@4.17.20`) no longer surfaces this diagnostic, consistent with the
150    /// equivalent exact-pin `package.json` dependency.
151    ///
152    /// For `jsr:` specifiers, applies unconditionally (`true`), matching the trait default
153    /// that Cargo/PyPI/Dart already rely on (#454, dropping the exact-pin-only restriction
154    /// this hook carried before #448 made it scheme-aware). Unlike npm's `deprecated` — which
155    /// #436 found routinely applied to *every* published version at once, making this
156    /// diagnostic redundant with the package-level deprecation diagnostic (#205) — JSR's
157    /// `yanked` flag has no package-level counterpart to conflate with:
158    /// [`JsrVersion`](crate::types::JsrVersion)'s `impl_version!` invocation leaves
159    /// `deprecation` unset, so `Version::deprecation()` is structurally `None` for every JSR
160    /// version and #205 never fires for `jsr:` dependencies. There is also no data gap: JSR's
161    /// `meta.json` (`JsrRegistry::get_versions`) returns every version's `yanked` flag in one
162    /// fetch, identical whether the manifest requirement is a range or an exact pin.
163    fn yanked_diagnostic_applies_to(
164        &self,
165        dep: &dyn Dependency,
166        _requirement: &VersionReq,
167    ) -> bool {
168        match split_scheme(dep.name().as_str()) {
169            Some((Scheme::Npm, _)) => false,
170            // `None` is unreachable in practice — every parser-produced `DenoDependency` name
171            // is scheme-qualified (see `package_url`'s `warn_rejected_value` handling of the
172            // same case above) — folded into the `jsr:` unconditional-true path as a harmless
173            // default.
174            Some((Scheme::Jsr, _)) | None => true,
175        }
176    }
177}
178
179impl SourcePolicy for DenoFormatter {}
180
181impl OsvNaming for DenoFormatter {
182    /// `npm:` dependencies map to OSV's `npm` ecosystem via their bare name (D5);
183    /// `jsr:` dependencies return `None` — OSV has no JSR ecosystem (live-verified:
184    /// `POST api.osv.dev/v1/query` with `{"ecosystem":"JSR"}` returns `code 3, invalid
185    /// ecosystem`) — which cleanly skips them from the scan rather than risking a
186    /// cross-registry name collision.
187    fn osv_package_name(&self, dep: &dyn Dependency) -> Option<String> {
188        match split_scheme(dep.name().as_str()) {
189            Some((Scheme::Npm, rest)) => Some(rest.to_string()),
190            _ => None,
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    use deps_core::test_util::capture_tracing_output;
200
201    #[test]
202    fn test_package_url_jsr() {
203        let formatter = DenoFormatter;
204        assert_eq!(
205            formatter.package_url(&PackageName::new("jsr:@std/fs")),
206            "https://jsr.io/@std/fs"
207        );
208    }
209
210    #[test]
211    fn test_package_url_npm() {
212        let formatter = DenoFormatter;
213        assert_eq!(
214            formatter.package_url(&PackageName::new("npm:react")),
215            "https://www.npmjs.com/package/react"
216        );
217    }
218
219    #[test]
220    fn test_package_url_npm_scoped() {
221        let formatter = DenoFormatter;
222        assert_eq!(
223            formatter.package_url(&PackageName::new("npm:@types/node")),
224            "https://www.npmjs.com/package/@types/node"
225        );
226    }
227
228    #[test]
229    fn test_package_url_unroutable_scheme_is_empty() {
230        let formatter = DenoFormatter;
231        assert_eq!(formatter.package_url(&PackageName::new("unknown:x")), "");
232    }
233
234    #[test]
235    fn test_package_url_jsr_unscoped_is_empty() {
236        // `split_scoped` fails for a `jsr:` specifier missing the `@scope/` prefix.
237        let formatter = DenoFormatter;
238        assert_eq!(formatter.package_url(&PackageName::new("jsr:std")), "");
239    }
240
241    #[test]
242    fn test_package_url_jsr_unscoped_logs_warn_rejected_value() {
243        // #380 B2/B3: `split_scoped` failure (missing `@scope/`) must warn, not silently
244        // drop the value.
245        let formatter = DenoFormatter;
246        let output = capture_tracing_output(|| {
247            let _ = formatter.package_url(&PackageName::new("jsr:std"));
248        });
249        assert!(output.contains("split_scoped"), "output was: {output}");
250        assert!(
251            output.contains("deno jsr package display formatting"),
252            "output was: {output}"
253        );
254    }
255
256    #[test]
257    fn test_package_url_unroutable_scheme_logs_warn_rejected_value() {
258        let formatter = DenoFormatter;
259        let output = capture_tracing_output(|| {
260            let _ = formatter.package_url(&PackageName::new("unknown:x"));
261        });
262        assert!(output.contains("split_scheme"), "output was: {output}");
263        assert!(
264            output.contains("deno package display formatting"),
265            "output was: {output}"
266        );
267    }
268
269    #[test]
270    fn test_package_url_jsr_accepted_logs_no_warn() {
271        let formatter = DenoFormatter;
272        let output = capture_tracing_output(|| {
273            let _ = formatter.package_url(&PackageName::new("jsr:@std/fs"));
274        });
275        assert!(output.is_empty(), "output was: {output}");
276    }
277
278    #[test]
279    fn test_yanked_wording_matches_npm() {
280        let formatter = DenoFormatter;
281        assert_eq!(formatter.yanked_message(), "This version is deprecated");
282        assert_eq!(formatter.yanked_label(), "*(deprecated)*");
283    }
284
285    #[test]
286    fn test_validate_package_name_jsr_requires_scope() {
287        let formatter = DenoFormatter;
288        assert!(formatter.validate_package_name("jsr:@std/fs").is_ok());
289        assert!(formatter.validate_package_name("jsr:std").is_err());
290        assert!(formatter.validate_package_name("jsr:@std").is_err());
291    }
292
293    #[test]
294    fn test_validate_package_name_jsr_rejects_dot_segments() {
295        // S-L1: without this, `jsr:@a/..` builds a URL that path-normalizes away from
296        // `jsr.io`'s intended `/@scope/pkg` shape.
297        let formatter = DenoFormatter;
298        assert!(formatter.validate_package_name("jsr:@a/..").is_err());
299        assert!(formatter.validate_package_name("jsr:@../x").is_err());
300        assert!(formatter.validate_package_name("jsr:@./x").is_err());
301        assert!(formatter.validate_package_name("jsr:@a/.hidden").is_err());
302    }
303
304    #[test]
305    fn test_validate_package_name_jsr_rejects_overlong_segment() {
306        let formatter = DenoFormatter;
307        let too_long = "a".repeat(65);
308        assert!(
309            formatter
310                .validate_package_name(&format!("jsr:@{too_long}/pkg"))
311                .is_err()
312        );
313        assert!(
314            formatter
315                .validate_package_name(&format!("jsr:@scope/{too_long}"))
316                .is_err()
317        );
318        let max_len = "a".repeat(64);
319        assert!(
320            formatter
321                .validate_package_name(&format!("jsr:@{max_len}/pkg"))
322                .is_ok()
323        );
324    }
325
326    #[test]
327    fn test_validate_package_name_npm_delegates_to_npm_rules() {
328        let formatter = DenoFormatter;
329        assert!(formatter.validate_package_name("npm:react").is_ok());
330        assert!(formatter.validate_package_name("npm:@types/node").is_ok());
331        // npm rejects a reserved name.
332        assert!(formatter.validate_package_name("npm:node_modules").is_err());
333    }
334
335    #[test]
336    fn test_validate_package_name_rejects_unknown_scheme() {
337        let formatter = DenoFormatter;
338        assert!(
339            formatter
340                .validate_package_name("https://example.com")
341                .is_err()
342        );
343    }
344
345    #[test]
346    fn test_compile_requirement_satisfiable() {
347        let formatter = DenoFormatter;
348        let matcher = formatter
349            .compile_requirement(&VersionReq::new("^1.0.0"))
350            .expect("valid node-semver range must compile");
351        assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
352        assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
353    }
354
355    #[test]
356    fn test_compile_requirement_dist_tag_returns_none() {
357        // M6: dist-tags like `npm:react@latest` fail `node_semver::Range::parse`, so
358        // `compile_requirement` correctly returns `None` — matching how package.json
359        // already handles dist-tags, not an accident.
360        let formatter = DenoFormatter;
361        assert!(
362            formatter
363                .compile_requirement(&VersionReq::new("latest"))
364                .is_none()
365        );
366        assert!(
367            formatter
368                .compile_requirement(&VersionReq::new("next"))
369                .is_none()
370        );
371    }
372
373    // Mirrors the sole call site (`crate::lsp_helpers::diagnostics::generate_diagnostics_from_cache`),
374    // where `requirement` is always `dep.version_requirement().unwrap()` — never an
375    // unrelated pair, even though `DenoFormatter` itself only consults `dep.name()`.
376    fn test_dep(name: &str, requirement: &str) -> crate::types::DenoDependency {
377        crate::types::DenoDependency {
378            name: PackageName::new(name),
379            name_range: tower_lsp_server::ls_types::Range::default(),
380            version_req: Some(VersionReq::new(requirement)),
381            version_range: None,
382            section: crate::types::DenoDependencySection::Imports,
383        }
384    }
385
386    /// #454: drops the pre-#448-blindness exact-pin-only restriction — a `jsr:` range
387    /// requirement now surfaces the diagnostic just like an exact pin, matching
388    /// Cargo/PyPI/Dart (which never override this hook and so keep the trait default).
389    #[test]
390    fn test_yanked_diagnostic_applies_to_jsr_scheme_always_true() {
391        let formatter = DenoFormatter;
392        for requirement in ["1.2.3", "^1.2.3", "~1.2.3", "*"] {
393            let dep = test_dep("jsr:@std/fs", requirement);
394            assert!(
395                formatter.yanked_diagnostic_applies_to(&dep, &VersionReq::new(requirement)),
396                "expected {requirement:?} to be accepted for jsr: scheme"
397            );
398        }
399    }
400
401    /// #448: fixes the #436 M1 cross-ecosystem divergence — an `npm:` specifier in
402    /// `deno.json` no longer surfaces this diagnostic, for any requirement shape,
403    /// consistent with `NpmFormatter`'s post-#436 behavior for `package.json`.
404    #[test]
405    fn test_yanked_diagnostic_applies_to_npm_scheme_always_false() {
406        let formatter = DenoFormatter;
407        for requirement in ["1.2.3", "^1.2.3", "~1.2.3", "*"] {
408            let dep = test_dep("npm:lodash", requirement);
409            assert!(
410                !formatter.yanked_diagnostic_applies_to(&dep, &VersionReq::new(requirement)),
411                "expected {requirement:?} to be rejected for npm: scheme"
412            );
413        }
414    }
415
416    #[test]
417    fn test_osv_package_name_npm_maps_to_bare_name() {
418        let formatter = DenoFormatter;
419
420        struct FakeDep(deps_core::PackageName);
421        impl Dependency for FakeDep {
422            fn name(&self) -> &deps_core::PackageName {
423                &self.0
424            }
425            fn name_range(&self) -> tower_lsp_server::ls_types::Range {
426                tower_lsp_server::ls_types::Range::default()
427            }
428            fn version_requirement(&self) -> Option<&VersionReq> {
429                None
430            }
431            fn version_range(&self) -> Option<tower_lsp_server::ls_types::Range> {
432                None
433            }
434            fn source(&self) -> deps_core::parser::DependencySource {
435                deps_core::parser::DependencySource::Registry
436            }
437            fn as_any(&self) -> &dyn std::any::Any {
438                self
439            }
440        }
441
442        let npm_dep = FakeDep(PackageName::new("npm:react"));
443        assert_eq!(
444            formatter.osv_package_name(&npm_dep),
445            Some("react".to_string())
446        );
447
448        let jsr_dep = FakeDep(PackageName::new("jsr:@std/fs"));
449        assert_eq!(formatter.osv_package_name(&jsr_dep), None);
450    }
451}