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::error::{ComposerError, Result};
8use crate::types::{ComposerDependency, ComposerSection};
9use serde_json::Value;
10use std::any::Any;
11use tower_lsp_server::ls_types::{Position, Range, Uri};
12
13/// Line offset table for O(log n) position lookups.
14struct LineOffsetTable {
15    offsets: Vec<usize>,
16}
17
18impl LineOffsetTable {
19    fn new(content: &str) -> Self {
20        let mut offsets = vec![0];
21        for (i, c) in content.char_indices() {
22            if c == '\n' {
23                offsets.push(i + 1);
24            }
25        }
26        Self { offsets }
27    }
28
29    /// Converts byte offset to LSP Position using UTF-16 code unit counting.
30    fn position_from_offset(&self, content: &str, offset: usize) -> Position {
31        let line = match self.offsets.binary_search(&offset) {
32            Ok(line) => line,
33            Err(line) => line.saturating_sub(1),
34        };
35        let line_start = self.offsets[line];
36        let character = content[line_start..offset]
37            .chars()
38            .map(|c| c.len_utf16() as u32)
39            .sum();
40        Position::new(line as u32, character)
41    }
42}
43
44/// Result of parsing a composer.json file.
45///
46/// Contains all non-platform dependencies found in the file with their positions.
47#[derive(Debug)]
48pub struct ComposerParseResult {
49    pub dependencies: Vec<ComposerDependency>,
50    pub uri: Uri,
51}
52
53impl deps_core::ParseResult for ComposerParseResult {
54    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
55        self.dependencies
56            .iter()
57            .map(|d| d as &dyn deps_core::Dependency)
58            .collect()
59    }
60
61    fn workspace_root(&self) -> Option<&std::path::Path> {
62        None
63    }
64
65    fn uri(&self) -> &Uri {
66        &self.uri
67    }
68
69    fn as_any(&self) -> &dyn Any {
70        self
71    }
72}
73
74/// Returns true if the package is a platform requirement (not a Packagist package).
75///
76/// Platform packages include:
77/// - `php` — PHP version requirement
78/// - `ext-*` — PHP extensions
79/// - `lib-*` — PHP libraries
80pub fn is_platform_package(name: &str) -> bool {
81    name == "php" || name.starts_with("ext-") || name.starts_with("lib-")
82}
83
84/// Parses a composer.json file and extracts all non-platform dependencies with positions.
85///
86/// Handles `require` and `require-dev` sections.
87/// Platform packages (php, ext-*, lib-*) are silently filtered out.
88///
89/// # Errors
90///
91/// Returns an error if JSON parsing fails.
92///
93/// # Examples
94///
95/// ```no_run
96/// use deps_composer::parser::parse_composer_json;
97/// use tower_lsp_server::ls_types::Uri;
98///
99/// let json = r#"{
100///   "require": {
101///     "symfony/console": "^6.0"
102///   }
103/// }"#;
104/// let uri = Uri::from_file_path("/project/composer.json").unwrap();
105///
106/// let result = parse_composer_json(json, &uri).unwrap();
107/// assert_eq!(result.dependencies.len(), 1);
108/// assert_eq!(result.dependencies[0].name, "symfony/console");
109/// ```
110pub fn parse_composer_json(content: &str, uri: &Uri) -> Result<ComposerParseResult> {
111    let root: Value =
112        serde_json::from_str(content).map_err(|e| ComposerError::JsonParseError { source: e })?;
113
114    let line_table = LineOffsetTable::new(content);
115    let mut dependencies = Vec::new();
116
117    if let Some(deps) = root.get("require").and_then(|v| v.as_object()) {
118        dependencies.extend(parse_section(
119            content,
120            deps,
121            ComposerSection::Require,
122            &line_table,
123        ));
124    }
125
126    if let Some(deps) = root.get("require-dev").and_then(|v| v.as_object()) {
127        dependencies.extend(parse_section(
128            content,
129            deps,
130            ComposerSection::RequireDev,
131            &line_table,
132        ));
133    }
134
135    Ok(ComposerParseResult {
136        dependencies,
137        uri: uri.clone(),
138    })
139}
140
141/// Parses a single dependency section and extracts positions, filtering platform packages.
142///
143/// Uses `search_start` to scope position lookups to the current section,
144/// preventing false matches when the same package name appears in multiple sections.
145fn parse_section(
146    content: &str,
147    deps: &serde_json::Map<String, Value>,
148    section: ComposerSection,
149    line_table: &LineOffsetTable,
150) -> Vec<ComposerDependency> {
151    let mut result = Vec::new();
152    let mut search_start = 0;
153
154    for (name, value) in deps {
155        if is_platform_package(name) {
156            continue;
157        }
158
159        let version_req = value.as_str().map(String::from);
160        let (name_range, version_range, new_offset) = find_positions(
161            content,
162            name,
163            version_req.as_ref(),
164            line_table,
165            search_start,
166        );
167
168        search_start = new_offset;
169
170        result.push(ComposerDependency {
171            name: name.clone(),
172            name_range,
173            version_req,
174            version_range,
175            section,
176        });
177    }
178
179    result
180}
181
182/// Finds the byte positions of a dependency name and version in the source text.
183///
184/// Returns `(name_range, version_range, new_search_offset)` where `new_search_offset`
185/// is advanced past the current match to avoid false matches in subsequent lookups.
186fn find_positions(
187    content: &str,
188    name: &str,
189    version_req: Option<&String>,
190    line_table: &LineOffsetTable,
191    search_from: usize,
192) -> (Range, Option<Range>, usize) {
193    let mut name_range = Range::default();
194    let mut version_range = None;
195
196    let name_pattern = format!("\"{name}\"");
197    let mut search_start = search_from;
198
199    while let Some(rel_idx) = content[search_start..].find(&name_pattern) {
200        let name_start_idx = search_start + rel_idx;
201        let after_name = &content[name_start_idx + name_pattern.len()..];
202        let trimmed = after_name.trim_start();
203
204        if !trimmed.starts_with(':') {
205            search_start = name_start_idx + name_pattern.len();
206            continue;
207        }
208
209        let name_start = line_table.position_from_offset(content, name_start_idx + 1);
210        let name_end = line_table.position_from_offset(content, name_start_idx + 1 + name.len());
211        name_range = Range::new(name_start, name_end);
212
213        if let Some(version) = version_req {
214            let version_search = format!("\"{version}\"");
215            let colon_offset =
216                name_start_idx + name_pattern.len() + (after_name.len() - trimmed.len());
217            let after_colon = &content[colon_offset..];
218            let search_limit = after_colon.len().min(100 + version.len());
219            let search_area = &after_colon[..search_limit];
220
221            if let Some(ver_rel_idx) = search_area.find(&version_search) {
222                let version_start_idx = colon_offset + ver_rel_idx + 1;
223                let version_start = line_table.position_from_offset(content, version_start_idx);
224                let version_end =
225                    line_table.position_from_offset(content, version_start_idx + version.len());
226                version_range = Some(Range::new(version_start, version_end));
227            }
228        }
229
230        return (
231            name_range,
232            version_range,
233            name_start_idx + name_pattern.len(),
234        );
235    }
236
237    (name_range, version_range, search_start)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn test_uri() -> Uri {
245        Uri::from_file_path("/test/composer.json").unwrap()
246    }
247
248    #[test]
249    fn test_parse_require() {
250        let json = r#"{
251  "require": {
252    "symfony/console": "^6.0",
253    "monolog/monolog": "^3.0"
254  }
255}"#;
256
257        let result = parse_composer_json(json, &test_uri()).unwrap();
258        assert_eq!(result.dependencies.len(), 2);
259
260        // JSON object iteration order is not guaranteed, so find by name
261        let symfony = result
262            .dependencies
263            .iter()
264            .find(|d| d.name == "symfony/console")
265            .expect("symfony/console not found");
266        assert_eq!(symfony.version_req, Some("^6.0".into()));
267        assert!(matches!(symfony.section, ComposerSection::Require));
268
269        let monolog = result
270            .dependencies
271            .iter()
272            .find(|d| d.name == "monolog/monolog")
273            .expect("monolog/monolog not found");
274        assert_eq!(monolog.version_req, Some("^3.0".into()));
275    }
276
277    #[test]
278    fn test_parse_require_dev() {
279        let json = r#"{
280  "require-dev": {
281    "phpunit/phpunit": "^10.0"
282  }
283}"#;
284
285        let result = parse_composer_json(json, &test_uri()).unwrap();
286        assert_eq!(result.dependencies.len(), 1);
287        assert!(matches!(
288            result.dependencies[0].section,
289            ComposerSection::RequireDev
290        ));
291    }
292
293    #[test]
294    fn test_filter_platform_packages() {
295        let json = r#"{
296  "require": {
297    "php": ">=8.1",
298    "ext-mbstring": "*",
299    "lib-xml": "*",
300    "symfony/console": "^6.0"
301  }
302}"#;
303
304        let result = parse_composer_json(json, &test_uri()).unwrap();
305        assert_eq!(result.dependencies.len(), 1);
306        assert_eq!(result.dependencies[0].name, "symfony/console");
307    }
308
309    #[test]
310    fn test_is_platform_package() {
311        assert!(is_platform_package("php"));
312        assert!(is_platform_package("ext-mbstring"));
313        assert!(is_platform_package("ext-json"));
314        assert!(is_platform_package("lib-xml"));
315        assert!(!is_platform_package("symfony/console"));
316        assert!(!is_platform_package("monolog/monolog"));
317        assert!(!is_platform_package("extended/package")); // not ext- prefix
318    }
319
320    #[test]
321    fn test_parse_both_sections() {
322        let json = r#"{
323  "require": {
324    "symfony/console": "^6.0"
325  },
326  "require-dev": {
327    "phpunit/phpunit": "^10.0"
328  }
329}"#;
330
331        let result = parse_composer_json(json, &test_uri()).unwrap();
332        assert_eq!(result.dependencies.len(), 2);
333
334        let require_count = result
335            .dependencies
336            .iter()
337            .filter(|d| matches!(d.section, ComposerSection::Require))
338            .count();
339        let dev_count = result
340            .dependencies
341            .iter()
342            .filter(|d| matches!(d.section, ComposerSection::RequireDev))
343            .count();
344
345        assert_eq!(require_count, 1);
346        assert_eq!(dev_count, 1);
347    }
348
349    #[test]
350    fn test_parse_empty() {
351        let json = r#"{"name": "vendor/project"}"#;
352        let result = parse_composer_json(json, &test_uri()).unwrap();
353        assert_eq!(result.dependencies.len(), 0);
354    }
355
356    #[test]
357    fn test_parse_invalid_json() {
358        let result = parse_composer_json("{invalid json}", &test_uri());
359        assert!(result.is_err());
360    }
361
362    #[test]
363    fn test_position_tracking() {
364        let json = r#"{
365  "require": {
366    "symfony/console": "^6.0"
367  }
368}"#;
369
370        let result = parse_composer_json(json, &test_uri()).unwrap();
371        let dep = &result.dependencies[0];
372
373        assert_eq!(dep.name_range.start.line, 2);
374        assert!(dep.version_range.is_some());
375        assert_eq!(dep.version_range.unwrap().start.line, 2);
376    }
377
378    #[test]
379    fn test_parse_empty_require() {
380        let json = r#"{"require": {}}"#;
381        let result = parse_composer_json(json, &test_uri()).unwrap();
382        assert_eq!(result.dependencies.len(), 0);
383    }
384
385    /// Regression test for https://github.com/bug-ops/deps-lsp/issues/84
386    ///
387    /// BTreeMap iterates alphabetically: guzzlehttp/guzzle → laravel/framework →
388    /// symfony/console. Without preserve_order, laravel/framework (file line 2) was
389    /// searched after search_start had advanced past line 3, so its name_range and
390    /// version_range were left at (0,0)→(0,0).
391    #[test]
392    fn test_position_tracking_out_of_alphabetical_order() {
393        let json = r#"{
394    "require": {
395        "laravel/framework": "^10.0",
396        "guzzlehttp/guzzle": "^7.5",
397        "symfony/console": "~6.0"
398    }
399}"#;
400        let result = parse_composer_json(json, &test_uri()).unwrap();
401        assert_eq!(result.dependencies.len(), 3);
402
403        for dep in &result.dependencies {
404            // Every dependency must have a valid (non-zero) name position.
405            assert!(
406                dep.name_range.start.line > 0,
407                "name_range for '{}' is at line 0 — position tracking regressed",
408                dep.name
409            );
410            assert!(
411                dep.version_range.is_some(),
412                "version_range for '{}' is missing",
413                dep.name
414            );
415        }
416
417        let laravel = result
418            .dependencies
419            .iter()
420            .find(|d| d.name == "laravel/framework")
421            .unwrap();
422        assert_eq!(laravel.name_range.start.line, 2);
423
424        let guzzle = result
425            .dependencies
426            .iter()
427            .find(|d| d.name == "guzzlehttp/guzzle")
428            .unwrap();
429        assert_eq!(guzzle.name_range.start.line, 3);
430
431        let symfony = result
432            .dependencies
433            .iter()
434            .find(|d| d.name == "symfony/console")
435            .unwrap();
436        assert_eq!(symfony.name_range.start.line, 4);
437    }
438}