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}