Skip to main content

deps_dart/
formatter.rs

1//! Version formatting for Dart ecosystem.
2
3use crate::version::{version_matches_constraint, version_matches_normalized_constraint};
4use deps_core::ConcreteVersion;
5use deps_core::InvalidPackageName;
6use deps_core::PackageName;
7use deps_core::VersionReq;
8use deps_core::lsp_helpers::{
9    DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
10    RequirementMatcher, RequirementResolution, SourcePolicy,
11};
12use deps_core::normalize_operator_spacing;
13
14/// Whether `name` is a valid Dart identifier: pub.dev requires every package name to be one,
15/// per <https://dart.dev/tools/pub/pubspec#name> — ASCII letters/digits/`_` only, starting
16/// with a letter or `_` (never a digit).
17fn is_valid_dart_identifier(name: &str) -> bool {
18    let mut chars = name.chars();
19    chars
20        .next()
21        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
22        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
23}
24
25/// pub.dev constraint matcher, compiled once per dependency by
26/// [`DartFormatter::compile_requirement`]. Holds the requirement already run through
27/// [`normalize_operator_spacing`] so per-candidate matching never re-normalizes or
28/// allocates. `version_matches_normalized_constraint` is a hand-rolled comparator with no
29/// external parser to fail on, so this always decides (`Some`).
30struct PubDevMatcher(String);
31
32impl RequirementMatcher for PubDevMatcher {
33    fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
34        let version = version.as_str();
35        Some(version_matches_normalized_constraint(version, &self.0))
36    }
37}
38
39pub struct DartFormatter;
40
41impl PackageNaming for DartFormatter {
42    /// Lints `name` against pub.dev's rule that every package name must be a valid Dart
43    /// identifier (see `is_valid_dart_identifier`), so a structurally invalid name is
44    /// reported as "Invalid package name" instead of falling through to a registry lookup
45    /// and rendering the generic "Registry lookup failed" diagnostic (#402).
46    ///
47    /// # Errors
48    ///
49    /// Returns [`InvalidPackageName`] if `name` is empty, starts with a digit, or contains a
50    /// character other than an ASCII letter, digit, or `_`.
51    fn validate_package_name(&self, name: &str) -> Result<(), InvalidPackageName> {
52        if name.is_empty() {
53            return Err(InvalidPackageName::new("name cannot be empty"));
54        }
55        if !is_valid_dart_identifier(name) {
56            return Err(InvalidPackageName::new(
57                "name must be a valid Dart identifier: only ASCII letters, digits, and '_', not starting with a digit",
58            ));
59        }
60        Ok(())
61    }
62}
63
64impl PackageRendering for DartFormatter {
65    fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
66        let version = version.as_str();
67        format!("^{version}")
68    }
69
70    fn package_url(&self, name: &PackageName) -> String {
71        crate::registry::package_url(name.as_str())
72    }
73}
74
75impl RequirementResolution for DartFormatter {
76    fn version_satisfies_requirement(&self, version: &ConcreteVersion, requirement: &str) -> bool {
77        let version = version.as_str();
78        version_matches_constraint(version, requirement)
79    }
80
81    /// Compiles `requirement` into a `PubDevMatcher` using the same comparator as
82    /// `version_satisfies_requirement` — Dart constraints have no separate "loose" vs.
83    /// "precise" form to distinguish. Spaced-operator normalization runs once here, not
84    /// per candidate version.
85    fn compile_requirement(&self, requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
86        let normalized = normalize_operator_spacing(requirement.as_str().trim()).into_owned();
87        Some(Box::new(PubDevMatcher(normalized)))
88    }
89}
90
91impl DiagnosticMessages for DartFormatter {}
92
93impl DiagnosticPolicy for DartFormatter {}
94
95impl SourcePolicy for DartFormatter {}
96
97impl OsvNaming for DartFormatter {}
98
99#[cfg(test)]
100mod tests {
101    use super::*;
102
103    #[test]
104    fn test_format_version() {
105        let f = DartFormatter;
106        assert_eq!(
107            f.format_version_for_text_edit(&ConcreteVersion::new("1.0.0")),
108            "^1.0.0"
109        );
110        assert_eq!(
111            f.format_version_for_text_edit(&ConcreteVersion::new("6.1.2")),
112            "^6.1.2"
113        );
114    }
115
116    #[test]
117    fn test_package_url() {
118        let f = DartFormatter;
119        assert_eq!(
120            f.package_url(&PackageName::new("provider")),
121            "https://pub.dev/packages/provider"
122        );
123    }
124
125    #[test]
126    fn test_version_satisfies() {
127        let f = DartFormatter;
128        assert!(f.version_satisfies_requirement(&ConcreteVersion::new("1.5.0"), "^1.0.0"));
129        assert!(!f.version_satisfies_requirement(&ConcreteVersion::new("2.0.0"), "^1.0.0"));
130    }
131
132    #[test]
133    fn test_normalize_is_identity() {
134        let f = DartFormatter;
135        assert_eq!(
136            f.normalize_package_name(&PackageName::new("flutter_bloc")),
137            "flutter_bloc"
138        );
139    }
140
141    #[test]
142    fn test_compile_requirement_satisfiable() {
143        let f = DartFormatter;
144        let matcher = f
145            .compile_requirement(&VersionReq::new("^1.0.0"))
146            .expect("Dart requirement always compiles");
147        assert_eq!(matcher.matches(&ConcreteVersion::new("1.5.0")), Some(true));
148        assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
149    }
150
151    #[test]
152    fn test_compile_requirement_spaced_range() {
153        let f = DartFormatter;
154        let matcher = f
155            .compile_requirement(&VersionReq::new(">= 1.15.0 < 2.0.0"))
156            .expect("Dart requirement always compiles");
157        assert_eq!(matcher.matches(&ConcreteVersion::new("1.15.0")), Some(true));
158        assert_eq!(matcher.matches(&ConcreteVersion::new("1.99.0")), Some(true));
159        assert_eq!(matcher.matches(&ConcreteVersion::new("2.0.0")), Some(false));
160        assert_eq!(
161            matcher.matches(&ConcreteVersion::new("1.14.0")),
162            Some(false)
163        );
164    }
165
166    #[test]
167    fn test_validate_package_name_accepts_valid_names() {
168        let f = DartFormatter;
169        for name in ["provider", "flutter_bloc", "_private", "path9"] {
170            assert!(
171                f.validate_package_name(name).is_ok(),
172                "expected {name:?} to be accepted"
173            );
174        }
175    }
176
177    /// #402: a structurally invalid Dart package name must be reported as an invalid package
178    /// name, not forwarded to the registry lookup that produces the misleading generic
179    /// diagnostic.
180    #[test]
181    fn test_validate_package_name_rejects_invalid_names() {
182        let f = DartFormatter;
183        for name in ["", "9path", "my-package", "my package", "日本語"] {
184            assert!(
185                f.validate_package_name(name).is_err(),
186                "expected {name:?} to be rejected"
187            );
188        }
189    }
190}