Skip to main content

deps_composer/
types.rs

1use tower_lsp_server::ls_types::Range;
2
3/// Parsed dependency from composer.json with position tracking.
4///
5/// Stores all information about a dependency declaration, including its name,
6/// version requirement, and source positions for LSP operations.
7///
8/// # Examples
9///
10/// ```
11/// use deps_composer::types::{ComposerDependency, ComposerSection};
12/// use tower_lsp_server::ls_types::{Position, Range};
13///
14/// let dep = ComposerDependency {
15///     name: "symfony/console".into(),
16///     name_range: Range::new(Position::new(3, 4), Position::new(3, 20)),
17///     version_req: Some("^6.0".into()),
18///     version_range: Some(Range::new(Position::new(3, 23), Position::new(3, 28))),
19///     section: ComposerSection::Require,
20/// };
21///
22/// assert_eq!(dep.name, "symfony/console");
23/// assert!(matches!(dep.section, ComposerSection::Require));
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ComposerDependency {
27    pub name: deps_core::PackageName,
28    pub name_range: Range,
29    pub version_req: Option<deps_core::VersionReq>,
30    pub version_range: Option<Range>,
31    pub section: ComposerSection,
32}
33
34deps_core::impl_dependency!(ComposerDependency {
35    name: name,
36    name_range: name_range,
37    version: version_req,
38    version_range: version_range,
39});
40
41/// Section in composer.json where a dependency is declared.
42///
43/// # Examples
44///
45/// ```
46/// use deps_composer::types::ComposerSection;
47///
48/// let section = ComposerSection::Require;
49/// assert!(matches!(section, ComposerSection::Require));
50/// ```
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ComposerSection {
53    /// Production dependencies (`require`)
54    Require,
55    /// Development dependencies (`require-dev`)
56    RequireDev,
57}
58
59/// Version information for a Packagist package.
60///
61/// Retrieved from the Packagist v2 API.
62/// Contains version number and abandonment status.
63///
64/// # Examples
65///
66/// ```
67/// use deps_composer::types::ComposerVersion;
68///
69/// let version = ComposerVersion {
70///     version: "6.0.0".into(),
71///     version_normalized: "6.0.0.0".into(),
72///     abandoned: false,
73///     deprecation: None,
74///     published_at: None,
75/// };
76///
77/// assert!(!version.abandoned);
78/// ```
79#[derive(Debug, Clone)]
80pub struct ComposerVersion {
81    pub version: deps_core::ConcreteVersion,
82    pub version_normalized: String,
83    pub abandoned: bool,
84    /// Package-level deprecation payload (issue #205), derived from Packagist's
85    /// `abandoned` field. `Some(Deprecation { reason: None, replacement: None })` for a
86    /// bare `"abandoned": true`; `replacement` populated when `abandoned` names a
87    /// successor package. `None` only when `abandoned` is absent/`false`/`null`.
88    pub deprecation: Option<deps_core::Deprecation>,
89    /// Publish timestamp, parsed from the p2 entry's own `time` field.
90    ///
91    /// Taken only from the entry itself, never inherited from a previous
92    /// minified entry — the Packagist v2 API's field-inheritance scheme does
93    /// not apply to `time`, since inheriting it would attribute one
94    /// release's publish date to another.
95    pub published_at: Option<deps_core::PublishTime>,
96}
97
98/// Whether `s` contains Composer's short `-a`/`-b` stability alias (`-a1`,
99/// `-b2`, `-a.1`, or a bare `-a`/`-b`), matching `composer/semver`'s
100/// `-?(dev|alpha|a|beta|b|RC|rc|patch|p)(\.?\d+)?` grammar for the short
101/// forms.
102///
103/// Checked directly against the raw `version` rather than relying on
104/// Packagist's `version_normalized` field (which expands `-a1` to
105/// `-alpha1`, already caught by the default heuristic): that field falls
106/// back to `version.clone()` when Packagist omits it, which would silently
107/// drop this coverage for any entry missing it (#327 M2).
108fn has_short_stability_alias(s: &str) -> bool {
109    let bytes = s.as_bytes();
110    let mut i = 0;
111    while i + 1 < bytes.len() {
112        if bytes[i] == b'-' && matches!(bytes[i + 1], b'a' | b'b' | b'A' | b'B') {
113            let after = i + 2;
114            let follows_digit_or_end = bytes.get(after).is_none_or(u8::is_ascii_digit);
115            let follows_dot_digit = bytes.get(after) == Some(&b'.')
116                && bytes.get(after + 1).is_some_and(u8::is_ascii_digit);
117            if follows_digit_or_end || follows_dot_digit {
118                return true;
119            }
120        }
121        i += 1;
122    }
123    false
124}
125
126/// Whether `s` contains a Composer stability keyword (`alpha`/`a`, `beta`/`b`, `RC`, `dev`)
127/// directly adjacent to a numeric run with no separator (e.g. `1.0.0RC1`, `1.0.0a1`,
128/// `1.0.0dev`) — `composer/semver`'s modifier grammar makes the `[._-]?` separator before the
129/// keyword optional for every recognized word (matching
130/// [`crate::formatter::composer_stability_rank`]'s full word list), not just `alpha`/`beta`/
131/// `rc`. The hyphenated forms are already caught by
132/// [`deps_core::has_default_prerelease_marker`]'s `-rc`/`-alpha`/`-beta`/`-dev` substring
133/// checks and [`has_short_stability_alias`]'s hyphenated `-a`/`-b`.
134///
135/// Covering only three of the six recognized words here left this classifier disagreeing with
136/// [`crate::formatter::composer_version_stability_rank`] on bare separator-less short-alias/
137/// `dev` forms (`1.0.0a1`, `1.0.0dev`): the rank function ranks them as prerelease (via the
138/// same word list `composer_stability_rank` uses), but this function said "not prerelease" —
139/// and since `registry.rs`'s `effective_minimum_stability_rank` uses this function for "does
140/// the requirement itself pin a prerelease" while the version-side filter uses the rank
141/// function, disagreement meant a pin like `1.0.0a1` could never match its own version — the
142/// exact #421 S1 failure mode, reintroduced by the original #424 S3 fix instead of being
143/// closed by it (critique S2).
144///
145/// Checked directly on the raw string rather than relying on a hyphen-inserting
146/// `version_normalized` to have expanded it: a requirement string has no `version_normalized`
147/// at all, and even for a real [`ComposerVersion`], Packagist supplying `version_normalized`
148/// is not guaranteed, so classification must not depend on which of the two happens to run
149/// first or be present (#424 S3).
150///
151/// The keyword may also sit directly after a `.`/`_` separator that is itself digit-adjacent
152/// (e.g. `2.6.3.alpha`, a live `api-platform/core` tag) — not just directly after a digit —
153/// since `composer_stability_rank`'s companion parser (`split_composer_core_and_suffix`)
154/// already strips a leading `.`/`_`/`-` separator before reading the qualifier word, so the
155/// rank function sees `2.6.3.alpha` as prerelease while this substring scan previously did
156/// not, the same #421 S1 failure mode S2 fixed for the hyphen-less case (#424 critique N3).
157/// Deliberately excludes `-`: a hyphen-separated qualifier is already covered by
158/// [`deps_core::has_default_prerelease_marker`]/[`has_short_stability_alias`] via a different
159/// algorithm, so including it here would only duplicate, not extend, coverage.
160fn has_separatorless_stability_keyword(s: &str) -> bool {
161    let lower = s.to_lowercase();
162    let bytes = lower.as_bytes();
163    for keyword in ["alpha", "beta", "rc", "dev", "a", "b"] {
164        let mut start = 0;
165        while let Some(rel) = lower[start..].find(keyword) {
166            let idx = start + rel;
167            let preceded_ok = idx > 0
168                && (bytes[idx - 1].is_ascii_digit()
169                    || (matches!(bytes[idx - 1], b'.' | b'_')
170                        && idx > 1
171                        && bytes[idx - 2].is_ascii_digit()));
172            let after = idx + keyword.len();
173            let followed_by_digit_or_end = bytes.get(after).is_none_or(u8::is_ascii_digit);
174            let followed_by_dot_digit = bytes.get(after) == Some(&b'.')
175                && bytes.get(after + 1).is_some_and(u8::is_ascii_digit);
176            if preceded_ok && (followed_by_digit_or_end || followed_by_dot_digit) {
177                return true;
178            }
179            start = idx + 1;
180        }
181    }
182    false
183}
184
185/// Whether `s` carries any Composer stability marker: `deps-core`'s default hyphen-substring
186/// heuristic (`-alpha`, `-beta`, `-rc`, ...), Composer's short `-a`/`-b` alias (see
187/// [`has_short_stability_alias`]), or a separator-less keyword suffix (see
188/// [`has_separatorless_stability_keyword`]).
189///
190/// Shared by [`ComposerVersion`]'s `is_prerelease()` (via `impl_version!` below, applied to a
191/// concrete version string) and `registry.rs`'s "is this requirement itself prerelease-bearing"
192/// check (applied to a requirement string, e.g. an exact `2.0.0-a1` pin) — both sides must use
193/// the same predicate, or a requirement naming a short-alias prerelease would be misclassified
194/// as stable while the version it pins is correctly classified as unstable, making it
195/// impossible to ever satisfy (#421 S1).
196pub(crate) fn is_prerelease_marker(s: &str) -> bool {
197    deps_core::has_default_prerelease_marker(s)
198        || has_short_stability_alias(s)
199        || has_separatorless_stability_keyword(s)
200}
201
202// Packagist versions aren't strict semver, so this layers a Composer-specific
203// short-stability-alias check on the raw `version` on top of `deps-core`'s
204// default hyphen-substring heuristic instead of relying on it alone, which
205// misses that gap (#327 M2). The `version_normalized` check is defense in
206// depth, not load-bearing: `has_short_stability_alias` already covers the
207// short-alias case directly on `version`. No `dev-` branch-alias check here:
208// `expand_minified_versions` (`registry.rs`) already filters every
209// `dev-`-prefixed version before a `ComposerVersion` is ever constructed, so
210// that case never reaches `is_prerelease()` (#327 M1).
211deps_core::impl_version!(ComposerVersion {
212    version: version,
213    status: |v: &ComposerVersion| deps_core::RemovalStatus::from_advisory(v.abandoned),
214    published_at: published_at,
215    prerelease: |v: &ComposerVersion| {
216        is_prerelease_marker(v.version.as_str())
217            || deps_core::has_default_prerelease_marker(&v.version_normalized)
218    },
219    deprecation: |v: &ComposerVersion| v.deprecation.as_ref(),
220});
221
222/// Package metadata from Packagist search.
223///
224/// Contains basic information about a Packagist package for display in
225/// completion suggestions.
226///
227/// # Examples
228///
229/// ```
230/// use deps_composer::types::ComposerPackage;
231///
232/// let pkg = ComposerPackage {
233///     name: deps_core::PackageName::new("symfony/console"),
234///     description: Some("Symfony Console Component".into()),
235///     repository: Some("https://github.com/symfony/console".into()),
236///     homepage: Some("https://packagist.org/packages/symfony/console".into()),
237///     latest_version: "6.0.0".into(),
238/// };
239///
240/// assert_eq!(pkg.name, "symfony/console");
241/// ```
242#[derive(Debug, Clone)]
243pub struct ComposerPackage {
244    pub name: deps_core::PackageName,
245    pub description: Option<String>,
246    pub repository: Option<String>,
247    pub homepage: Option<String>,
248    pub latest_version: deps_core::ConcreteVersion,
249}
250
251deps_core::impl_metadata!(ComposerPackage {
252    name: name,
253    description: description,
254    repository: repository,
255    documentation: homepage,
256    latest_version: latest_version,
257});
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use deps_core::{Metadata, Version};
263    use std::assert_matches;
264    use tower_lsp_server::ls_types::Position;
265
266    #[test]
267    fn test_composer_dependency_creation() {
268        let dep = ComposerDependency {
269            name: "symfony/console".into(),
270            name_range: Range::new(Position::new(0, 0), Position::new(0, 15)),
271            version_req: Some("^6.0".into()),
272            version_range: Some(Range::new(Position::new(0, 18), Position::new(0, 22))),
273            section: ComposerSection::Require,
274        };
275
276        assert_eq!(dep.name, "symfony/console");
277        assert_eq!(dep.version_req, Some("^6.0".into()));
278        assert_matches!(dep.section, ComposerSection::Require);
279    }
280
281    #[test]
282    fn test_composer_section_variants() {
283        assert_matches!(ComposerSection::Require, ComposerSection::Require);
284        assert_matches!(ComposerSection::RequireDev, ComposerSection::RequireDev);
285    }
286
287    #[test]
288    fn test_composer_version_trait() {
289        let version = ComposerVersion {
290            version: "2.0.0".into(),
291            version_normalized: "2.0.0.0".into(),
292            abandoned: true,
293            deprecation: None,
294            published_at: None,
295        };
296
297        assert_eq!(version.version_string(), "2.0.0");
298        assert_eq!(
299            version.removal_status(),
300            deps_core::RemovalStatus::AdvisoryDeprecated
301        );
302        assert!(!version.removal_status().blocks_resolution());
303    }
304
305    /// #205: `Version::deprecation()` reads the dedicated field, independent of the
306    /// `removal_status`-driving `abandoned` bool.
307    #[test]
308    fn test_composer_version_deprecation_accessor() {
309        let with_payload = ComposerVersion {
310            version: "2.0.0".into(),
311            version_normalized: "2.0.0.0".into(),
312            abandoned: true,
313            deprecation: Some(deps_core::Deprecation {
314                reason: None,
315                replacement: Some("other/package".to_string()),
316            }),
317            published_at: None,
318        };
319        assert_eq!(
320            with_payload
321                .deprecation()
322                .and_then(|d| d.replacement.as_deref()),
323            Some("other/package")
324        );
325
326        let without_payload = ComposerVersion {
327            version: "1.0.0".into(),
328            version_normalized: "1.0.0.0".into(),
329            abandoned: false,
330            deprecation: None,
331            published_at: None,
332        };
333        assert!(without_payload.deprecation().is_none());
334    }
335
336    #[test]
337    fn test_composer_version_short_stability_alias_is_prerelease() {
338        // Regression test for #327 M2: Composer's short "-a"/"-b" stability
339        // aliases, with `version_normalized` present and already expanded to
340        // "-alpha"/"-beta" the way Packagist normally returns it.
341        let alpha = ComposerVersion {
342            version: "1.0.0-a1".into(),
343            version_normalized: "1.0.0.0-alpha1".into(),
344            abandoned: false,
345            deprecation: None,
346            published_at: None,
347        };
348        let beta = ComposerVersion {
349            version: "1.0.0-b1".into(),
350            version_normalized: "1.0.0.0-beta1".into(),
351            abandoned: false,
352            deprecation: None,
353            published_at: None,
354        };
355        assert!(alpha.is_prerelease());
356        assert!(beta.is_prerelease());
357    }
358
359    #[test]
360    fn test_composer_version_short_stability_alias_without_normalized_field() {
361        // Regression test for #327 M2: when Packagist omits
362        // `version_normalized`, `expand_minified_versions` falls back to
363        // `version.clone()` (crates/deps-composer/src/registry.rs), so the
364        // short-alias check must not depend on `version_normalized` having
365        // been expanded — it must catch the alias directly on `version`.
366        for (name, is_alias) in [
367            ("1.0.0-a1", true),
368            ("1.0.0-b2", true),
369            ("1.0.0-a", true),
370            ("1.0.0-a.1", true),
371            ("1.0.0-alpha1", true), // already caught by the default heuristic
372            ("1.0.0-abandoned", false),
373            ("1.0.0", false),
374        ] {
375            let version = ComposerVersion {
376                version: name.into(),
377                version_normalized: name.into(), // no expansion happened
378                abandoned: false,
379                deprecation: None,
380                published_at: None,
381            };
382            assert_eq!(
383                version.is_prerelease(),
384                is_alias,
385                "{name} prerelease mismatch"
386            );
387        }
388    }
389
390    /// #424 S3: a separator-less stability keyword suffix (`1.0.0RC1`, no hyphen before
391    /// `RC`) must be classified as prerelease by the primary `is_prerelease_marker` path
392    /// alone — without relying on `version_normalized` to have expanded it.
393    #[test]
394    fn test_is_prerelease_marker_separatorless_suffix() {
395        for (s, expected) in [
396            ("1.0.0RC1", true),
397            ("2.0.0beta3", true),
398            ("2.0.0alpha1", true),
399            ("1.0.0-RC1", true), // hyphenated form still caught (existing heuristic)
400            ("1.0.0", false),
401            ("1.0.0-abandoned", false),
402        ] {
403            assert_eq!(
404                is_prerelease_marker(s),
405                expected,
406                "{s} prerelease-marker mismatch"
407            );
408        }
409    }
410
411    /// #424 critique S2: the short-alias (`a`/`b`) and `dev` separator-less forms must also
412    /// be recognized — not just `alpha`/`beta`/`rc` — or this classifier disagrees with
413    /// `composer_version_stability_rank` on exactly these forms (see that function's rank
414    /// test `test_is_prerelease_marker_separatorless_suffix_agrees_with_rank` below).
415    #[test]
416    fn test_is_prerelease_marker_separatorless_short_alias_and_dev() {
417        for (s, expected) in [
418            ("1.0.0a1", true),
419            ("1.0.0b1", true),
420            ("1.0.0dev", true),
421            ("2.0.0A1", true),
422            ("2.0.0B2", true),
423        ] {
424            assert_eq!(
425                is_prerelease_marker(s),
426                expected,
427                "{s} prerelease-marker mismatch"
428            );
429        }
430    }
431
432    /// #424 critique N3: a dot/underscore-separated qualifier (`2.6.3.alpha`, a live
433    /// `api-platform/core` tag; `version_normalized: "2.6.3.0-alpha"`) must also be recognized
434    /// — the separator before the keyword need not be a bare digit, since
435    /// `split_composer_core_and_suffix` (the rank function's own parser) already strips a
436    /// leading `.`/`_`/`-` before reading the qualifier word.
437    #[test]
438    fn test_is_prerelease_marker_dot_underscore_separated_suffix() {
439        for (s, expected) in [
440            ("2.6.3.alpha", true),
441            ("2.6.3_alpha", true),
442            ("2.6.3.beta1", true),
443            ("2.6.3_dev", true),
444            ("2.6.3.a1", true),
445            ("2.6.3_b2", true),
446            ("2.6.3.rc1", true),
447        ] {
448            assert_eq!(
449                is_prerelease_marker(s),
450                expected,
451                "{s} prerelease-marker mismatch"
452            );
453        }
454    }
455
456    /// #424 critique S2/N3: `is_prerelease_marker` (substring-scan classifier, used for
457    /// requirement strings) and `composer_version_stability_rank` (anchored-parse classifier,
458    /// used for candidate versions) must agree on every grammar-valid bare version-shaped
459    /// string — a real generated cross-product, not a hand-picked table, so a future addition
460    /// to either classifier's word/separator list that misses the other is actually caught,
461    /// not just the handful of shapes someone thought to write down.
462    ///
463    /// Cross product: word (Composer's full recognized set) × separator (the `[._-]?`
464    /// grammar's optional-separator axis, including no separator at all) × `v`/`V` prefix ×
465    /// numeric suffix shape = 216 grammar-valid forms. Critique N3's first pass covered only
466    /// the `-` and `""` separators (the two that already agreed); this covers all four,
467    /// closing the gap on the entire `.`/`_` axis (e.g. the live `api-platform/core` tag
468    /// `v2.6.3.alpha`) that the narrower table never exercised.
469    #[test]
470    fn test_is_prerelease_marker_agrees_with_rank_cross_product() {
471        let mut mismatches = Vec::new();
472        for word in ["alpha", "beta", "rc", "dev", "a", "b"] {
473            for sep in ["-", ".", "_", ""] {
474                for prefix in ["", "v", "V"] {
475                    for suffix in ["", "1", ".1"] {
476                        let s = format!("{prefix}2.6.3{sep}{word}{suffix}");
477                        let is_prerelease = is_prerelease_marker(&s);
478                        let is_stable_rank = crate::formatter::composer_version_stability_rank(&s)
479                            == crate::formatter::COMPOSER_STABLE_RANK;
480                        if is_prerelease == is_stable_rank {
481                            mismatches.push(s);
482                        }
483                    }
484                }
485            }
486        }
487        assert!(
488            mismatches.is_empty(),
489            "{} / 216 grammar-valid forms disagree between is_prerelease_marker and \
490             composer_version_stability_rank: {mismatches:?}",
491            mismatches.len()
492        );
493    }
494
495    /// #424 S3: a `ComposerVersion` whose `version_normalized` was never hyphen-expanded
496    /// (mirrors a Packagist response that returns the separator-less form verbatim in both
497    /// fields) must still classify as prerelease via the raw `version` alone.
498    #[test]
499    fn test_composer_version_separatorless_rc_is_prerelease_without_normalized_expansion() {
500        let version = ComposerVersion {
501            version: "2.0.0RC1".into(),
502            version_normalized: "2.0.0RC1".into(),
503            abandoned: false,
504            deprecation: None,
505            published_at: None,
506        };
507        assert!(version.is_prerelease());
508    }
509
510    #[test]
511    fn test_composer_version_stable_is_not_prerelease() {
512        let version = ComposerVersion {
513            version: "6.0.0".into(),
514            version_normalized: "6.0.0.0".into(),
515            abandoned: false,
516            deprecation: None,
517            published_at: None,
518        };
519        assert!(!version.is_prerelease());
520    }
521
522    #[test]
523    fn test_composer_package_metadata_trait() {
524        let pkg = ComposerPackage {
525            name: "monolog/monolog".into(),
526            description: Some(
527                "Sends your logs to files, sockets, inboxes, databases and various web services"
528                    .into(),
529            ),
530            repository: Some("https://github.com/Seldaek/monolog".into()),
531            homepage: Some("https://packagist.org/packages/monolog/monolog".into()),
532            latest_version: "3.0.0".into(),
533        };
534
535        assert_eq!(pkg.name(), "monolog/monolog");
536        assert_eq!(pkg.latest_version(), "3.0.0");
537        assert_eq!(pkg.repository(), Some("https://github.com/Seldaek/monolog"));
538    }
539}