Skip to main content

deps_composer/
parser.rs

1//! composer.json parser with position tracking.
2//!
3//! Parses composer.json files and extracts dependency information with precise
4//! source positions for LSP operations. Platform packages (php, ext-*, lib-*)
5//! are filtered out as they are not Packagist packages.
6
7use crate::types::{ComposerDependency, ComposerSection};
8use deps_core::Result;
9use deps_core::lsp_helpers::LineOffsetTable;
10use serde_json::Value;
11use std::any::Any;
12use tower_lsp_server::ls_types::{Range, Uri};
13
14/// Result of parsing a composer.json file.
15///
16/// Contains all non-platform dependencies found in the file with their positions.
17#[derive(Debug)]
18pub struct ComposerParseResult {
19    pub dependencies: Vec<ComposerDependency>,
20    pub uri: Uri,
21    /// Raw value of the manifest's own top-level `minimum-stability` field (e.g. `"beta"`),
22    /// if present — Composer's project-wide default stability floor, one of `dev`, `alpha`,
23    /// `beta`, `RC`, `stable` (case-insensitive). `None` when the field is absent, which
24    /// Composer itself treats as `stable` (#424).
25    ///
26    /// Consumers rank this word (`registry.rs`'s crate-private stability ranking) rather than
27    /// comparing it as a string — stored raw here rather than pre-ranked so a caller with no
28    /// interest in stability filtering (e.g. a future manifest-summary feature) is not forced
29    /// to depend on that ranking.
30    pub minimum_stability: Option<String>,
31}
32
33impl deps_core::ParseResult for ComposerParseResult {
34    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
35        self.dependencies
36            .iter()
37            .map(|d| d as &dyn deps_core::Dependency)
38            .collect()
39    }
40
41    fn workspace_root(&self) -> Option<&std::path::Path> {
42        None
43    }
44
45    fn uri(&self) -> &Uri {
46        &self.uri
47    }
48
49    fn as_any(&self) -> &dyn Any {
50        self
51    }
52}
53
54/// Returns true if the package is a platform requirement (not a Packagist package).
55///
56/// Platform packages include:
57/// - `php`, and its variants `php-64bit`, `php-ipv6`, `php-zts`, `php-debug` — PHP version
58///   requirements
59/// - `hhvm` — HHVM version requirement
60/// - `composer` — the Composer CLI version itself
61/// - `ext-*` — PHP extensions
62/// - `lib-*` — PHP libraries
63/// - `composer-plugin-api`, `composer-runtime-api` — Composer's own virtual packages
64///
65/// Mirrors Composer's `PlatformRepository` package set (#402 critique S1: an incomplete list
66/// here previously let a real platform requirement like `composer-plugin-api` — present in
67/// essentially every Composer plugin's `composer.json` — reach the Packagist-shaped
68/// `vendor/package` name validator and get flagged "Invalid package name"; M6: `composer`
69/// itself was still missing from the set).
70///
71/// Every platform package name is a single bare token with no `/` — a real Packagist package
72/// always has a `vendor/package` shape — so any name containing `/` returns `false`
73/// immediately, before the prefix checks below. Without this guard, `starts_with("php-")` (and
74/// `ext-`/`lib-`) would also match a real package merely because its *vendor* happens to start
75/// with that prefix (e.g. `php-di/php-di`, `php-amqplib/php-amqplib`, `ext-mongo/whatever`),
76/// silently dropping it from the dependency list entirely — no diagnostic, hover, inlay hint,
77/// or code lens — rather than validating it as a normal dependency (#402 critique C2).
78pub fn is_platform_package(name: &str) -> bool {
79    if name.contains('/') {
80        return false;
81    }
82    name == "php"
83        || name.starts_with("php-")
84        || name == "hhvm"
85        || name == "composer"
86        || name.starts_with("ext-")
87        || name.starts_with("lib-")
88        || name == "composer-plugin-api"
89        || name == "composer-runtime-api"
90}
91
92/// Parses a composer.json file and extracts all non-platform dependencies with positions.
93///
94/// Handles `require` and `require-dev` sections.
95/// Platform packages (php, ext-*, lib-*) are silently filtered out.
96///
97/// # Errors
98///
99/// Returns an error if JSON parsing fails.
100///
101/// # Examples
102///
103/// ```no_run
104/// use deps_composer::parser::parse_composer_json;
105/// use tower_lsp_server::ls_types::Uri;
106///
107/// let json = r#"{
108///   "require": {
109///     "symfony/console": "^6.0"
110///   }
111/// }"#;
112/// let uri = Uri::from_file_path("/project/composer.json").unwrap();
113///
114/// let result = parse_composer_json(json, &uri).unwrap();
115/// assert_eq!(result.dependencies.len(), 1);
116/// assert_eq!(result.dependencies[0].name, "symfony/console");
117/// ```
118pub fn parse_composer_json(content: &str, uri: &Uri) -> Result<ComposerParseResult> {
119    let root: Value = deps_core::parse_json_checked(content.as_bytes())?;
120
121    let line_table = LineOffsetTable::new(content);
122    let mut dependencies = Vec::new();
123
124    // Parse each section, scoping position search to that section's own byte range (see
125    // `deps_core::parser::find_json_section_byte_range`) so a name repeated across sections
126    // (e.g. also present in `require-dev`) does not have both occurrences resolve to the
127    // same, monotonically-advancing search cursor position — `serde_json::Map` iteration
128    // order (alphabetical without `preserve_order`) cannot be relied on to match source-text
129    // order (#610).
130    const SECTIONS: [(&str, ComposerSection); 2] = [
131        ("require", ComposerSection::Require),
132        ("require-dev", ComposerSection::RequireDev),
133    ];
134
135    for (key, section) in SECTIONS {
136        if let Some(deps) = root.get(key).and_then(|v| v.as_object()) {
137            // Fall back to the whole-file range (pre-fix behavior) if the section's own
138            // bounds cannot be located — never drop a section's dependencies over this.
139            let section_range = deps_core::parser::find_json_section_byte_range(content, key)
140                .unwrap_or_else(|| {
141                    tracing::debug!(
142                        section = key,
143                        "section byte range not found, falling back to whole-file position search"
144                    );
145                    (0, content.len())
146                });
147            dependencies.extend(parse_section(
148                content,
149                deps,
150                section,
151                section_range,
152                &line_table,
153            ));
154        }
155    }
156
157    let minimum_stability = root
158        .get("minimum-stability")
159        .and_then(|v| v.as_str())
160        .map(str::to_string);
161
162    Ok(ComposerParseResult {
163        dependencies,
164        uri: uri.clone(),
165        minimum_stability,
166    })
167}
168
169/// Parses a single dependency section and extracts positions, filtering platform packages.
170///
171/// `section_range` bounds the search performed by `find_positions` to this section's own
172/// `{...}` byte range, so a name that also appears in another section (e.g. `require` and
173/// `require-dev`) does not resolve to that other section's occurrence.
174fn parse_section(
175    content: &str,
176    deps: &serde_json::Map<String, Value>,
177    section: ComposerSection,
178    section_range: (usize, usize),
179    line_table: &LineOffsetTable,
180) -> Vec<ComposerDependency> {
181    let mut result = Vec::new();
182
183    for (name, value) in deps {
184        if is_platform_package(name) {
185            continue;
186        }
187
188        let version_req = value.as_str().map(String::from);
189        let (name_range, version_range) = find_positions(
190            content,
191            name,
192            version_req.as_ref(),
193            section_range,
194            line_table,
195        );
196
197        result.push(ComposerDependency {
198            name: name.clone().into(),
199            name_range,
200            version_req: version_req.map(Into::into),
201            version_range,
202            section,
203        });
204    }
205
206    result
207}
208
209/// Finds the byte positions of a dependency name and version in the source text.
210///
211/// Searches for the dependency as a JSON key-value pair within `section_range` (the
212/// enclosing section's own `{...}` byte range) to avoid false matches both from unrelated
213/// text elsewhere in the file and from the same name declared in a *different* dependency
214/// section.
215fn find_positions(
216    content: &str,
217    name: &str,
218    version_req: Option<&String>,
219    section_range: (usize, usize),
220    line_table: &LineOffsetTable,
221) -> (Range, Option<Range>) {
222    let mut name_range = Range::default();
223    let mut version_range = None;
224
225    let name_pattern = format!("\"{name}\"");
226    let (range_start, range_end) = section_range;
227    let section_content = &content[range_start..range_end];
228
229    let mut search_start = 0;
230    while let Some(rel_idx) = section_content[search_start..].find(&name_pattern) {
231        let name_start_idx = range_start + search_start + rel_idx;
232        let after_name = &content[name_start_idx + name_pattern.len()..range_end];
233        let trimmed = after_name.trim_start();
234
235        if !trimmed.starts_with(':') {
236            search_start = (name_start_idx + name_pattern.len()) - range_start;
237            continue;
238        }
239
240        let name_start = line_table.byte_offset_to_position(content, name_start_idx + 1);
241        let name_end = line_table.byte_offset_to_position(content, name_start_idx + 1 + name.len());
242        name_range = Range::new(name_start, name_end);
243
244        if let Some(version) = version_req {
245            let version_search = format!("\"{version}\"");
246            let colon_offset =
247                name_start_idx + name_pattern.len() + (after_name.len() - trimmed.len());
248            let after_colon = &content[colon_offset..];
249
250            // Limit search to the next 100 chars to stay within this key-value pair, and
251            // never past the enclosing section's own end — otherwise a version search
252            // starting near a section boundary could bleed into the next section's text.
253            // Round down to a char boundary since `version.len()` is a byte count that
254            // can land mid-character when the source contains multi-byte UTF-8.
255            let search_limit = after_colon.floor_char_boundary(
256                after_colon
257                    .len()
258                    .min(100 + version.len())
259                    .min(range_end.saturating_sub(colon_offset)),
260            );
261            let search_area = &after_colon[..search_limit];
262
263            if let Some(ver_rel_idx) = search_area.find(&version_search) {
264                let version_start_idx = colon_offset + ver_rel_idx + 1;
265                let version_start = line_table.byte_offset_to_position(content, version_start_idx);
266                let version_end =
267                    line_table.byte_offset_to_position(content, version_start_idx + version.len());
268                version_range = Some(Range::new(version_start, version_end));
269            }
270        }
271
272        break;
273    }
274
275    (name_range, version_range)
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    use std::assert_matches;
283
284    fn test_uri() -> Uri {
285        deps_core::test_util::test_uri("/test/composer.json")
286    }
287
288    #[test]
289    fn test_parse_require() {
290        let json = r#"{
291  "require": {
292    "symfony/console": "^6.0",
293    "monolog/monolog": "^3.0"
294  }
295}"#;
296
297        let result = parse_composer_json(json, &test_uri()).unwrap();
298        assert_eq!(result.dependencies.len(), 2);
299
300        // JSON object iteration order is not guaranteed, so find by name
301        let symfony = result
302            .dependencies
303            .iter()
304            .find(|d| d.name == "symfony/console")
305            .expect("symfony/console not found");
306        assert_eq!(symfony.version_req, Some("^6.0".into()));
307        assert_matches!(symfony.section, ComposerSection::Require);
308
309        let monolog = result
310            .dependencies
311            .iter()
312            .find(|d| d.name == "monolog/monolog")
313            .expect("monolog/monolog not found");
314        assert_eq!(monolog.version_req, Some("^3.0".into()));
315    }
316
317    #[test]
318    fn test_parse_require_dev() {
319        let json = r#"{
320  "require-dev": {
321    "phpunit/phpunit": "^10.0"
322  }
323}"#;
324
325        let result = parse_composer_json(json, &test_uri()).unwrap();
326        assert_eq!(result.dependencies.len(), 1);
327        assert_matches!(result.dependencies[0].section, ComposerSection::RequireDev);
328    }
329
330    #[test]
331    fn test_filter_platform_packages() {
332        let json = r#"{
333  "require": {
334    "php": ">=8.1",
335    "ext-mbstring": "*",
336    "lib-xml": "*",
337    "symfony/console": "^6.0"
338  }
339}"#;
340
341        let result = parse_composer_json(json, &test_uri()).unwrap();
342        assert_eq!(result.dependencies.len(), 1);
343        assert_eq!(result.dependencies[0].name, "symfony/console");
344    }
345
346    #[test]
347    fn test_is_platform_package() {
348        assert!(is_platform_package("php"));
349        assert!(is_platform_package("ext-mbstring"));
350        assert!(is_platform_package("ext-json"));
351        assert!(is_platform_package("lib-xml"));
352        assert!(!is_platform_package("symfony/console"));
353        assert!(!is_platform_package("monolog/monolog"));
354        assert!(!is_platform_package("extended/package")); // not ext- prefix
355    }
356
357    /// #402 critique S1: the platform-package set previously covered only `php`/`ext-*`/
358    /// `lib-*`, so a real requirement like `composer-plugin-api` fell through to the
359    /// Packagist-shaped `vendor/package` name validator and was flagged "Invalid package
360    /// name" instead of being silently filtered like the other platform packages.
361    #[test]
362    fn test_is_platform_package_covers_full_composer_platform_set() {
363        for name in [
364            "php-64bit",
365            "php-ipv6",
366            "php-zts",
367            "php-debug",
368            "hhvm",
369            "composer",
370            "composer-plugin-api",
371            "composer-runtime-api",
372        ] {
373            assert!(
374                is_platform_package(name),
375                "expected {name:?} to be recognized as a platform package"
376            );
377        }
378    }
379
380    /// #402 critique C2: a real Packagist package under a vendor whose name happens to start
381    /// with a platform prefix (`php-di/php-di`, `php-amqplib/php-amqplib`,
382    /// `ext-mongo/whatever`, `lib-xml/whatever`) must not be misclassified as a platform
383    /// package — that would silently drop it from the dependency list entirely, with no
384    /// diagnostic, hover, inlay hint, or code lens, rather than validating it normally.
385    #[test]
386    fn test_is_platform_package_does_not_swallow_real_packages_with_platform_like_vendors() {
387        for name in [
388            "php-di/php-di",
389            "php-amqplib/php-amqplib",
390            "php-debugbar/php-debugbar",
391            "php-ffmpeg/php-ffmpeg",
392            "ext-mongo/whatever",
393            "lib-xml/whatever",
394            "hhvm/whatever",
395            "composer/whatever",
396        ] {
397            assert!(
398                !is_platform_package(name),
399                "expected {name:?} to NOT be recognized as a platform package"
400            );
401        }
402    }
403
404    #[test]
405    fn test_parse_both_sections() {
406        let json = r#"{
407  "require": {
408    "symfony/console": "^6.0"
409  },
410  "require-dev": {
411    "phpunit/phpunit": "^10.0"
412  }
413}"#;
414
415        let result = parse_composer_json(json, &test_uri()).unwrap();
416        assert_eq!(result.dependencies.len(), 2);
417
418        let require_count = result
419            .dependencies
420            .iter()
421            .filter(|d| matches!(d.section, ComposerSection::Require))
422            .count();
423        let dev_count = result
424            .dependencies
425            .iter()
426            .filter(|d| matches!(d.section, ComposerSection::RequireDev))
427            .count();
428
429        assert_eq!(require_count, 1);
430        assert_eq!(dev_count, 1);
431    }
432
433    /// #424: `minimum-stability` is parsed from the manifest root when present.
434    #[test]
435    fn test_parse_minimum_stability_present() {
436        let json = r#"{
437  "minimum-stability": "beta",
438  "require": {
439    "symfony/console": "^6.0"
440  }
441}"#;
442        let result = parse_composer_json(json, &test_uri()).unwrap();
443        assert_eq!(result.minimum_stability.as_deref(), Some("beta"));
444    }
445
446    /// #424: a manifest with no `minimum-stability` field parses to `None`, not a fabricated
447    /// `"stable"` — the stable default is applied by the registry ranking, not the parser.
448    #[test]
449    fn test_parse_minimum_stability_absent() {
450        let json = r#"{"require": {"symfony/console": "^6.0"}}"#;
451        let result = parse_composer_json(json, &test_uri()).unwrap();
452        assert_eq!(result.minimum_stability, None);
453    }
454
455    #[test]
456    fn test_parse_empty() {
457        let json = r#"{"name": "vendor/project"}"#;
458        let result = parse_composer_json(json, &test_uri()).unwrap();
459        assert_eq!(result.dependencies.len(), 0);
460    }
461
462    #[test]
463    fn test_parse_invalid_json() {
464        let result = parse_composer_json("{invalid json}", &test_uri());
465        assert_matches!(result, Err(deps_core::DepsError::Json(_)));
466    }
467
468    #[test]
469    fn test_parse_deeply_nested_json_rejected_before_parse() {
470        // #430: a deeply nested `composer.json` must be rejected by the
471        // depth guard rather than handed to `serde_json::from_str`. Reported
472        // as `DepsError::Json`, the same variant a genuinely malformed
473        // `composer.json` produces (unified via `deps_core::parse_json_checked`).
474        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
475        let json = format!("{}1{}", "[".repeat(depth), "]".repeat(depth));
476        let result = parse_composer_json(&json, &test_uri());
477        assert_matches!(result, Err(deps_core::DepsError::Json(_)));
478    }
479
480    #[test]
481    fn test_parse_nesting_at_max_depth_accepted() {
482        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
483        let json = format!(
484            r#"{{"require": {{}}, "extra": {}1{}}}"#,
485            "[".repeat(depth - 1),
486            "]".repeat(depth - 1)
487        );
488        let result = parse_composer_json(&json, &test_uri());
489        assert!(result.is_ok());
490    }
491
492    #[test]
493    fn test_position_tracking() {
494        let json = r#"{
495  "require": {
496    "symfony/console": "^6.0"
497  }
498}"#;
499
500        let result = parse_composer_json(json, &test_uri()).unwrap();
501        let dep = &result.dependencies[0];
502
503        assert_eq!(dep.name_range.start.line, 2);
504        assert!(dep.version_range.is_some());
505        assert_eq!(dep.version_range.unwrap().start.line, 2);
506    }
507
508    #[test]
509    fn test_parse_empty_require() {
510        let json = r#"{"require": {}}"#;
511        let result = parse_composer_json(json, &test_uri()).unwrap();
512        assert_eq!(result.dependencies.len(), 0);
513    }
514
515    /// Regression test for https://github.com/bug-ops/deps-lsp/issues/84
516    ///
517    /// `serde_json::Map` iterates alphabetically without the `preserve_order` feature:
518    /// guzzlehttp/guzzle → laravel/framework → symfony/console. The parser's original
519    /// implementation searched for each dependency's position using a single
520    /// monotonically-advancing cursor in that iteration order, so laravel/framework (file
521    /// line 2) was searched for only after the cursor had already advanced past line 3,
522    /// leaving its name_range and version_range at (0,0)→(0,0). Fixed by scoping each
523    /// section's search to its own byte range (see `find_positions`), independent of
524    /// iteration order — the same fix later generalized for the cross-section case in #610.
525    #[test]
526    fn test_position_tracking_out_of_alphabetical_order() {
527        let json = r#"{
528    "require": {
529        "laravel/framework": "^10.0",
530        "guzzlehttp/guzzle": "^7.5",
531        "symfony/console": "~6.0"
532    }
533}"#;
534        let result = parse_composer_json(json, &test_uri()).unwrap();
535        assert_eq!(result.dependencies.len(), 3);
536
537        for dep in &result.dependencies {
538            // Every dependency must have a valid (non-zero) name position.
539            assert!(
540                dep.name_range.start.line > 0,
541                "name_range for '{}' is at line 0 — position tracking regressed",
542                dep.name
543            );
544            assert!(
545                dep.version_range.is_some(),
546                "version_range for '{}' is missing",
547                dep.name
548            );
549        }
550
551        let laravel = result
552            .dependencies
553            .iter()
554            .find(|d| d.name == "laravel/framework")
555            .unwrap();
556        assert_eq!(laravel.name_range.start.line, 2);
557
558        let guzzle = result
559            .dependencies
560            .iter()
561            .find(|d| d.name == "guzzlehttp/guzzle")
562            .unwrap();
563        assert_eq!(guzzle.name_range.start.line, 3);
564
565        let symfony = result
566            .dependencies
567            .iter()
568            .find(|d| d.name == "symfony/console")
569            .unwrap();
570        assert_eq!(symfony.name_range.start.line, 4);
571    }
572
573    #[test]
574    fn test_find_positions_no_panic_on_multibyte_utf8_boundary() {
575        // The search window is `100 + version.len()` bytes after the colon. Placing a
576        // 2-byte UTF-8 character ('é') to straddle that exact byte offset used to panic
577        // when the raw offset was used to slice the string directly (issue #245, same
578        // pattern as #230 in deps-npm).
579        let name = "vendor/pkg";
580        let version = "1.0.0".to_string();
581        let padding = "a".repeat(103);
582        let content = format!("\"{name}\":{padding}é more text \"{version}\" end");
583
584        let line_table = LineOffsetTable::new(&content);
585        let (name_range, version_range) = find_positions(
586            &content,
587            name,
588            Some(&version),
589            (0, content.len()),
590            &line_table,
591        );
592
593        assert_eq!(name_range.start.line, 0);
594        // The multibyte character falls right at the truncated search boundary, so the
595        // version string (placed after it) is outside the search window and not found.
596        assert!(version_range.is_none());
597    }
598
599    #[test]
600    fn test_find_positions_finds_version_when_multibyte_char_is_further_out() {
601        // Same truncation boundary as above, but this time the version sits well inside
602        // the truncated window while the 2-byte UTF-8 character ('é') straddles the exact
603        // raw byte offset (107) that used to be sliced naively. Confirms the fix does not
604        // just avoid panicking but still returns the correct, real match.
605        let name = "vendor/pkg";
606        let version = "1.0.0-x".to_string(); // 7 bytes -> raw window limit = 100 + 7 = 107
607        let quoted_version = format!("\"{version}\"");
608        let padding = "a".repeat(95);
609        let content = format!("\"{name}\": {quoted_version}{padding}é end");
610
611        let line_table = LineOffsetTable::new(&content);
612        let (name_range, version_range) = find_positions(
613            &content,
614            name,
615            Some(&version),
616            (0, content.len()),
617            &line_table,
618        );
619
620        assert_eq!(name_range.start.line, 0);
621        let version_range = version_range.expect("version should still be found after truncation");
622        assert_eq!(version_range.start.line, 0);
623
624        // Version content starts right after the opening quote; everything before it is
625        // ASCII, so byte offset and UTF-16 character offset coincide.
626        let expected_start = content.find(&quoted_version).unwrap() + 1;
627        assert_eq!(version_range.start.character, expected_start as u32);
628    }
629
630    #[test]
631    fn test_parse_composer_json_no_panic_with_multibyte_field_after_dependency() {
632        // Public-API-level regression test for issue #245: an ordinary manifest where a
633        // later top-level field (`description`) contains multi-byte UTF-8 can still land
634        // the search-window offset for an *earlier* dependency's version mid-character.
635        // This reaches the panic through `parse_composer_json`, the only entry point the
636        // LSP layer actually calls, rather than the private `find_positions` helper.
637        let json = r#"{
638  "require": {
639    "symfony/console": "^6.0"
640  },
641  "authors": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
642  "description": "Gestão de projetos"
643}"#;
644
645        let result = parse_composer_json(json, &test_uri()).unwrap();
646        assert_eq!(result.dependencies.len(), 1);
647
648        let symfony = &result.dependencies[0];
649        assert_eq!(symfony.name, "symfony/console");
650        assert_eq!(symfony.version_req, Some("^6.0".into()));
651        assert!(symfony.version_range.is_some());
652    }
653
654    // --- #610: duplicate dependency names across sections ---
655
656    #[test]
657    fn test_duplicate_name_across_require_and_require_dev() {
658        // #610, same bug class as npm's #605: "vendor/pkg" appears in both `require` and
659        // `require-dev`, with keys not in source-text order relative to each section's
660        // start. The old implementation threaded a single, monotonically-advancing
661        // `search_start` cursor across `serde_json::Map` iteration, so it could skip past —
662        // or land on — the wrong occurrence whenever `Map` iteration order didn't match
663        // source-text order. The fix no longer depends on `Map` iteration order at all: each
664        // section's own byte range is located up front, and every entry's position is
665        // searched fresh within it. Each occurrence must resolve to its own section's
666        // position.
667        let json = r#"{
668  "require": {
669    "zzz/other": "^1.0",
670    "vendor/pkg": "^2.0"
671  },
672  "require-dev": {
673    "vendor/pkg": "^9.9",
674    "aaa/other": "^1.0"
675  }
676}"#;
677
678        let result = parse_composer_json(json, &test_uri()).unwrap();
679        assert_eq!(result.dependencies.len(), 4);
680
681        let require_pkg = result
682            .dependencies
683            .iter()
684            .find(|d| d.name == "vendor/pkg" && matches!(d.section, ComposerSection::Require))
685            .expect("vendor/pkg in require");
686        let dev_pkg = result
687            .dependencies
688            .iter()
689            .find(|d| d.name == "vendor/pkg" && matches!(d.section, ComposerSection::RequireDev))
690            .expect("vendor/pkg in require-dev");
691
692        assert_eq!(require_pkg.version_req, Some("^2.0".into()));
693        assert_eq!(dev_pkg.version_req, Some("^9.9".into()));
694
695        assert_eq!(require_pkg.name_range.start.line, 3);
696        assert_eq!(dev_pkg.name_range.start.line, 6);
697
698        let require_version = require_pkg
699            .version_range
700            .expect("require vendor/pkg version_range");
701        let dev_version = dev_pkg
702            .version_range
703            .expect("require-dev vendor/pkg version_range");
704        assert_eq!(require_version.start.line, 3);
705        assert_eq!(dev_version.start.line, 6);
706    }
707}