deps_npm/
parser.rs

1//! package.json parser with position tracking.
2//!
3//! Parses package.json files and extracts dependency information with precise
4//! source positions for LSP operations.
5
6use crate::error::{NpmError, Result};
7use crate::types::{NpmDependency, NpmDependencySection};
8use serde_json::Value;
9use std::any::Any;
10use tower_lsp_server::ls_types::{Position, Range, Uri};
11
12/// Line offset table for O(log n) position lookups.
13///
14/// Stores byte offsets of each line start, enabling fast binary search
15/// for line-to-offset conversion. This avoids O(n) scans for each position lookup.
16struct LineOffsetTable {
17    offsets: Vec<usize>,
18}
19
20impl LineOffsetTable {
21    /// Builds a line offset table from content in O(n) time.
22    fn new(content: &str) -> Self {
23        let mut offsets = vec![0];
24        for (i, c) in content.char_indices() {
25            if c == '\n' {
26                offsets.push(i + 1);
27            }
28        }
29        Self { offsets }
30    }
31
32    /// Converts byte offset to line/character position in O(log n) time.
33    ///
34    /// Uses UTF-16 character counting as required by LSP specification.
35    fn position_from_offset(&self, content: &str, offset: usize) -> Position {
36        let line = match self.offsets.binary_search(&offset) {
37            Ok(line) => line,
38            Err(line) => line.saturating_sub(1),
39        };
40        let line_start = self.offsets[line];
41
42        // Count UTF-16 code units (not bytes) as required by LSP spec
43        let character = content[line_start..offset]
44            .chars()
45            .map(|c| c.len_utf16() as u32)
46            .sum();
47
48        Position::new(line as u32, character)
49    }
50}
51
52/// Result of parsing a package.json file.
53///
54/// Contains all dependencies found in the file with their positions.
55#[derive(Debug)]
56pub struct NpmParseResult {
57    pub dependencies: Vec<NpmDependency>,
58    pub uri: Uri,
59}
60
61impl deps_core::ParseResult for NpmParseResult {
62    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
63        self.dependencies
64            .iter()
65            .map(|d| d as &dyn deps_core::Dependency)
66            .collect()
67    }
68
69    fn workspace_root(&self) -> Option<&std::path::Path> {
70        None
71    }
72
73    fn uri(&self) -> &Uri {
74        &self.uri
75    }
76
77    fn as_any(&self) -> &dyn Any {
78        self
79    }
80}
81
82/// Parses a package.json file and extracts all dependencies with positions.
83///
84/// Handles all dependency sections:
85/// - `dependencies`
86/// - `devDependencies`
87/// - `peerDependencies`
88/// - `optionalDependencies`
89///
90/// # Errors
91///
92/// Returns an error if:
93/// - JSON parsing fails
94/// - File is not a valid package.json structure
95///
96/// # Examples
97///
98/// ```no_run
99/// use deps_npm::parser::parse_package_json;
100/// use tower_lsp_server::ls_types::Uri;
101///
102/// let json = r#"{
103///   "dependencies": {
104///     "express": "^4.18.2"
105///   }
106/// }"#;
107/// let uri = Uri::from_file_path("/project/package.json").unwrap();
108///
109/// let result = parse_package_json(json, &uri).unwrap();
110/// assert_eq!(result.dependencies.len(), 1);
111/// assert_eq!(result.dependencies[0].name, "express");
112/// ```
113pub fn parse_package_json(content: &str, uri: &Uri) -> Result<NpmParseResult> {
114    let root: Value =
115        serde_json::from_str(content).map_err(|e| NpmError::JsonParseError { source: e })?;
116
117    // Build line offset table once for O(log n) position lookups
118    let line_table = LineOffsetTable::new(content);
119
120    let mut dependencies = Vec::new();
121
122    // Parse each dependency section
123    if let Some(deps) = root.get("dependencies").and_then(|v| v.as_object()) {
124        dependencies.extend(parse_dependency_section(
125            content,
126            deps,
127            NpmDependencySection::Dependencies,
128            &line_table,
129        ));
130    }
131
132    if let Some(deps) = root.get("devDependencies").and_then(|v| v.as_object()) {
133        dependencies.extend(parse_dependency_section(
134            content,
135            deps,
136            NpmDependencySection::DevDependencies,
137            &line_table,
138        ));
139    }
140
141    if let Some(deps) = root.get("peerDependencies").and_then(|v| v.as_object()) {
142        dependencies.extend(parse_dependency_section(
143            content,
144            deps,
145            NpmDependencySection::PeerDependencies,
146            &line_table,
147        ));
148    }
149
150    if let Some(deps) = root.get("optionalDependencies").and_then(|v| v.as_object()) {
151        dependencies.extend(parse_dependency_section(
152            content,
153            deps,
154            NpmDependencySection::OptionalDependencies,
155            &line_table,
156        ));
157    }
158
159    Ok(NpmParseResult {
160        dependencies,
161        uri: uri.clone(),
162    })
163}
164
165/// Parses a single dependency section and extracts positions.
166fn parse_dependency_section(
167    content: &str,
168    deps: &serde_json::Map<String, Value>,
169    section: NpmDependencySection,
170    line_table: &LineOffsetTable,
171) -> Vec<NpmDependency> {
172    let mut result = Vec::new();
173
174    for (name, value) in deps {
175        let version_req = value.as_str().map(String::from);
176
177        // Calculate positions for name and version
178        let (name_range, version_range) =
179            find_dependency_positions(content, name, version_req.as_ref(), line_table);
180
181        result.push(NpmDependency {
182            name: name.clone(),
183            name_range,
184            version_req,
185            version_range,
186            section,
187        });
188    }
189
190    result
191}
192
193/// Finds the position of a dependency name and version in the source text.
194///
195/// Searches for the dependency as a JSON key-value pair to avoid false matches
196/// when the name appears elsewhere in the file (e.g., in scripts).
197fn find_dependency_positions(
198    content: &str,
199    name: &str,
200    version_req: Option<&String>,
201    line_table: &LineOffsetTable,
202) -> (Range, Option<Range>) {
203    let mut name_range = Range::default();
204    let mut version_range = None;
205
206    let name_pattern = format!("\"{name}\"");
207
208    // Find all occurrences of the name pattern and check which one is a dependency key
209    let mut search_start = 0;
210    while let Some(rel_idx) = content[search_start..].find(&name_pattern) {
211        let name_start_idx = search_start + rel_idx;
212        let after_name = &content[name_start_idx + name_pattern.len()..];
213
214        // Check if this is a JSON key (followed by optional whitespace and colon)
215        let trimmed = after_name.trim_start();
216        if !trimmed.starts_with(':') {
217            // Not a key, continue searching
218            search_start = name_start_idx + name_pattern.len();
219            continue;
220        }
221
222        // Found a valid key, calculate position
223        let name_start = line_table.position_from_offset(content, name_start_idx + 1);
224        let name_end = line_table.position_from_offset(content, name_start_idx + 1 + name.len());
225        name_range = Range::new(name_start, name_end);
226
227        // Find version position (after the colon)
228        if let Some(version) = version_req {
229            let version_search = format!("\"{version}\"");
230            // Search for version only in the portion after the colon
231            let colon_offset =
232                name_start_idx + name_pattern.len() + (after_name.len() - trimmed.len());
233            let after_colon = &content[colon_offset..];
234
235            // Limit search to the next 100 chars to stay within this key-value pair
236            let search_limit = after_colon.len().min(100 + version.len());
237            let search_area = &after_colon[..search_limit];
238
239            if let Some(ver_rel_idx) = search_area.find(&version_search) {
240                let version_start_idx = colon_offset + ver_rel_idx + 1;
241                let version_start = line_table.position_from_offset(content, version_start_idx);
242                let version_end =
243                    line_table.position_from_offset(content, version_start_idx + version.len());
244                version_range = Some(Range::new(version_start, version_end));
245            }
246        }
247
248        // Found valid dependency, stop searching
249        break;
250    }
251
252    (name_range, version_range)
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn test_uri() -> Uri {
260        Uri::from_file_path("/test/package.json").unwrap()
261    }
262
263    #[test]
264    fn test_parse_simple_dependencies() {
265        let json = r#"{
266  "dependencies": {
267    "express": "^4.18.2",
268    "lodash": "^4.17.21"
269  }
270}"#;
271
272        let result = parse_package_json(json, &test_uri()).unwrap();
273        assert_eq!(result.dependencies.len(), 2);
274
275        let express = &result.dependencies[0];
276        assert_eq!(express.name, "express");
277        assert_eq!(express.version_req, Some("^4.18.2".into()));
278        assert!(matches!(
279            express.section,
280            NpmDependencySection::Dependencies
281        ));
282
283        let lodash = &result.dependencies[1];
284        assert_eq!(lodash.name, "lodash");
285        assert_eq!(lodash.version_req, Some("^4.17.21".into()));
286    }
287
288    #[test]
289    fn test_parse_dev_dependencies() {
290        let json = r#"{
291  "devDependencies": {
292    "typescript": "^5.0.0",
293    "jest": "^29.0.0"
294  }
295}"#;
296
297        let result = parse_package_json(json, &test_uri()).unwrap();
298        assert_eq!(result.dependencies.len(), 2);
299
300        assert!(
301            result
302                .dependencies
303                .iter()
304                .all(|d| matches!(d.section, NpmDependencySection::DevDependencies))
305        );
306    }
307
308    #[test]
309    fn test_parse_peer_dependencies() {
310        let json = r#"{
311  "peerDependencies": {
312    "react": "^18.0.0"
313  }
314}"#;
315
316        let result = parse_package_json(json, &test_uri()).unwrap();
317        assert_eq!(result.dependencies.len(), 1);
318        assert!(matches!(
319            result.dependencies[0].section,
320            NpmDependencySection::PeerDependencies
321        ));
322    }
323
324    #[test]
325    fn test_parse_optional_dependencies() {
326        let json = r#"{
327  "optionalDependencies": {
328    "fsevents": "^2.3.2"
329  }
330}"#;
331
332        let result = parse_package_json(json, &test_uri()).unwrap();
333        assert_eq!(result.dependencies.len(), 1);
334        assert!(matches!(
335            result.dependencies[0].section,
336            NpmDependencySection::OptionalDependencies
337        ));
338    }
339
340    #[test]
341    fn test_parse_multiple_sections() {
342        let json = r#"{
343  "dependencies": {
344    "express": "^4.18.2"
345  },
346  "devDependencies": {
347    "jest": "^29.0.0"
348  }
349}"#;
350
351        let result = parse_package_json(json, &test_uri()).unwrap();
352        assert_eq!(result.dependencies.len(), 2);
353
354        let deps_count = result
355            .dependencies
356            .iter()
357            .filter(|d| matches!(d.section, NpmDependencySection::Dependencies))
358            .count();
359        let dev_deps_count = result
360            .dependencies
361            .iter()
362            .filter(|d| matches!(d.section, NpmDependencySection::DevDependencies))
363            .count();
364
365        assert_eq!(deps_count, 1);
366        assert_eq!(dev_deps_count, 1);
367    }
368
369    #[test]
370    fn test_parse_empty_dependencies() {
371        let json = r#"{
372  "dependencies": {}
373}"#;
374
375        let result = parse_package_json(json, &test_uri()).unwrap();
376        assert_eq!(result.dependencies.len(), 0);
377    }
378
379    #[test]
380    fn test_parse_no_dependencies() {
381        let json = r#"{
382  "name": "my-package",
383  "version": "1.0.0"
384}"#;
385
386        let result = parse_package_json(json, &test_uri()).unwrap();
387        assert_eq!(result.dependencies.len(), 0);
388    }
389
390    #[test]
391    fn test_parse_invalid_json() {
392        let json = "{ invalid json }";
393        let result = parse_package_json(json, &test_uri());
394        assert!(result.is_err());
395    }
396
397    #[test]
398    fn test_position_calculation() {
399        let json = r#"{
400  "dependencies": {
401    "express": "^4.18.2"
402  }
403}"#;
404
405        let result = parse_package_json(json, &test_uri()).unwrap();
406        let express = &result.dependencies[0];
407
408        // Name should be on line 2 (0-indexed: line 2)
409        assert_eq!(express.name_range.start.line, 2);
410
411        // Version should also be on line 2
412        if let Some(version_range) = express.version_range {
413            assert_eq!(version_range.start.line, 2);
414        }
415    }
416
417    #[test]
418    fn test_line_offset_table() {
419        let content = "line0\nline1\nline2";
420        let table = LineOffsetTable::new(content);
421
422        let pos0 = table.position_from_offset(content, 0);
423        assert_eq!(pos0.line, 0);
424        assert_eq!(pos0.character, 0);
425
426        let pos6 = table.position_from_offset(content, 6);
427        assert_eq!(pos6.line, 1);
428        assert_eq!(pos6.character, 0);
429
430        let pos12 = table.position_from_offset(content, 12);
431        assert_eq!(pos12.line, 2);
432        assert_eq!(pos12.character, 0);
433    }
434
435    #[test]
436    fn test_line_offset_table_utf16() {
437        // Test UTF-16 character counting (LSP requirement)
438        // "hello 世界" where 世界 are multi-byte Unicode characters
439        let content = "hello 世界\nworld";
440        let table = LineOffsetTable::new(content);
441
442        // Byte offset for "world" is 16 (6 bytes "hello " + 6 bytes "世界" + 1 byte "\n" + 3 bytes "wor")
443        // But we need UTF-16 character count for LSP
444        let world_offset = content.find("world").unwrap();
445        let pos = table.position_from_offset(content, world_offset);
446        assert_eq!(pos.line, 1);
447        assert_eq!(pos.character, 0);
448
449        // Test character position within a line with multi-byte chars
450        // "hello " = 6 UTF-16 code units
451        let world_char_offset = content.find('世').unwrap();
452        let pos = table.position_from_offset(content, world_char_offset);
453        assert_eq!(pos.line, 0);
454        assert_eq!(pos.character, 6); // "hello " = 6 UTF-16 code units
455    }
456
457    #[test]
458    fn test_line_offset_table_emoji() {
459        // Test with emoji (4-byte UTF-8, 2 UTF-16 code units)
460        let content = "test 🚀 rocket\nline2";
461        let table = LineOffsetTable::new(content);
462
463        // Find position of "rocket"
464        let rocket_offset = content.find("rocket").unwrap();
465        let pos = table.position_from_offset(content, rocket_offset);
466        assert_eq!(pos.line, 0);
467        // "test " = 5, "🚀" = 2 UTF-16 code units, " " = 1 => total 8
468        assert_eq!(pos.character, 8);
469    }
470
471    #[test]
472    fn test_dependency_with_git_url() {
473        let json = r#"{
474  "dependencies": {
475    "my-lib": "git+https://github.com/user/repo.git"
476  }
477}"#;
478
479        let result = parse_package_json(json, &test_uri()).unwrap();
480        assert_eq!(result.dependencies.len(), 1);
481        assert_eq!(result.dependencies[0].name, "my-lib");
482        assert_eq!(
483            result.dependencies[0].version_req,
484            Some("git+https://github.com/user/repo.git".into())
485        );
486    }
487
488    #[test]
489    fn test_dependency_with_file_path() {
490        let json = r#"{
491  "dependencies": {
492    "local-pkg": "file:../local-package"
493  }
494}"#;
495
496        let result = parse_package_json(json, &test_uri()).unwrap();
497        assert_eq!(result.dependencies.len(), 1);
498        assert_eq!(result.dependencies[0].name, "local-pkg");
499        assert_eq!(
500            result.dependencies[0].version_req,
501            Some("file:../local-package".into())
502        );
503    }
504
505    #[test]
506    fn test_scoped_package() {
507        let json = r#"{
508  "devDependencies": {
509    "@vitest/coverage-v8": "^3.1.4"
510  }
511}"#;
512
513        let result = parse_package_json(json, &test_uri()).unwrap();
514        assert_eq!(result.dependencies.len(), 1);
515        assert_eq!(result.dependencies[0].name, "@vitest/coverage-v8");
516        assert_eq!(result.dependencies[0].version_req, Some("^3.1.4".into()));
517        assert!(result.dependencies[0].version_range.is_some());
518    }
519
520    #[test]
521    fn test_package_name_in_scripts_not_confused() {
522        // Regression test: "vitest" appears in scripts as a value,
523        // but should only be found as a dependency key
524        let json = r#"{
525  "scripts": {
526    "test": "vitest",
527    "coverage": "vitest run --coverage"
528  },
529  "devDependencies": {
530    "vitest": "^3.1.4"
531  }
532}"#;
533
534        let result = parse_package_json(json, &test_uri()).unwrap();
535        assert_eq!(result.dependencies.len(), 1);
536
537        let vitest = &result.dependencies[0];
538        assert_eq!(vitest.name, "vitest");
539        assert_eq!(vitest.version_req, Some("^3.1.4".into()));
540        // Verify version_range is found (this was the bug)
541        assert!(
542            vitest.version_range.is_some(),
543            "vitest should have a version_range"
544        );
545        // Verify position is in devDependencies, not scripts
546        // devDependencies starts at line 6
547        assert!(
548            vitest.name_range.start.line >= 5,
549            "vitest should be found in devDependencies, not scripts"
550        );
551    }
552
553    #[test]
554    fn test_multiple_packages_same_version() {
555        // Both packages have the same version - each should have distinct positions
556        let json = r#"{
557  "devDependencies": {
558    "@vitest/coverage-v8": "^3.1.4",
559    "vitest": "^3.1.4"
560  }
561}"#;
562
563        let result = parse_package_json(json, &test_uri()).unwrap();
564        assert_eq!(result.dependencies.len(), 2);
565
566        // Find both dependencies
567        let coverage = result
568            .dependencies
569            .iter()
570            .find(|d| d.name == "@vitest/coverage-v8")
571            .expect("@vitest/coverage-v8 should be parsed");
572        let vitest = result
573            .dependencies
574            .iter()
575            .find(|d| d.name == "vitest")
576            .expect("vitest should be parsed");
577
578        // Both should have version ranges
579        assert!(
580            coverage.version_range.is_some(),
581            "@vitest/coverage-v8 should have version_range"
582        );
583        assert!(
584            vitest.version_range.is_some(),
585            "vitest should have version_range"
586        );
587
588        // Positions should be different
589        let coverage_pos = coverage.version_range.unwrap();
590        let vitest_pos = vitest.version_range.unwrap();
591        assert_ne!(
592            coverage_pos.start.line, vitest_pos.start.line,
593            "version positions should be on different lines"
594        );
595    }
596}