Skip to main content

deps_maven/
parser.rs

1//! pom.xml parser with byte-accurate position tracking.
2//!
3//! Uses quick-xml SAX reader to parse Maven POM files.
4//! Tracks byte positions for LSP range computation.
5
6use 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/// Context stack element for SAX parsing.
23#[derive(Debug, Clone, PartialEq, Eq)]
24enum ParseContext {
25    Root,
26    Dependencies,
27    DependencyManagement,
28    Plugins,
29    Dependency,
30    Plugin,
31    Properties,
32}
33
34/// Accumulator for a single dependency being parsed.
35#[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                        // Matches both top-level <plugins> and <build><plugins>:
85                        // <build> is silently ignored (falls through `_ => {}`), so
86                        // when <plugins> is encountered inside <build> the stack is
87                        // still at Root — this is intentional for MVP simplicity.
88                        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                    // Unescape XML entities
121                    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    // name_range covers the artifactId text (primary hover/action target)
214    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
252/// Resolves `${property}` references in a string using the properties map.
253///
254/// Handles `${project.version}` and similar Maven property expressions.
255/// Unresolved properties are left as-is.
256fn resolve_properties(input: &str, properties: &HashMap<String, String>) -> String {
257    let mut result = input.to_string();
258    // Iterate until no more replacements (handles nested, though rare)
259    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
281/// Finds the LSP range of `text` within content, searching near `hint_start`.
282///
283/// Limitation: uses `str::find` which returns the first occurrence at or after
284/// `hint_start`. For pom.xml files with duplicate artifactId values across
285/// different groupIds, the range may point to an earlier occurrence if the
286/// byte hint is imprecise. This is acceptable for MVP single-version-tag use.
287fn 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        // Unresolved property kept as-is
512        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        // Stray < inside text is a well-formed XML error
563        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        // quick-xml may or may not error on this; either empty deps or error is acceptable
566        if let Ok(ref r) = result {
567            // If parsed, groupId should not contain invalid XML content
568            let _ = r.dependencies.len();
569        }
570        // Malformed attribute triggers a hard error
571        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        // artifactId "my-lib" is on line 4 (0-indexed)
603        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}