1use crate::types::{MavenDependency, MavenScope};
7use deps_core::lsp_helpers::LineOffsetTable;
8use deps_core::{DepsError, Result};
9use quick_xml::Reader;
10use quick_xml::events::Event;
11use std::any::Any;
12use std::collections::HashMap;
13use tower_lsp_server::ls_types::{Range, Uri};
14
15#[derive(Debug)]
16pub struct MavenParseResult {
17 pub dependencies: Vec<MavenDependency>,
18 pub properties: HashMap<String, String>,
19 pub uri: Uri,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24enum ParseContext {
25 Root,
26 Dependencies,
27 DependencyManagement,
28 Plugins,
29 Dependency,
30 Plugin,
31 Properties,
32}
33
34#[derive(Default)]
36struct DepAccum {
37 group_id: Option<String>,
38 artifact_id: Option<String>,
39 artifact_id_start: u64,
40 artifact_id_end: u64,
41 version: Option<String>,
42 version_start: u64,
43 version_end: u64,
44 scope: Option<String>,
45}
46
47pub fn parse_pom_xml(content: &str, doc_uri: &Uri) -> Result<MavenParseResult> {
48 let line_table = LineOffsetTable::new(content);
49 let mut dependencies = Vec::new();
50 let mut properties = HashMap::new();
51
52 let mut reader = Reader::from_str(content);
53 reader.config_mut().trim_text(true);
54
55 let mut context_stack: Vec<ParseContext> = vec![ParseContext::Root];
56 let mut current_dep: Option<DepAccum> = None;
57 let mut current_tag: Option<String> = None;
58 let mut current_prop_key: Option<String> = None;
59 let mut root_tag: Option<String> = None;
60
61 loop {
62 let pos = reader.buffer_position();
63 let event = reader.read_event().map_err(|e| DepsError::ParseError {
64 file_type: "pom.xml".into(),
65 source: Box::new(std::io::Error::other(e.to_string())),
66 })?;
67
68 match event {
69 Event::Start(ref e) => {
70 let tag = e.local_name().as_ref().to_string();
71 let ctx = context_stack.last().cloned().unwrap_or(ParseContext::Root);
72
73 match (ctx, tag.as_str()) {
74 (ParseContext::Root, "dependencies") => {
75 context_stack.push(ParseContext::Dependencies);
76 }
77 (ParseContext::Root, "dependencyManagement") => {
78 context_stack.push(ParseContext::DependencyManagement);
79 }
80 (ParseContext::DependencyManagement, "dependencies") => {
81 context_stack.push(ParseContext::Dependencies);
82 }
83 (ParseContext::Root, "plugins") => {
84 context_stack.push(ParseContext::Plugins);
89 }
90 (ParseContext::Dependencies, "dependency") => {
91 context_stack.push(ParseContext::Dependency);
92 current_dep = Some(DepAccum::default());
93 current_tag = None;
94 }
95 (ParseContext::Plugins, "plugin") => {
96 context_stack.push(ParseContext::Plugin);
97 current_dep = Some(DepAccum::default());
98 current_tag = None;
99 }
100 (ParseContext::Root, "properties") => {
101 context_stack.push(ParseContext::Properties);
102 }
103 (ParseContext::Properties, key) => {
104 current_prop_key = Some(key.to_string());
105 }
106 (ParseContext::Dependency | ParseContext::Plugin, field) => {
107 current_tag = Some(field.to_string());
108 }
109 (ParseContext::Root, tag @ ("version" | "groupId" | "artifactId")) => {
110 root_tag = Some(tag.to_string());
111 }
112 _ => {}
113 }
114 let _ = pos;
115 }
116 Event::Text(ref e) => {
117 let text_start = pos;
118 let text = {
119 let s = e.trim().to_string();
120 quick_xml::escape::unescape(&s)
122 .map(|c| c.into_owned())
123 .unwrap_or(s)
124 };
125 let text_end = reader.buffer_position();
126
127 let ctx = context_stack.last().cloned().unwrap_or(ParseContext::Root);
128
129 if matches!(ctx, ParseContext::Dependency | ParseContext::Plugin) {
130 if let (Some(ref tag), Some(ref mut dep)) =
131 (current_tag.clone(), current_dep.as_mut())
132 {
133 match tag.as_str() {
134 "groupId" => {
135 dep.group_id = Some(text.clone());
136 }
137 "artifactId" => {
138 dep.artifact_id = Some(text.clone());
139 dep.artifact_id_start = text_start;
140 dep.artifact_id_end = text_end;
141 }
142 "version" => {
143 dep.version = Some(text.clone());
144 dep.version_start = text_start;
145 dep.version_end = text_end;
146 }
147 "scope" => {
148 dep.scope = Some(text.clone());
149 }
150 _ => {}
151 }
152 }
153 } else if ctx == ParseContext::Properties
154 && let Some(key) = current_prop_key.take()
155 {
156 properties.insert(key, text);
157 } else if ctx == ParseContext::Root
158 && let Some(tag) = root_tag.take()
159 {
160 let prop_key = format!("project.{tag}");
161 properties.insert(prop_key, text);
162 }
163 }
164 Event::End(ref e) => {
165 let tag = e.local_name().as_ref().to_string();
166 let ctx = context_stack.last().cloned().unwrap_or(ParseContext::Root);
167
168 match (ctx, tag.as_str()) {
169 (ParseContext::Dependency, "dependency") | (ParseContext::Plugin, "plugin") => {
170 context_stack.pop();
171 if let Some(dep) = current_dep.take()
172 && let Some(maven_dep) =
173 finalize_dep(dep, content, &line_table, &properties)
174 {
175 dependencies.push(maven_dep);
176 }
177 current_tag = None;
178 }
179 (ParseContext::Dependencies, "dependencies")
180 | (ParseContext::DependencyManagement, "dependencyManagement")
181 | (ParseContext::Plugins, "plugins")
182 | (ParseContext::Properties, "properties") => {
183 context_stack.pop();
184 }
185 (ParseContext::Dependency | ParseContext::Plugin, _) => {
186 current_tag = None;
187 }
188 _ => {}
189 }
190 }
191 Event::Eof => break,
192 _ => {}
193 }
194 }
195
196 Ok(MavenParseResult {
197 dependencies,
198 properties,
199 uri: doc_uri.clone(),
200 })
201}
202
203fn finalize_dep(
204 dep: DepAccum,
205 content: &str,
206 line_table: &LineOffsetTable,
207 properties: &HashMap<String, String>,
208) -> Option<MavenDependency> {
209 let group_id = dep.group_id?;
210 let artifact_id = dep.artifact_id?;
211 let name = format!("{group_id}:{artifact_id}");
212
213 let name_range = text_range(
215 content,
216 line_table,
217 dep.artifact_id_start as usize,
218 dep.artifact_id_end as usize,
219 &artifact_id,
220 );
221
222 let version_range = dep.version.as_ref().map(|v| {
223 text_range(
224 content,
225 line_table,
226 dep.version_start as usize,
227 dep.version_end as usize,
228 v,
229 )
230 });
231
232 let scope = dep
233 .scope
234 .as_deref()
235 .unwrap_or("compile")
236 .parse::<MavenScope>()
237 .unwrap_or_default();
238
239 let version_req = dep.version.map(|v| resolve_properties(&v, properties));
240
241 Some(MavenDependency {
242 group_id,
243 artifact_id,
244 name: name.into(),
245 name_range,
246 version_req: version_req.map(Into::into),
247 version_range,
248 scope,
249 })
250}
251
252fn resolve_properties(input: &str, properties: &HashMap<String, String>) -> String {
257 let mut result = input.to_string();
258 for _ in 0..5 {
260 let Some(start) = result.find("${") else {
261 break;
262 };
263 let Some(end) = result[start..].find('}') else {
264 break;
265 };
266 let key = &result[start + 2..start + end];
267 if let Some(value) = properties.get(key) {
268 result = format!(
269 "{}{}{}",
270 &result[..start],
271 value,
272 &result[start + end + 1..]
273 );
274 } else {
275 break;
276 }
277 }
278 result
279}
280
281fn text_range(
288 content: &str,
289 line_table: &LineOffsetTable,
290 hint_start: usize,
291 _hint_end: usize,
292 text: &str,
293) -> Range {
294 if text.is_empty() {
295 return Range::default();
296 }
297 let search_from = hint_start.min(content.len());
298 if let Some(rel) = content[search_from..].find(text) {
299 let abs_start = search_from + rel;
300 let abs_end = abs_start + text.len();
301 let start = line_table.byte_offset_to_position(content, abs_start);
302 let end = line_table.byte_offset_to_position(content, abs_end);
303 Range::new(start, end)
304 } else {
305 Range::default()
306 }
307}
308
309impl deps_core::ParseResult for MavenParseResult {
310 fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
311 self.dependencies
312 .iter()
313 .map(|d| d as &dyn deps_core::Dependency)
314 .collect()
315 }
316
317 fn workspace_root(&self) -> Option<&std::path::Path> {
318 None
319 }
320
321 fn uri(&self) -> &Uri {
322 &self.uri
323 }
324
325 fn as_any(&self) -> &dyn Any {
326 self
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 use std::assert_matches;
335
336 fn test_uri() -> Uri {
337 #[cfg(windows)]
338 let path = "C:/test/pom.xml";
339 #[cfg(not(windows))]
340 let path = "/test/pom.xml";
341 Uri::from_file_path(path).unwrap()
342 }
343
344 #[test]
345 fn test_parse_simple_pom() {
346 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
347<project>
348 <dependencies>
349 <dependency>
350 <groupId>org.apache.commons</groupId>
351 <artifactId>commons-lang3</artifactId>
352 <version>3.14.0</version>
353 </dependency>
354 </dependencies>
355</project>"#;
356
357 let result = parse_pom_xml(xml, &test_uri()).unwrap();
358 assert_eq!(result.dependencies.len(), 1);
359 let dep = &result.dependencies[0];
360 assert_eq!(dep.group_id, "org.apache.commons");
361 assert_eq!(dep.artifact_id, "commons-lang3");
362 assert_eq!(dep.name, "org.apache.commons:commons-lang3");
363 assert_eq!(dep.version_req, Some("3.14.0".into()));
364 assert_matches!(dep.scope, MavenScope::Compile);
365 }
366
367 #[test]
368 fn test_parse_multiple_deps() {
369 let xml = r"<project>
370 <dependencies>
371 <dependency>
372 <groupId>com.google.guava</groupId>
373 <artifactId>guava</artifactId>
374 <version>33.0.0-jre</version>
375 </dependency>
376 <dependency>
377 <groupId>junit</groupId>
378 <artifactId>junit</artifactId>
379 <version>4.13.2</version>
380 <scope>test</scope>
381 </dependency>
382 </dependencies>
383</project>";
384
385 let result = parse_pom_xml(xml, &test_uri()).unwrap();
386 assert_eq!(result.dependencies.len(), 2);
387 assert_eq!(result.dependencies[0].name, "com.google.guava:guava");
388 assert_eq!(result.dependencies[1].name, "junit:junit");
389 assert_matches!(result.dependencies[1].scope, MavenScope::Test);
390 }
391
392 #[test]
393 fn test_parse_dependency_management() {
394 let xml = r"<project>
395 <dependencyManagement>
396 <dependencies>
397 <dependency>
398 <groupId>org.springframework.boot</groupId>
399 <artifactId>spring-boot-dependencies</artifactId>
400 <version>3.2.0</version>
401 <type>pom</type>
402 <scope>import</scope>
403 </dependency>
404 </dependencies>
405 </dependencyManagement>
406</project>";
407
408 let result = parse_pom_xml(xml, &test_uri()).unwrap();
409 assert_eq!(result.dependencies.len(), 1);
410 assert_eq!(
411 result.dependencies[0].name,
412 "org.springframework.boot:spring-boot-dependencies"
413 );
414 assert_matches!(result.dependencies[0].scope, MavenScope::Import);
415 }
416
417 #[test]
418 fn test_parse_plugin_deps() {
419 let xml = r"<project>
420 <build>
421 <plugins>
422 <plugin>
423 <groupId>org.apache.maven.plugins</groupId>
424 <artifactId>maven-compiler-plugin</artifactId>
425 <version>3.11.0</version>
426 </plugin>
427 </plugins>
428 </build>
429</project>";
430
431 let result = parse_pom_xml(xml, &test_uri()).unwrap();
432 assert_eq!(result.dependencies.len(), 1);
433 assert_eq!(
434 result.dependencies[0].name,
435 "org.apache.maven.plugins:maven-compiler-plugin"
436 );
437 }
438
439 #[test]
440 fn test_parse_scopes() {
441 let xml = r"<project>
442 <dependencies>
443 <dependency>
444 <groupId>a</groupId>
445 <artifactId>b</artifactId>
446 <scope>runtime</scope>
447 </dependency>
448 <dependency>
449 <groupId>c</groupId>
450 <artifactId>d</artifactId>
451 <scope>provided</scope>
452 </dependency>
453 </dependencies>
454</project>";
455
456 let result = parse_pom_xml(xml, &test_uri()).unwrap();
457 assert_matches!(result.dependencies[0].scope, MavenScope::Runtime);
458 assert_matches!(result.dependencies[1].scope, MavenScope::Provided);
459 }
460
461 #[test]
462 fn test_parse_no_version() {
463 let xml = r"<project>
464 <dependencies>
465 <dependency>
466 <groupId>org.springframework</groupId>
467 <artifactId>spring-core</artifactId>
468 </dependency>
469 </dependencies>
470</project>";
471
472 let result = parse_pom_xml(xml, &test_uri()).unwrap();
473 assert_eq!(result.dependencies.len(), 1);
474 assert!(result.dependencies[0].version_req.is_none());
475 }
476
477 #[test]
478 fn test_parse_property_version_resolved() {
479 let xml = r"<project>
480 <properties>
481 <slf4j.version>2.0.16</slf4j.version>
482 </properties>
483 <dependencies>
484 <dependency>
485 <groupId>org.slf4j</groupId>
486 <artifactId>slf4j-api</artifactId>
487 <version>${slf4j.version}</version>
488 </dependency>
489 </dependencies>
490</project>";
491
492 let result = parse_pom_xml(xml, &test_uri()).unwrap();
493 assert_eq!(result.dependencies.len(), 1);
494 assert_eq!(result.dependencies[0].version_req, Some("2.0.16".into()));
495 }
496
497 #[test]
498 fn test_parse_property_version_unresolved() {
499 let xml = r"<project>
500 <dependencies>
501 <dependency>
502 <groupId>org.slf4j</groupId>
503 <artifactId>slf4j-api</artifactId>
504 <version>${slf4j.version}</version>
505 </dependency>
506 </dependencies>
507</project>";
508
509 let result = parse_pom_xml(xml, &test_uri()).unwrap();
510 assert_eq!(result.dependencies.len(), 1);
511 assert_eq!(
513 result.dependencies[0].version_req,
514 Some("${slf4j.version}".into())
515 );
516 }
517
518 #[test]
519 fn test_parse_project_version_property() {
520 let xml = r"<project>
521 <groupId>org.example</groupId>
522 <artifactId>my-app</artifactId>
523 <version>2.5.0</version>
524 <dependencies>
525 <dependency>
526 <groupId>org.example</groupId>
527 <artifactId>my-lib</artifactId>
528 <version>${project.version}</version>
529 </dependency>
530 </dependencies>
531</project>";
532
533 let result = parse_pom_xml(xml, &test_uri()).unwrap();
534 assert_eq!(result.dependencies[0].version_req, Some("2.5.0".into()));
535 assert_eq!(
536 result.properties.get("project.version"),
537 Some(&"2.5.0".to_string())
538 );
539 assert_eq!(
540 result.properties.get("project.groupId"),
541 Some(&"org.example".to_string())
542 );
543 assert_eq!(
544 result.properties.get("project.artifactId"),
545 Some(&"my-app".to_string())
546 );
547 }
548
549 #[test]
550 fn test_parse_empty_pom() {
551 let xml = r#"<?xml version="1.0"?>
552<project>
553 <modelVersion>4.0.0</modelVersion>
554</project>"#;
555
556 let result = parse_pom_xml(xml, &test_uri()).unwrap();
557 assert!(result.dependencies.is_empty());
558 }
559
560 #[test]
561 fn test_parse_invalid_xml() {
562 let xml = "<project><dependencies><dependency><groupId>a</groupId><artifactId>b < c</artifactId></dependency></dependencies></project>";
564 let result = parse_pom_xml(xml, &test_uri());
565 if let Ok(ref r) = result {
567 let _ = r.dependencies.len();
569 }
570 let xml2 = r#"<project attr="unclosed></project>"#;
572 let result2 = parse_pom_xml(xml2, &test_uri());
573 assert_matches!(
574 result2,
575 Err(DepsError::ParseError { file_type, .. }) if file_type == "pom.xml"
576 );
577 }
578
579 #[test]
580 fn test_parse_with_namespaces() {
581 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
582<project xmlns="http://maven.apache.org/POM/4.0.0">
583 <dependencies>
584 <dependency>
585 <groupId>junit</groupId>
586 <artifactId>junit</artifactId>
587 <version>4.13.2</version>
588 </dependency>
589 </dependencies>
590</project>"#;
591
592 let result = parse_pom_xml(xml, &test_uri()).unwrap();
593 assert_eq!(result.dependencies.len(), 1);
594 assert_eq!(result.dependencies[0].name, "junit:junit");
595 }
596
597 #[test]
598 fn test_position_tracking() {
599 let xml = "<project>\n <dependencies>\n <dependency>\n <groupId>com.example</groupId>\n <artifactId>my-lib</artifactId>\n <version>1.0.0</version>\n </dependency>\n </dependencies>\n</project>";
600 let result = parse_pom_xml(xml, &test_uri()).unwrap();
601 let dep = &result.dependencies[0];
602 assert_eq!(dep.name_range.start.line, 4);
604 }
605
606 #[test]
607 fn test_parse_result_trait() {
608 use deps_core::ParseResult;
609
610 let xml = r"<project>
611 <dependencies>
612 <dependency>
613 <groupId>a</groupId>
614 <artifactId>b</artifactId>
615 </dependency>
616 </dependencies>
617</project>";
618
619 let result = parse_pom_xml(xml, &test_uri()).unwrap();
620 assert_eq!(result.dependencies().len(), 1);
621 assert!(result.workspace_root().is_none());
622 assert!(result.as_any().is::<MavenParseResult>());
623 }
624
625 #[test]
626 fn test_resolve_properties() {
627 let mut props = HashMap::new();
628 props.insert("ver".to_string(), "1.0".to_string());
629 props.insert("suffix".to_string(), "RELEASE".to_string());
630
631 assert_eq!(resolve_properties("${ver}", &props), "1.0");
632 assert_eq!(resolve_properties("plain", &props), "plain");
633 assert_eq!(resolve_properties("${missing}", &props), "${missing}");
634 assert_eq!(
635 resolve_properties("${ver}-${suffix}", &props),
636 "1.0-RELEASE"
637 );
638 }
639
640 #[test]
641 fn test_parse_properties() {
642 let xml = r"<project>
643 <properties>
644 <java.version>17</java.version>
645 <spring.version>3.2.0</spring.version>
646 </properties>
647</project>";
648
649 let result = parse_pom_xml(xml, &test_uri()).unwrap();
650 assert_eq!(
651 result.properties.get("java.version"),
652 Some(&"17".to_string())
653 );
654 assert_eq!(
655 result.properties.get("spring.version"),
656 Some(&"3.2.0".to_string())
657 );
658 }
659}