deps_cargo/
parser.rs

1//! Cargo.toml parser with position tracking.
2//!
3//! Parses Cargo.toml files using toml_edit to preserve formatting and extract
4//! precise LSP positions for every dependency field. Critical for features like
5//! hover, completion, and inlay hints.
6//!
7//! # Key Features
8//!
9//! - Position-preserving parsing via toml_edit spans
10//! - Handles all dependency formats: inline, table, workspace inheritance
11//! - Extracts dependencies from all sections: dependencies, dev-dependencies, build-dependencies
12//! - Converts byte offsets to LSP Position (line, UTF-16 character)
13//!
14//! # Examples
15//!
16//! ```no_run
17//! use deps_cargo::parse_cargo_toml;
18//! use tower_lsp_server::ls_types::Uri;
19//!
20//! let toml = r#"
21//! [dependencies]
22//! serde = "1.0"
23//! "#;
24//!
25//! let url = Uri::from_file_path("/test/Cargo.toml").unwrap();
26//! let result = parse_cargo_toml(toml, &url).unwrap();
27//! assert_eq!(result.dependencies.len(), 1);
28//! assert_eq!(result.dependencies[0].name, "serde");
29//! ```
30
31use crate::error::{CargoError, Result};
32use crate::types::{DependencySection, DependencySource, ParsedDependency};
33use std::any::Any;
34use std::path::PathBuf;
35use toml_edit::{Document, DocumentMut, Item, Table, Value};
36use tower_lsp_server::ls_types::{Position, Range, Uri};
37
38/// Result of parsing a Cargo.toml file.
39///
40/// Contains all extracted dependencies with their positions, plus optional
41/// workspace root information for resolving inherited dependencies.
42#[derive(Debug, Clone)]
43pub struct ParseResult {
44    /// All dependencies found in the file
45    pub dependencies: Vec<ParsedDependency>,
46    /// Workspace root path if this is a workspace member
47    pub workspace_root: Option<PathBuf>,
48    /// Document URI
49    pub uri: Uri,
50}
51
52/// Pre-computed line start byte offsets for O(1) position lookups.
53struct LineOffsetTable {
54    line_starts: Vec<usize>,
55}
56
57impl LineOffsetTable {
58    fn new(content: &str) -> Self {
59        let mut line_starts = vec![0];
60        for (i, c) in content.char_indices() {
61            if c == '\n' {
62                line_starts.push(i + 1);
63            }
64        }
65        Self { line_starts }
66    }
67
68    fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
69        let line = self
70            .line_starts
71            .partition_point(|&start| start <= offset)
72            .saturating_sub(1);
73        let line_start = self.line_starts[line];
74
75        let character = content[line_start..offset]
76            .chars()
77            .map(|c| c.len_utf16() as u32)
78            .sum();
79
80        Position::new(line as u32, character)
81    }
82}
83
84/// Parses a Cargo.toml file and extracts all dependencies with positions.
85///
86/// # Errors
87///
88/// Returns an error if:
89/// - TOML syntax is invalid
90/// - File path cannot be converted from URL
91///
92/// # Examples
93///
94/// ```no_run
95/// use deps_cargo::parse_cargo_toml;
96/// use tower_lsp_server::ls_types::Uri;
97///
98/// let toml = r#"
99/// [dependencies]
100/// serde = "1.0"
101/// tokio = { version = "1.0", features = ["full"] }
102/// "#;
103///
104/// let url = Uri::from_file_path("/test/Cargo.toml").unwrap();
105/// let result = parse_cargo_toml(toml, &url).unwrap();
106/// assert_eq!(result.dependencies.len(), 2);
107/// ```
108pub fn parse_cargo_toml(content: &str, doc_uri: &Uri) -> Result<ParseResult> {
109    // Use Document (not DocumentMut) to preserve span information
110    let doc: Document<&str> =
111        Document::parse(content).map_err(|e| CargoError::TomlParseError { source: e })?;
112
113    let line_table = LineOffsetTable::new(content);
114    let mut dependencies = Vec::new();
115
116    if let Some(deps_item) = doc.get("dependencies")
117        && let Some(deps) = deps_item.as_table()
118    {
119        dependencies.extend(parse_dependencies_section(
120            deps,
121            content,
122            &line_table,
123            DependencySection::Dependencies,
124        )?);
125    }
126
127    if let Some(dev_deps_item) = doc.get("dev-dependencies")
128        && let Some(dev_deps) = dev_deps_item.as_table()
129    {
130        dependencies.extend(parse_dependencies_section(
131            dev_deps,
132            content,
133            &line_table,
134            DependencySection::DevDependencies,
135        )?);
136    }
137
138    if let Some(build_deps_item) = doc.get("build-dependencies")
139        && let Some(build_deps) = build_deps_item.as_table()
140    {
141        dependencies.extend(parse_dependencies_section(
142            build_deps,
143            content,
144            &line_table,
145            DependencySection::BuildDependencies,
146        )?);
147    }
148
149    // Parse workspace dependencies (for workspace root Cargo.toml)
150    if let Some(workspace_item) = doc.get("workspace")
151        && let Some(workspace_table) = workspace_item.as_table()
152        && let Some(workspace_deps_item) = workspace_table.get("dependencies")
153        && let Some(workspace_deps) = workspace_deps_item.as_table()
154    {
155        dependencies.extend(parse_dependencies_section(
156            workspace_deps,
157            content,
158            &line_table,
159            DependencySection::WorkspaceDependencies,
160        )?);
161    }
162
163    let workspace_root = find_workspace_root(doc_uri)?;
164
165    Ok(ParseResult {
166        dependencies,
167        workspace_root,
168        uri: doc_uri.clone(),
169    })
170}
171
172/// Parses a single dependency section (dependencies, dev-dependencies, or build-dependencies).
173fn parse_dependencies_section(
174    table: &Table,
175    content: &str,
176    line_table: &LineOffsetTable,
177    section: DependencySection,
178) -> Result<Vec<ParsedDependency>> {
179    let mut deps = Vec::new();
180
181    for (key, value) in table {
182        let name = key.to_string();
183
184        let name_range = compute_name_range_from_value(content, line_table, &name, value);
185
186        let mut dep = ParsedDependency {
187            name,
188            name_range,
189            version_req: None,
190            version_range: None,
191            features: Vec::new(),
192            features_range: None,
193            source: DependencySource::Registry,
194            workspace_inherited: false,
195            section,
196        };
197
198        match value {
199            Item::Value(Value::String(s)) => {
200                dep.version_req = Some(s.value().clone());
201                if let Some(span) = s.span() {
202                    dep.version_range = Some(span_to_range_with_table(
203                        content, line_table, span.start, span.end,
204                    ));
205                }
206            }
207            Item::Value(Value::InlineTable(t)) => {
208                parse_inline_table_dependency(&mut dep, t, content, line_table)?;
209            }
210            Item::Table(t) => {
211                parse_table_dependency(&mut dep, t, content, line_table)?;
212            }
213            _ => continue,
214        }
215
216        deps.push(dep);
217    }
218
219    Ok(deps)
220}
221
222/// Computes the name range by searching backwards from the value position.
223fn compute_name_range_from_value(
224    content: &str,
225    line_table: &LineOffsetTable,
226    name: &str,
227    value: &Item,
228) -> Range {
229    let value_span = match value {
230        Item::Value(v) => v.span(),
231        Item::Table(t) => t.span(),
232        _ => None,
233    };
234
235    if let Some(span) = value_span {
236        let search_start = span.start.saturating_sub(name.len() + 100);
237        let search_end = span.start;
238
239        if search_start < content.len() && search_end <= content.len() {
240            let search_slice = &content[search_start..search_end];
241
242            if let Some(pos) = search_slice.rfind(name) {
243                let name_start = search_start + pos;
244                let name_end = name_start + name.len();
245
246                if name_end <= search_end && name_start < content.len() && name_end <= content.len()
247                {
248                    return span_to_range_with_table(content, line_table, name_start, name_end);
249                }
250            }
251        }
252    } else {
253        // Fallback: search for the name in the entire content
254        if let Some(pos) = content.find(name) {
255            let name_start = pos;
256            let name_end = pos + name.len();
257            if name_end <= content.len() {
258                return span_to_range_with_table(content, line_table, name_start, name_end);
259            }
260        }
261    }
262
263    Range::default()
264}
265
266/// Parses an inline table dependency.
267fn parse_inline_table_dependency(
268    dep: &mut ParsedDependency,
269    table: &toml_edit::InlineTable,
270    content: &str,
271    line_table: &LineOffsetTable,
272) -> Result<()> {
273    for (key, value) in table {
274        match key {
275            "version" => {
276                if let Some(s) = value.as_str() {
277                    dep.version_req = Some(s.to_string());
278                    if let Some(span) = value.span() {
279                        dep.version_range = Some(span_to_range_with_table(
280                            content, line_table, span.start, span.end,
281                        ));
282                    }
283                }
284            }
285            "features" => {
286                if let Some(arr) = value.as_array() {
287                    dep.features = arr
288                        .iter()
289                        .filter_map(|v| v.as_str().map(String::from))
290                        .collect();
291                    if let Some(span) = value.span() {
292                        dep.features_range = Some(span_to_range_with_table(
293                            content, line_table, span.start, span.end,
294                        ));
295                    }
296                }
297            }
298            "workspace" if value.as_bool() == Some(true) => {
299                dep.workspace_inherited = true;
300            }
301            "git" => {
302                if let Some(url) = value.as_str() {
303                    dep.source = DependencySource::Git {
304                        url: url.to_string(),
305                        rev: None,
306                    };
307                }
308            }
309            "path" => {
310                if let Some(path) = value.as_str() {
311                    dep.source = DependencySource::Path {
312                        path: path.to_string(),
313                    };
314                }
315            }
316            _ => {}
317        }
318    }
319
320    Ok(())
321}
322
323/// Parses a full table dependency.
324fn parse_table_dependency(
325    dep: &mut ParsedDependency,
326    table: &Table,
327    content: &str,
328    line_table: &LineOffsetTable,
329) -> Result<()> {
330    for (key, item) in table {
331        let Item::Value(value) = item else {
332            continue;
333        };
334
335        match key {
336            "version" => {
337                if let Some(s) = value.as_str() {
338                    dep.version_req = Some(s.to_string());
339                    if let Some(span) = value.span() {
340                        dep.version_range = Some(span_to_range_with_table(
341                            content, line_table, span.start, span.end,
342                        ));
343                    }
344                }
345            }
346            "features" => {
347                if let Some(arr) = value.as_array() {
348                    dep.features = arr
349                        .iter()
350                        .filter_map(|v| v.as_str().map(String::from))
351                        .collect();
352                    if let Some(span) = value.span() {
353                        dep.features_range = Some(span_to_range_with_table(
354                            content, line_table, span.start, span.end,
355                        ));
356                    }
357                }
358            }
359            "workspace" if value.as_bool() == Some(true) => {
360                dep.workspace_inherited = true;
361            }
362            "git" => {
363                if let Some(url) = value.as_str() {
364                    dep.source = DependencySource::Git {
365                        url: url.to_string(),
366                        rev: None,
367                    };
368                }
369            }
370            "path" => {
371                if let Some(path) = value.as_str() {
372                    dep.source = DependencySource::Path {
373                        path: path.to_string(),
374                    };
375                }
376            }
377            _ => {}
378        }
379    }
380
381    Ok(())
382}
383
384/// Converts toml_edit byte offsets to LSP Range using pre-computed line table.
385fn span_to_range_with_table(
386    content: &str,
387    line_table: &LineOffsetTable,
388    start: usize,
389    end: usize,
390) -> Range {
391    let start_pos = line_table.byte_offset_to_position(content, start);
392    let end_pos = line_table.byte_offset_to_position(content, end);
393    Range::new(start_pos, end_pos)
394}
395
396/// Finds the workspace root by walking up the directory tree.
397///
398/// Looks for a Cargo.toml file with a [workspace] section.
399fn find_workspace_root(doc_uri: &Uri) -> Result<Option<PathBuf>> {
400    let path = doc_uri
401        .to_file_path()
402        .ok_or_else(|| CargoError::invalid_uri(format!("{doc_uri:?}")))?;
403
404    let mut current = path.parent();
405
406    while let Some(dir) = current {
407        let workspace_toml = dir.join("Cargo.toml");
408
409        if workspace_toml.exists()
410            && let Ok(content) = std::fs::read_to_string(&workspace_toml)
411            && let Ok(doc) = content.parse::<DocumentMut>()
412            && doc.get("workspace").is_some()
413        {
414            return Ok(Some(dir.to_path_buf()));
415        }
416
417        current = dir.parent();
418    }
419
420    Ok(None)
421}
422
423/// Parser for Cargo.toml manifests implementing the deps-core traits.
424pub struct CargoParser;
425
426impl deps_core::ManifestParser for CargoParser {
427    type Dependency = ParsedDependency;
428    type ParseResult = ParseResult;
429
430    fn parse(&self, content: &str, doc_uri: &Uri) -> deps_core::Result<Self::ParseResult> {
431        parse_cargo_toml(content, doc_uri).map_err(Into::into)
432    }
433}
434
435// Implement DependencyInfo trait for ParsedDependency
436impl deps_core::DependencyInfo for ParsedDependency {
437    fn name(&self) -> &str {
438        &self.name
439    }
440
441    fn name_range(&self) -> Range {
442        self.name_range
443    }
444
445    fn version_requirement(&self) -> Option<&str> {
446        self.version_req.as_deref()
447    }
448
449    fn version_range(&self) -> Option<Range> {
450        self.version_range
451    }
452
453    fn source(&self) -> deps_core::DependencySource {
454        match &self.source {
455            DependencySource::Registry => deps_core::DependencySource::Registry,
456            DependencySource::Git { url, rev } => deps_core::DependencySource::Git {
457                url: url.clone(),
458                rev: rev.clone(),
459            },
460            DependencySource::Path { path } => {
461                deps_core::DependencySource::Path { path: path.clone() }
462            }
463        }
464    }
465
466    fn features(&self) -> &[String] {
467        &self.features
468    }
469}
470
471// Implement ParseResultInfo trait for ParseResult (legacy)
472impl deps_core::ParseResultInfo for ParseResult {
473    type Dependency = ParsedDependency;
474
475    fn dependencies(&self) -> &[Self::Dependency] {
476        &self.dependencies
477    }
478
479    fn workspace_root(&self) -> Option<&std::path::Path> {
480        self.workspace_root.as_deref()
481    }
482}
483
484// Implement new ParseResult trait for trait object support
485impl deps_core::ParseResult for ParseResult {
486    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
487        self.dependencies
488            .iter()
489            .map(|d| d as &dyn deps_core::Dependency)
490            .collect()
491    }
492
493    fn workspace_root(&self) -> Option<&std::path::Path> {
494        self.workspace_root.as_deref()
495    }
496
497    fn uri(&self) -> &Uri {
498        &self.uri
499    }
500
501    fn as_any(&self) -> &dyn Any {
502        self
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    fn test_url() -> Uri {
511        #[cfg(windows)]
512        let path = "C:/test/Cargo.toml";
513        #[cfg(not(windows))]
514        let path = "/test/Cargo.toml";
515        Uri::from_file_path(path).unwrap()
516    }
517
518    #[test]
519    fn test_parse_inline_dependency() {
520        let toml = r#"[dependencies]
521serde = "1.0""#;
522        let result = parse_cargo_toml(toml, &test_url()).unwrap();
523        assert_eq!(result.dependencies.len(), 1);
524        assert_eq!(result.dependencies[0].name, "serde");
525        assert_eq!(result.dependencies[0].version_req, Some("1.0".into()));
526        assert!(matches!(
527            result.dependencies[0].source,
528            DependencySource::Registry
529        ));
530    }
531
532    #[test]
533    fn test_parse_table_dependency() {
534        let toml = r#"[dependencies]
535serde = { version = "1.0", features = ["derive"] }"#;
536        let result = parse_cargo_toml(toml, &test_url()).unwrap();
537        assert_eq!(result.dependencies.len(), 1);
538        assert_eq!(result.dependencies[0].version_req, Some("1.0".into()));
539        assert_eq!(result.dependencies[0].features, vec!["derive"]);
540    }
541
542    #[test]
543    fn test_parse_workspace_inheritance() {
544        let toml = r"[dependencies]
545serde = { workspace = true }";
546        let result = parse_cargo_toml(toml, &test_url()).unwrap();
547        assert_eq!(result.dependencies.len(), 1);
548        assert!(result.dependencies[0].workspace_inherited);
549    }
550
551    #[test]
552    fn test_parse_git_dependency() {
553        let toml = r#"[dependencies]
554tower-lsp = { git = "https://github.com/ebkalderon/tower-lsp", branch = "main" }"#;
555        let result = parse_cargo_toml(toml, &test_url()).unwrap();
556        assert_eq!(result.dependencies.len(), 1);
557        assert!(matches!(
558            result.dependencies[0].source,
559            DependencySource::Git { .. }
560        ));
561    }
562
563    #[test]
564    fn test_parse_path_dependency() {
565        let toml = r#"[dependencies]
566local = { path = "../local" }"#;
567        let result = parse_cargo_toml(toml, &test_url()).unwrap();
568        assert_eq!(result.dependencies.len(), 1);
569        assert!(matches!(
570            result.dependencies[0].source,
571            DependencySource::Path { .. }
572        ));
573    }
574
575    #[test]
576    fn test_parse_multiple_sections() {
577        let toml = r#"
578[dependencies]
579serde = "1.0"
580
581[dev-dependencies]
582insta = "1.0"
583
584[build-dependencies]
585cc = "1.0"
586"#;
587        let result = parse_cargo_toml(toml, &test_url()).unwrap();
588        assert_eq!(result.dependencies.len(), 3);
589
590        assert!(matches!(
591            result.dependencies[0].section,
592            DependencySection::Dependencies
593        ));
594        assert!(matches!(
595            result.dependencies[1].section,
596            DependencySection::DevDependencies
597        ));
598        assert!(matches!(
599            result.dependencies[2].section,
600            DependencySection::BuildDependencies
601        ));
602    }
603
604    #[test]
605    fn test_line_offset_table() {
606        let content = "abc\ndef";
607        let table = LineOffsetTable::new(content);
608        let pos = table.byte_offset_to_position(content, 4);
609        assert_eq!(pos.line, 1);
610        assert_eq!(pos.character, 0);
611    }
612
613    #[test]
614    fn test_line_offset_table_unicode() {
615        let content = "hello 世界\nworld";
616        let table = LineOffsetTable::new(content);
617        let world_offset = content.find("world").unwrap();
618        let pos = table.byte_offset_to_position(content, world_offset);
619        assert_eq!(pos.line, 1);
620        assert_eq!(pos.character, 0);
621    }
622
623    #[test]
624    fn test_malformed_toml() {
625        let toml = r#"[dependencies
626serde = "1.0"#;
627        let result = parse_cargo_toml(toml, &test_url());
628        assert!(result.is_err());
629    }
630
631    #[test]
632    fn test_empty_dependencies() {
633        let toml = r"[dependencies]";
634        let result = parse_cargo_toml(toml, &test_url()).unwrap();
635        assert_eq!(result.dependencies.len(), 0);
636    }
637
638    #[test]
639    fn test_position_tracking() {
640        let toml = r#"[dependencies]
641serde = "1.0""#;
642        let result = parse_cargo_toml(toml, &test_url()).unwrap();
643        let dep = &result.dependencies[0];
644
645        assert_eq!(dep.name, "serde");
646        assert_eq!(dep.version_req, Some("1.0".into()));
647
648        // Verify name_range is on line 1 (after [dependencies])
649        assert_eq!(dep.name_range.start.line, 1);
650        // serde starts at column 0 on that line
651        assert_eq!(dep.name_range.start.character, 0);
652        // Verify end position is after "serde" (5 characters)
653        assert_eq!(dep.name_range.end.character, 5);
654    }
655
656    #[test]
657    fn test_name_range_tracking() {
658        let toml = r#"[dependencies]
659serde = "1.0"
660tokio = { version = "1.0", features = ["full"] }"#;
661        let result = parse_cargo_toml(toml, &test_url()).unwrap();
662
663        for dep in &result.dependencies {
664            // All dependencies should have non-default name ranges
665            let is_default = dep.name_range.start.line == 0
666                && dep.name_range.start.character == 0
667                && dep.name_range.end.line == 0
668                && dep.name_range.end.character == 0;
669            assert!(
670                !is_default,
671                "name_range should not be default for {}",
672                dep.name
673            );
674        }
675    }
676
677    #[test]
678    fn test_parse_workspace_dependencies() {
679        let toml = r#"
680[workspace]
681members = ["crates/*"]
682
683[workspace.dependencies]
684serde = "1.0"
685tokio = { version = "1.0", features = ["full"] }
686"#;
687        let result = parse_cargo_toml(toml, &test_url()).unwrap();
688        assert_eq!(result.dependencies.len(), 2);
689
690        for dep in &result.dependencies {
691            assert!(matches!(
692                dep.section,
693                DependencySection::WorkspaceDependencies
694            ));
695        }
696
697        let serde = result.dependencies.iter().find(|d| d.name == "serde");
698        assert!(serde.is_some());
699        let serde = serde.unwrap();
700        assert_eq!(serde.version_req, Some("1.0".into()));
701        // version_range should be set for inlay hints
702        assert!(
703            serde.version_range.is_some(),
704            "version_range should be set for serde"
705        );
706
707        let tokio = result.dependencies.iter().find(|d| d.name == "tokio");
708        assert!(tokio.is_some());
709        let tokio = tokio.unwrap();
710        assert_eq!(tokio.version_req, Some("1.0".into()));
711        assert_eq!(tokio.features, vec!["full"]);
712        // version_range should be set for inlay hints
713        assert!(
714            tokio.version_range.is_some(),
715            "version_range should be set for tokio"
716        );
717    }
718
719    #[test]
720    fn test_parse_workspace_and_regular_dependencies() {
721        let toml = r#"
722[workspace]
723members = ["crates/*"]
724
725[workspace.dependencies]
726serde = "1.0"
727
728[dependencies]
729tokio = "1.0"
730"#;
731        let result = parse_cargo_toml(toml, &test_url()).unwrap();
732        assert_eq!(result.dependencies.len(), 2);
733
734        let serde = result.dependencies.iter().find(|d| d.name == "serde");
735        assert!(serde.is_some());
736        assert!(matches!(
737            serde.unwrap().section,
738            DependencySection::WorkspaceDependencies
739        ));
740
741        let tokio = result.dependencies.iter().find(|d| d.name == "tokio");
742        assert!(tokio.is_some());
743        assert!(matches!(
744            tokio.unwrap().section,
745            DependencySection::Dependencies
746        ));
747    }
748}