Skip to main content

deps_cargo/
parser.rs

1//! Cargo.toml parser with position tracking.
2//!
3//! Parses Cargo.toml files using toml-span 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-span 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_span::value::{Table, Value};
36use tower_lsp_server::ls_types::{Range, Uri};
37
38pub use deps_core::lsp_helpers::LineOffsetTable;
39
40/// Result of parsing a Cargo.toml file.
41///
42/// Contains all extracted dependencies with their positions, plus optional
43/// workspace root information for resolving inherited dependencies.
44#[derive(Debug, Clone)]
45pub struct ParseResult {
46    /// All dependencies found in the file
47    pub dependencies: Vec<ParsedDependency>,
48    /// Workspace root path if this is a workspace member
49    pub workspace_root: Option<PathBuf>,
50    /// Document URI
51    pub uri: Uri,
52}
53
54/// Parses a Cargo.toml file and extracts all dependencies with positions.
55///
56/// # Errors
57///
58/// Returns an error if:
59/// - TOML syntax is invalid
60/// - File path cannot be converted from URL
61///
62/// # Examples
63///
64/// ```no_run
65/// use deps_cargo::parse_cargo_toml;
66/// use tower_lsp_server::ls_types::Uri;
67///
68/// let toml = r#"
69/// [dependencies]
70/// serde = "1.0"
71/// tokio = { version = "1.0", features = ["full"] }
72/// "#;
73///
74/// let url = Uri::from_file_path("/test/Cargo.toml").unwrap();
75/// let result = parse_cargo_toml(toml, &url).unwrap();
76/// assert_eq!(result.dependencies.len(), 2);
77/// ```
78pub fn parse_cargo_toml(content: &str, doc_uri: &Uri) -> Result<ParseResult> {
79    let doc = toml_span::parse(content).map_err(|e| CargoError::TomlParseError {
80        message: e.to_string(),
81    })?;
82
83    let line_table = LineOffsetTable::new(content);
84    let mut dependencies = Vec::new();
85
86    let root_table = doc.as_table().ok_or_else(|| CargoError::TomlParseError {
87        message: "root is not a table".into(),
88    })?;
89
90    if let Some(deps_val) = get_val(root_table, "dependencies")
91        && let Some(deps) = deps_val.as_table()
92    {
93        dependencies.extend(parse_dependencies_section(
94            deps,
95            content,
96            &line_table,
97            DependencySection::Dependencies,
98        ));
99    }
100
101    if let Some(dev_deps_val) = get_val(root_table, "dev-dependencies")
102        && let Some(dev_deps) = dev_deps_val.as_table()
103    {
104        dependencies.extend(parse_dependencies_section(
105            dev_deps,
106            content,
107            &line_table,
108            DependencySection::DevDependencies,
109        ));
110    }
111
112    if let Some(build_deps_val) = get_val(root_table, "build-dependencies")
113        && let Some(build_deps) = build_deps_val.as_table()
114    {
115        dependencies.extend(parse_dependencies_section(
116            build_deps,
117            content,
118            &line_table,
119            DependencySection::BuildDependencies,
120        ));
121    }
122
123    // Parse workspace dependencies (for workspace root Cargo.toml)
124    if let Some(workspace_val) = get_val(root_table, "workspace")
125        && let Some(workspace_table) = workspace_val.as_table()
126        && let Some(workspace_deps_val) = get_val(workspace_table, "dependencies")
127        && let Some(workspace_deps) = workspace_deps_val.as_table()
128    {
129        dependencies.extend(parse_dependencies_section(
130            workspace_deps,
131            content,
132            &line_table,
133            DependencySection::WorkspaceDependencies,
134        ));
135    }
136
137    let workspace_root = find_workspace_root(doc_uri)?;
138
139    Ok(ParseResult {
140        dependencies,
141        workspace_root,
142        uri: doc_uri.clone(),
143    })
144}
145
146fn get_val<'a>(table: &'a Table<'a>, key: &str) -> Option<&'a Value<'a>> {
147    table.get(key)
148}
149
150/// Parses a single dependency section (dependencies, dev-dependencies, or build-dependencies).
151fn parse_dependencies_section(
152    table: &Table<'_>,
153    content: &str,
154    line_table: &LineOffsetTable,
155    section: DependencySection,
156) -> Vec<ParsedDependency> {
157    let mut deps = Vec::new();
158
159    for (key, value) in table {
160        let name = key.name.to_string();
161        let name_range = span_to_range(content, line_table, key.span);
162
163        let mut dep = ParsedDependency {
164            name,
165            name_range,
166            version_req: None,
167            version_range: None,
168            features: Vec::new(),
169            features_range: None,
170            source: DependencySource::Registry,
171            section,
172        };
173
174        if let Some(s) = value.as_str() {
175            // Simple string version: serde = "1.0"
176            dep.version_req = Some(s.to_string());
177            dep.version_range = Some(span_to_range(content, line_table, value.span));
178        } else if let Some(t) = value.as_table() {
179            // Inline table or full table: serde = { version = "1.0" }
180            parse_table_dependency(&mut dep, t, content, line_table);
181        } else {
182            continue;
183        }
184
185        deps.push(dep);
186    }
187
188    deps
189}
190
191/// Parses a table (inline or full) dependency entry.
192fn parse_table_dependency(
193    dep: &mut ParsedDependency,
194    table: &Table<'_>,
195    content: &str,
196    line_table: &LineOffsetTable,
197) {
198    for (key, value) in table {
199        match key.name.as_ref() {
200            "version" => {
201                if let Some(s) = value.as_str() {
202                    dep.version_req = Some(s.to_string());
203                    dep.version_range = Some(span_to_range(content, line_table, value.span));
204                }
205            }
206            "features" => {
207                if let Some(arr) = value.as_array() {
208                    dep.features = arr
209                        .iter()
210                        .filter_map(|v| v.as_str().map(String::from))
211                        .collect();
212                    dep.features_range = Some(span_to_range(content, line_table, value.span));
213                }
214            }
215            "workspace" if value.as_bool() == Some(true) => {
216                dep.source = DependencySource::Workspace;
217            }
218            "workspace" => {}
219            "git" => {
220                if let Some(url) = value.as_str() {
221                    dep.source = DependencySource::Git {
222                        url: url.to_string(),
223                        rev: None,
224                    };
225                }
226            }
227            "path" => {
228                if let Some(path) = value.as_str() {
229                    dep.source = DependencySource::Path {
230                        path: path.to_string(),
231                    };
232                }
233            }
234            _ => {}
235        }
236    }
237}
238
239/// Converts toml-span byte offsets to LSP Range using pre-computed line table.
240fn span_to_range(content: &str, line_table: &LineOffsetTable, span: toml_span::Span) -> Range {
241    let start = line_table.byte_offset_to_position(content, span.start);
242    let end = line_table.byte_offset_to_position(content, span.end);
243    Range::new(start, end)
244}
245
246/// Finds the workspace root by walking up the directory tree.
247///
248/// Looks for a Cargo.toml file with a [workspace] section.
249fn find_workspace_root(doc_uri: &Uri) -> Result<Option<PathBuf>> {
250    let path = doc_uri
251        .to_file_path()
252        .ok_or_else(|| CargoError::invalid_uri(format!("{doc_uri:?}")))?;
253
254    let mut current = path.parent();
255
256    while let Some(dir) = current {
257        let workspace_toml = dir.join("Cargo.toml");
258
259        if workspace_toml.exists()
260            && let Ok(content) = std::fs::read_to_string(&workspace_toml)
261            && let Ok(doc) = toml_span::parse(&content)
262            && doc
263                .as_table()
264                .and_then(|t| get_val(t, "workspace"))
265                .is_some()
266        {
267            return Ok(Some(dir.to_path_buf()));
268        }
269
270        current = dir.parent();
271    }
272
273    Ok(None)
274}
275
276/// Parser for Cargo.toml manifests implementing the deps-core traits.
277pub struct CargoParser;
278
279impl deps_core::ManifestParser for CargoParser {
280    type Dependency = ParsedDependency;
281    type ParseResult = ParseResult;
282
283    fn parse(&self, content: &str, doc_uri: &Uri) -> deps_core::Result<Self::ParseResult> {
284        parse_cargo_toml(content, doc_uri).map_err(Into::into)
285    }
286}
287
288// Implement DependencyInfo trait for ParsedDependency
289impl deps_core::DependencyInfo for ParsedDependency {
290    fn name(&self) -> &str {
291        &self.name
292    }
293
294    fn name_range(&self) -> Range {
295        self.name_range
296    }
297
298    fn version_requirement(&self) -> Option<&str> {
299        self.version_req.as_deref()
300    }
301
302    fn version_range(&self) -> Option<Range> {
303        self.version_range
304    }
305
306    fn source(&self) -> deps_core::DependencySource {
307        self.source.clone()
308    }
309
310    fn features(&self) -> &[String] {
311        &self.features
312    }
313}
314
315// Implement ParseResultInfo trait for ParseResult (legacy)
316impl deps_core::ParseResultInfo for ParseResult {
317    type Dependency = ParsedDependency;
318
319    fn dependencies(&self) -> &[Self::Dependency] {
320        &self.dependencies
321    }
322
323    fn workspace_root(&self) -> Option<&std::path::Path> {
324        self.workspace_root.as_deref()
325    }
326}
327
328// Implement new ParseResult trait for trait object support
329impl deps_core::ParseResult for ParseResult {
330    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
331        self.dependencies
332            .iter()
333            .map(|d| d as &dyn deps_core::Dependency)
334            .collect()
335    }
336
337    fn workspace_root(&self) -> Option<&std::path::Path> {
338        self.workspace_root.as_deref()
339    }
340
341    fn uri(&self) -> &Uri {
342        &self.uri
343    }
344
345    fn as_any(&self) -> &dyn Any {
346        self
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353
354    fn test_url() -> Uri {
355        #[cfg(windows)]
356        let path = "C:/test/Cargo.toml";
357        #[cfg(not(windows))]
358        let path = "/test/Cargo.toml";
359        Uri::from_file_path(path).unwrap()
360    }
361
362    #[test]
363    fn test_parse_inline_dependency() {
364        let toml = r#"[dependencies]
365serde = "1.0""#;
366        let result = parse_cargo_toml(toml, &test_url()).unwrap();
367        assert_eq!(result.dependencies.len(), 1);
368        assert_eq!(result.dependencies[0].name, "serde");
369        assert_eq!(result.dependencies[0].version_req, Some("1.0".into()));
370        assert!(matches!(
371            result.dependencies[0].source,
372            DependencySource::Registry
373        ));
374    }
375
376    #[test]
377    fn test_parse_table_dependency() {
378        let toml = r#"[dependencies]
379serde = { version = "1.0", features = ["derive"] }"#;
380        let result = parse_cargo_toml(toml, &test_url()).unwrap();
381        assert_eq!(result.dependencies.len(), 1);
382        assert_eq!(result.dependencies[0].version_req, Some("1.0".into()));
383        assert_eq!(result.dependencies[0].features, vec!["derive"]);
384    }
385
386    #[test]
387    fn test_parse_workspace_inheritance() {
388        let toml = r"[dependencies]
389serde = { workspace = true }";
390        let result = parse_cargo_toml(toml, &test_url()).unwrap();
391        assert_eq!(result.dependencies.len(), 1);
392        assert!(matches!(
393            result.dependencies[0].source,
394            DependencySource::Workspace
395        ));
396    }
397
398    #[test]
399    fn test_parse_git_dependency() {
400        let toml = r#"[dependencies]
401tower-lsp = { git = "https://github.com/ebkalderon/tower-lsp", branch = "main" }"#;
402        let result = parse_cargo_toml(toml, &test_url()).unwrap();
403        assert_eq!(result.dependencies.len(), 1);
404        assert!(matches!(
405            result.dependencies[0].source,
406            DependencySource::Git { .. }
407        ));
408    }
409
410    #[test]
411    fn test_parse_path_dependency() {
412        let toml = r#"[dependencies]
413local = { path = "../local" }"#;
414        let result = parse_cargo_toml(toml, &test_url()).unwrap();
415        assert_eq!(result.dependencies.len(), 1);
416        assert!(matches!(
417            result.dependencies[0].source,
418            DependencySource::Path { .. }
419        ));
420    }
421
422    #[test]
423    fn test_parse_multiple_sections() {
424        let toml = r#"
425[dependencies]
426serde = "1.0"
427
428[dev-dependencies]
429insta = "1.0"
430
431[build-dependencies]
432cc = "1.0"
433"#;
434        let result = parse_cargo_toml(toml, &test_url()).unwrap();
435        assert_eq!(result.dependencies.len(), 3);
436
437        assert!(matches!(
438            result.dependencies[0].section,
439            DependencySection::Dependencies
440        ));
441        assert!(matches!(
442            result.dependencies[1].section,
443            DependencySection::DevDependencies
444        ));
445        assert!(matches!(
446            result.dependencies[2].section,
447            DependencySection::BuildDependencies
448        ));
449    }
450
451    #[test]
452    fn test_line_offset_table() {
453        let content = "abc\ndef";
454        let table = LineOffsetTable::new(content);
455        let pos = table.byte_offset_to_position(content, 4);
456        assert_eq!(pos.line, 1);
457        assert_eq!(pos.character, 0);
458    }
459
460    #[test]
461    fn test_line_offset_table_unicode() {
462        let content = "hello 世界\nworld";
463        let table = LineOffsetTable::new(content);
464        let world_offset = content.find("world").unwrap();
465        let pos = table.byte_offset_to_position(content, world_offset);
466        assert_eq!(pos.line, 1);
467        assert_eq!(pos.character, 0);
468    }
469
470    #[test]
471    fn test_malformed_toml() {
472        let toml = r#"[dependencies
473serde = "1.0"#;
474        let result = parse_cargo_toml(toml, &test_url());
475        assert!(result.is_err());
476    }
477
478    #[test]
479    fn test_empty_dependencies() {
480        let toml = r"[dependencies]";
481        let result = parse_cargo_toml(toml, &test_url()).unwrap();
482        assert_eq!(result.dependencies.len(), 0);
483    }
484
485    #[test]
486    fn test_position_tracking() {
487        let toml = r#"[dependencies]
488serde = "1.0""#;
489        let result = parse_cargo_toml(toml, &test_url()).unwrap();
490        let dep = &result.dependencies[0];
491
492        assert_eq!(dep.name, "serde");
493        assert_eq!(dep.version_req, Some("1.0".into()));
494
495        // Verify name_range is on line 1 (after [dependencies])
496        assert_eq!(dep.name_range.start.line, 1);
497        // serde starts at column 0 on that line
498        assert_eq!(dep.name_range.start.character, 0);
499        // Verify end position is after "serde" (5 characters)
500        assert_eq!(dep.name_range.end.character, 5);
501    }
502
503    #[test]
504    fn test_name_range_tracking() {
505        let toml = r#"[dependencies]
506serde = "1.0"
507tokio = { version = "1.0", features = ["full"] }"#;
508        let result = parse_cargo_toml(toml, &test_url()).unwrap();
509
510        for dep in &result.dependencies {
511            // All dependencies should have non-default name ranges
512            let is_default = dep.name_range.start.line == 0
513                && dep.name_range.start.character == 0
514                && dep.name_range.end.line == 0
515                && dep.name_range.end.character == 0;
516            assert!(
517                !is_default,
518                "name_range should not be default for {}",
519                dep.name
520            );
521        }
522    }
523
524    #[test]
525    fn test_parse_workspace_dependencies() {
526        let toml = r#"
527[workspace]
528members = ["crates/*"]
529
530[workspace.dependencies]
531serde = "1.0"
532tokio = { version = "1.0", features = ["full"] }
533"#;
534        let result = parse_cargo_toml(toml, &test_url()).unwrap();
535        assert_eq!(result.dependencies.len(), 2);
536
537        for dep in &result.dependencies {
538            assert!(matches!(
539                dep.section,
540                DependencySection::WorkspaceDependencies
541            ));
542        }
543
544        let serde = result.dependencies.iter().find(|d| d.name == "serde");
545        assert!(serde.is_some());
546        let serde = serde.unwrap();
547        assert_eq!(serde.version_req, Some("1.0".into()));
548        // version_range should be set for inlay hints
549        assert!(
550            serde.version_range.is_some(),
551            "version_range should be set for serde"
552        );
553
554        let tokio = result.dependencies.iter().find(|d| d.name == "tokio");
555        assert!(tokio.is_some());
556        let tokio = tokio.unwrap();
557        assert_eq!(tokio.version_req, Some("1.0".into()));
558        assert_eq!(tokio.features, vec!["full"]);
559        // version_range should be set for inlay hints
560        assert!(
561            tokio.version_range.is_some(),
562            "version_range should be set for tokio"
563        );
564    }
565
566    #[test]
567    fn test_parse_workspace_and_regular_dependencies() {
568        let toml = r#"
569[workspace]
570members = ["crates/*"]
571
572[workspace.dependencies]
573serde = "1.0"
574
575[dependencies]
576tokio = "1.0"
577"#;
578        let result = parse_cargo_toml(toml, &test_url()).unwrap();
579        assert_eq!(result.dependencies.len(), 2);
580
581        let serde = result.dependencies.iter().find(|d| d.name == "serde");
582        assert!(serde.is_some());
583        assert!(matches!(
584            serde.unwrap().section,
585            DependencySection::WorkspaceDependencies
586        ));
587
588        let tokio = result.dependencies.iter().find(|d| d.name == "tokio");
589        assert!(tokio.is_some());
590        assert!(matches!(
591            tokio.unwrap().section,
592            DependencySection::Dependencies
593        ));
594    }
595}