1use 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
12struct LineOffsetTable {
17 offsets: Vec<usize>,
18}
19
20impl LineOffsetTable {
21 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 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 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#[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
82pub 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 let line_table = LineOffsetTable::new(content);
119
120 let mut dependencies = Vec::new();
121
122 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
165fn 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 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
193fn 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 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 let trimmed = after_name.trim_start();
216 if !trimmed.starts_with(':') {
217 search_start = name_start_idx + name_pattern.len();
219 continue;
220 }
221
222 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 if let Some(version) = version_req {
229 let version_search = format!("\"{version}\"");
230 let colon_offset =
232 name_start_idx + name_pattern.len() + (after_name.len() - trimmed.len());
233 let after_colon = &content[colon_offset..];
234
235 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 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 assert_eq!(express.name_range.start.line, 2);
410
411 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 let content = "hello 世界\nworld";
440 let table = LineOffsetTable::new(content);
441
442 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 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); }
456
457 #[test]
458 fn test_line_offset_table_emoji() {
459 let content = "test 🚀 rocket\nline2";
461 let table = LineOffsetTable::new(content);
462
463 let rocket_offset = content.find("rocket").unwrap();
465 let pos = table.position_from_offset(content, rocket_offset);
466 assert_eq!(pos.line, 0);
467 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 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 assert!(
542 vitest.version_range.is_some(),
543 "vitest should have a version_range"
544 );
545 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 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 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 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 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}