1use 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
13struct 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 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#[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
74pub fn is_platform_package(name: &str) -> bool {
81 name == "php" || name.starts_with("ext-") || name.starts_with("lib-")
82}
83
84pub 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
141fn 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
182fn 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 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")); }
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 #[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 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}