1use 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#[derive(Debug)]
18pub struct ComposerParseResult {
19 pub dependencies: Vec<ComposerDependency>,
20 pub uri: Uri,
21 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
54pub 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
92pub 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 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 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
169fn 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
209fn 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 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 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")); }
356
357 #[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 #[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 #[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 #[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 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 #[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 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 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 assert!(version_range.is_none());
597 }
598
599 #[test]
600 fn test_find_positions_finds_version_when_multibyte_char_is_further_out() {
601 let name = "vendor/pkg";
606 let version = "1.0.0-x".to_string(); 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 let expected_start = content.find("ed_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 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 #[test]
657 fn test_duplicate_name_across_require_and_require_dev() {
658 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}