Skip to main content

deps_dart/
parser.rs

1//! pubspec.yaml parser with position tracking.
2
3use crate::types::{DartDependency, DependencySection, DependencySource};
4use deps_core::lsp_helpers::LineOffsetTable;
5use deps_core::{DepsError, Result};
6use std::any::Any;
7use tower_lsp_server::ls_types::{Range, Uri};
8use yaml_rust2::{Yaml, YamlLoader};
9
10#[derive(Debug, Clone)]
11pub struct DartParseResult {
12    pub dependencies: Vec<DartDependency>,
13    pub sdk_constraint: Option<String>,
14    pub uri: Uri,
15}
16
17pub fn parse_pubspec_yaml(content: &str, doc_uri: &Uri) -> Result<DartParseResult> {
18    if let Err(depth) =
19        deps_core::check_yaml_nesting_depth(content, deps_core::MAX_YAML_NESTING_DEPTH)
20    {
21        return Err(DepsError::ParseError {
22            file_type: "pubspec.yaml".into(),
23            source: Box::new(std::io::Error::other(format!(
24                "YAML nesting depth {depth} exceeds maximum of {}",
25                deps_core::MAX_YAML_NESTING_DEPTH
26            ))),
27        });
28    }
29
30    if let Err(bytes) = deps_core::check_yaml_expansion(content, deps_core::MAX_YAML_EXPANDED_BYTES)
31    {
32        return Err(DepsError::ParseError {
33            file_type: "pubspec.yaml".into(),
34            source: Box::new(std::io::Error::other(format!(
35                "YAML expansion {bytes} bytes exceeds maximum of {} bytes",
36                deps_core::MAX_YAML_EXPANDED_BYTES
37            ))),
38        });
39    }
40
41    let line_table = LineOffsetTable::new(content);
42    let mut dependencies = Vec::new();
43    let mut sdk_constraint = None;
44
45    let docs = YamlLoader::load_from_str(content).map_err(|e| DepsError::ParseError {
46        file_type: "pubspec.yaml".into(),
47        source: Box::new(std::io::Error::other(e.to_string())),
48    })?;
49
50    let doc = match docs.first() {
51        Some(d) => d,
52        None => {
53            return Ok(DartParseResult {
54                dependencies,
55                sdk_constraint,
56                uri: doc_uri.clone(),
57            });
58        }
59    };
60
61    // Extract SDK constraint
62    if let Some(env) = doc["environment"]["sdk"].as_str() {
63        sdk_constraint = Some(env.to_string());
64    }
65
66    // Parse each dependency section
67    let sections = [
68        ("dependencies", DependencySection::Dependencies),
69        ("dev_dependencies", DependencySection::DevDependencies),
70        (
71            "dependency_overrides",
72            DependencySection::DependencyOverrides,
73        ),
74    ];
75
76    for (key, section) in &sections {
77        if let Yaml::Hash(map) = &doc[*key] {
78            for (name_yaml, value) in map {
79                if let Some(name) = name_yaml.as_str() {
80                    let (name_range, version_req, version_range, source, git_path) =
81                        parse_dependency_entry(name, value, content, &line_table);
82
83                    dependencies.push(DartDependency {
84                        name: name.into(),
85                        name_range,
86                        version_req: version_req.map(Into::into),
87                        version_range,
88                        section: section.clone(),
89                        source,
90                        git_path,
91                    });
92                }
93            }
94        }
95    }
96
97    Ok(DartParseResult {
98        dependencies,
99        sdk_constraint,
100        uri: doc_uri.clone(),
101    })
102}
103
104fn parse_dependency_entry(
105    name: &str,
106    value: &Yaml,
107    content: &str,
108    line_table: &LineOffsetTable,
109) -> (
110    Range,
111    Option<String>,
112    Option<Range>,
113    DependencySource,
114    Option<String>,
115) {
116    let name_range = find_key_range(name, content, line_table);
117
118    match value {
119        // Simple version string: "package: ^1.0.0"
120        Yaml::String(ver) => {
121            let version_range = find_value_range_after_key(name, ver, content, line_table);
122            (
123                name_range,
124                Some(ver.clone()),
125                version_range,
126                DependencySource::Registry,
127                None,
128            )
129        }
130        // Map form
131        Yaml::Hash(map) => {
132            let mut version_req = None;
133            let mut version_range = None;
134            let mut source = DependencySource::Registry;
135            let mut git_path = None;
136
137            if let Some(Yaml::String(ver)) = map.get(&Yaml::String("version".into())) {
138                version_req = Some(ver.clone());
139                version_range = find_value_range_after_key("version", ver, content, line_table);
140            }
141
142            if let Some(git_val) = map.get(&Yaml::String("git".into())) {
143                let (git_source, extracted_path) = parse_git_source(git_val);
144                source = git_source;
145                git_path = extracted_path;
146            } else if let Some(Yaml::String(path)) = map.get(&Yaml::String("path".into())) {
147                source = DependencySource::Path { path: path.clone() };
148            } else if let Some(Yaml::String(sdk)) = map.get(&Yaml::String("sdk".into())) {
149                source = DependencySource::Sdk { sdk: sdk.clone() };
150            }
151
152            (name_range, version_req, version_range, source, git_path)
153        }
154        _ => (name_range, None, None, DependencySource::Registry, None),
155    }
156}
157
158/// Returns `(DependencySource, git_subpath)`.
159fn parse_git_source(git_val: &Yaml) -> (DependencySource, Option<String>) {
160    match git_val {
161        Yaml::String(url) => (
162            DependencySource::Git {
163                url: url.clone(),
164                rev: None,
165            },
166            None,
167        ),
168        Yaml::Hash(map) => {
169            let url = map
170                .get(&Yaml::String("url".into()))
171                .and_then(Yaml::as_str)
172                .unwrap_or("")
173                .to_string();
174            let rev = map
175                .get(&Yaml::String("ref".into()))
176                .and_then(Yaml::as_str)
177                .map(String::from);
178            let path = map
179                .get(&Yaml::String("path".into()))
180                .and_then(Yaml::as_str)
181                .map(String::from);
182            (DependencySource::Git { url, rev }, path)
183        }
184        _ => (DependencySource::Registry, None),
185    }
186}
187
188fn find_key_range(key: &str, content: &str, line_table: &LineOffsetTable) -> Range {
189    // Search for "key:" pattern in YAML content
190    for (i, _) in content.match_indices(key) {
191        let after = i + key.len();
192        if after < content.len() {
193            let next_char = content.as_bytes()[after];
194            if next_char == b':' {
195                // Verify this is at the start of a line (after optional whitespace)
196                let line_start = content[..i].rfind('\n').map_or(0, |p| p + 1);
197                let prefix = &content[line_start..i];
198                if prefix.chars().all(|c| c == ' ') {
199                    let start = line_table.byte_offset_to_position(content, i);
200                    let end = line_table.byte_offset_to_position(content, after);
201                    return Range::new(start, end);
202                }
203            }
204        }
205    }
206    Range::default()
207}
208
209fn find_value_range_after_key(
210    key: &str,
211    value: &str,
212    content: &str,
213    line_table: &LineOffsetTable,
214) -> Option<Range> {
215    // Find "key: value" or "key: 'value'" patterns
216    let pattern = format!("{key}:");
217    for (i, _) in content.match_indices(&pattern) {
218        let after_colon = i + pattern.len();
219        let rest = &content[after_colon..];
220        if let Some(val_offset) = rest.find(value) {
221            let abs_start = after_colon + val_offset;
222            let abs_end = abs_start + value.len();
223            let start = line_table.byte_offset_to_position(content, abs_start);
224            let end = line_table.byte_offset_to_position(content, abs_end);
225            return Some(Range::new(start, end));
226        }
227    }
228    None
229}
230
231impl deps_core::ParseResult for DartParseResult {
232    fn dependencies(&self) -> Vec<&dyn deps_core::Dependency> {
233        self.dependencies
234            .iter()
235            .map(|d| d as &dyn deps_core::Dependency)
236            .collect()
237    }
238
239    fn workspace_root(&self) -> Option<&std::path::Path> {
240        None
241    }
242
243    fn uri(&self) -> &Uri {
244        &self.uri
245    }
246
247    fn as_any(&self) -> &dyn Any {
248        self
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    use std::assert_matches;
257
258    fn test_uri() -> Uri {
259        #[cfg(windows)]
260        let path = "C:/test/pubspec.yaml";
261        #[cfg(not(windows))]
262        let path = "/test/pubspec.yaml";
263        Uri::from_file_path(path).unwrap()
264    }
265
266    #[test]
267    fn test_parse_simple_deps() {
268        let yaml = r"
269name: my_app
270dependencies:
271  provider: ^6.0.0
272  http: ^1.0.0
273";
274        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
275        assert_eq!(result.dependencies.len(), 2);
276        assert_eq!(result.dependencies[0].name, "provider");
277        assert_eq!(result.dependencies[0].version_req, Some("^6.0.0".into()));
278        assert_eq!(result.dependencies[1].name, "http");
279    }
280
281    #[test]
282    fn test_parse_dev_dependencies() {
283        let yaml = r"
284name: my_app
285dev_dependencies:
286  flutter_test:
287    sdk: flutter
288  build_runner: ^2.4.0
289";
290        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
291        assert_eq!(result.dependencies.len(), 2);
292        assert_matches!(
293            result.dependencies[0].section,
294            DependencySection::DevDependencies
295        );
296        assert_matches!(result.dependencies[0].source, DependencySource::Sdk { .. });
297    }
298
299    #[test]
300    fn test_parse_git_dependency() {
301        let yaml = r"
302name: my_app
303dependencies:
304  my_pkg:
305    git:
306      url: https://github.com/user/repo.git
307      ref: main
308      path: packages/my_pkg
309";
310        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
311        assert_eq!(result.dependencies.len(), 1);
312        match &result.dependencies[0].source {
313            DependencySource::Git { url, rev } => {
314                assert_eq!(url, "https://github.com/user/repo.git");
315                assert_eq!(rev, &Some("main".into()));
316            }
317            _ => panic!("Expected Git source"),
318        }
319        assert_eq!(
320            result.dependencies[0].git_path,
321            Some("packages/my_pkg".into())
322        );
323    }
324
325    #[test]
326    fn test_parse_path_dependency() {
327        let yaml = r"
328name: my_app
329dependencies:
330  local_pkg:
331    path: ../local_pkg
332";
333        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
334        assert_matches!(result.dependencies[0].source, DependencySource::Path { .. });
335    }
336
337    #[test]
338    fn test_parse_sdk_constraint() {
339        let yaml = r"
340name: my_app
341environment:
342  sdk: '>=3.0.0 <4.0.0'
343dependencies:
344  http: ^1.0.0
345";
346        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
347        assert_eq!(result.sdk_constraint, Some(">=3.0.0 <4.0.0".into()));
348    }
349
350    #[test]
351    fn test_parse_empty_pubspec() {
352        let yaml = "name: empty_app\n";
353        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
354        assert!(result.dependencies.is_empty());
355        assert!(result.sdk_constraint.is_none());
356    }
357
358    #[test]
359    fn test_parse_dependency_overrides() {
360        let yaml = r"
361name: my_app
362dependency_overrides:
363  http: ^2.0.0
364";
365        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
366        assert_eq!(result.dependencies.len(), 1);
367        assert_matches!(
368            result.dependencies[0].section,
369            DependencySection::DependencyOverrides
370        );
371    }
372
373    #[test]
374    fn test_parse_hosted_with_version() {
375        let yaml = r"
376name: my_app
377dependencies:
378  custom_pkg:
379    hosted: https://custom-registry.example.com
380    version: ^1.0.0
381";
382        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
383        assert_eq!(result.dependencies.len(), 1);
384        assert_eq!(result.dependencies[0].version_req, Some("^1.0.0".into()));
385    }
386
387    #[test]
388    fn test_parse_git_shorthand() {
389        let yaml = r"
390name: my_app
391dependencies:
392  my_pkg:
393    git: https://github.com/user/repo.git
394";
395        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
396        match &result.dependencies[0].source {
397            DependencySource::Git { url, rev } => {
398                assert_eq!(url, "https://github.com/user/repo.git");
399                assert!(rev.is_none());
400            }
401            _ => panic!("Expected Git source"),
402        }
403        assert!(result.dependencies[0].git_path.is_none());
404    }
405
406    #[test]
407    fn test_position_tracking() {
408        let yaml = "name: my_app\ndependencies:\n  http: ^1.0.0\n";
409        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
410        let dep = &result.dependencies[0];
411        // Name should be on line 2 (0-indexed)
412        assert_eq!(dep.name_range.start.line, 2);
413    }
414
415    #[test]
416    fn test_parse_result_trait() {
417        use deps_core::ParseResult;
418        let yaml = "name: app\ndependencies:\n  http: ^1.0.0\n";
419        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
420        assert_eq!(result.dependencies().len(), 1);
421        assert!(result.workspace_root().is_none());
422        assert!(result.as_any().is::<DartParseResult>());
423    }
424
425    #[test]
426    fn test_line_offset_table() {
427        let content = "abc\ndef";
428        let table = LineOffsetTable::new(content);
429        let pos = table.byte_offset_to_position(content, 4);
430        assert_eq!(pos.line, 1);
431        assert_eq!(pos.character, 0);
432    }
433
434    #[test]
435    fn test_name_range_crosses_multibyte_accented_character() {
436        // "é" is a 2-byte UTF-8 character but a single UTF-16 code unit — the name
437        // range's end offset lands immediately after it, so position computation must
438        // count UTF-16 units (not bytes) and must not panic when slicing content up to
439        // that offset (deps-npm's test_line_offset_table_emoji, deps-composer's
440        // test_find_positions_no_panic_on_multibyte_utf8_boundary; #542 dedups
441        // deps-dart onto the same shared LineOffsetTable).
442        let yaml = "dependencies:\n  café: ^1.0.0\n";
443        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
444
445        let dep = result
446            .dependencies
447            .iter()
448            .find(|d| d.name.as_ref() == "café")
449            .expect("café dependency should be parsed");
450
451        assert_eq!(dep.name_range.start.line, 1);
452        assert_eq!(dep.name_range.start.character, 2); // "  " indent
453        assert_eq!(dep.name_range.end.character, 6); // indent + "café" (4 UTF-16 units)
454    }
455
456    #[test]
457    fn test_version_range_crosses_multibyte_emoji_in_same_line() {
458        // The emoji is a 4-byte UTF-8 character (2 UTF-16 code units) placed before the
459        // `version` key on the same line, so computing `version_range` must walk past it
460        // using UTF-16 counting rather than byte counting, and must not panic on the
461        // subsequent slice.
462        let yaml = "dependencies:\n  http: {description: \"🚀\", version: \"^1.0.0\"}\n";
463        let result = parse_pubspec_yaml(yaml, &test_uri()).unwrap();
464
465        let dep = result
466            .dependencies
467            .iter()
468            .find(|d| d.name.as_ref() == "http")
469            .expect("http dependency should be parsed");
470        let version_range = dep.version_range.expect("version range should be found");
471
472        let line = yaml.lines().nth(1).unwrap();
473        let quoted_version = "\"^1.0.0\"";
474        let byte_offset_in_line = line.find(quoted_version).unwrap() + 1; // skip opening quote
475        let expected_character: u32 = line[..byte_offset_in_line]
476            .chars()
477            .map(|c| c.len_utf16() as u32)
478            .sum();
479
480        assert_eq!(version_range.start.line, 1);
481        assert_eq!(version_range.start.character, expected_character);
482        assert_eq!(dep.version_req, Some("^1.0.0".into()));
483    }
484
485    #[test]
486    fn test_invalid_yaml() {
487        let yaml = "{{invalid yaml";
488        let result = parse_pubspec_yaml(yaml, &test_uri());
489        assert_matches!(
490            result,
491            Err(DepsError::ParseError { file_type, .. }) if file_type == "pubspec.yaml"
492        );
493    }
494
495    #[test]
496    fn test_deeply_nested_yaml_rejected_not_crashed() {
497        // 6000 comfortably exceeds the empirically bisected real
498        // `yaml-rust2` 0.12 crash threshold for this exact payload shape
499        // (compact dash chain: aborts at depth 4536 on a 2 MiB debug
500        // stack), so this is a genuine regression test for the pre-fix
501        // SIGABRT, not just proof the 64 limit fires.
502        let yaml = format!("{}1", "- ".repeat(6000));
503        let result = parse_pubspec_yaml(&yaml, &test_uri());
504        assert!(result.is_err());
505    }
506
507    #[test]
508    fn test_deeply_nested_yaml_with_apostrophe_rejected_not_crashed() {
509        // impl-critic C1: a `'`/`"` inside a plain scalar earlier in the
510        // file (e.g. in a description) must not blind the guard to real
511        // nesting later in the same file.
512        let yaml = format!(
513            "name: my_app\ndescription: A package that doesn't panic\ndependencies:\n  foo:\n{}1",
514            "- ".repeat(6000)
515        );
516        let result = parse_pubspec_yaml(&yaml, &test_uri());
517        assert!(result.is_err());
518    }
519
520    #[test]
521    fn test_anchor_alias_expansion_bomb_rejected_not_oomed() {
522        // #175: a shallow (depth-2) doubling chain of anchor/alias
523        // references, which `check_yaml_nesting_depth` cannot catch since
524        // nesting depth stays constant — must be rejected by the expansion
525        // budget instead of handed to `YamlLoader::load_from_str`, which
526        // would OOM/SIGKILL the process on this shape.
527        let mut yaml = String::from("name: app\na0: &a0 [x, x]\n");
528        for i in 1..=30 {
529            yaml.push_str(&format!("a{i}: &a{i} [*a{prev}, *a{prev}]\n", prev = i - 1));
530        }
531        let result = parse_pubspec_yaml(&yaml, &test_uri());
532        // Asserting on the message (not just `is_err()`) pins that this is
533        // rejected by the expansion guard specifically, so a future reorder
534        // that lets a different guard fire first would be caught.
535        match result {
536            Err(DepsError::ParseError { source, .. }) => {
537                let message = source.to_string();
538                assert!(
539                    message.contains("YAML expansion"),
540                    "unexpected error message: {message}"
541                );
542            }
543            other => panic!("expected DepsError::ParseError, got {other:?}"),
544        }
545    }
546
547    #[test]
548    fn test_asterisk_in_description_not_misread_as_alias() {
549        let yaml = "name: my_app\ndescription: A widget *multiplier* helper\ndependencies:\n  http: ^1.0.0\n";
550        let result = parse_pubspec_yaml(yaml, &test_uri());
551        assert!(result.is_ok());
552    }
553
554    #[test]
555    fn test_realistic_deeply_nested_pubspec_still_parses() {
556        // A legitimately deep (but realistic) pubspec.yaml must still parse
557        // successfully — the guard's margin must not produce false
558        // positives on real-world manifests.
559        let yaml = r"
560name: my_app
561dependencies:
562  http: ^1.0.0
563flutter:
564  fonts:
565    - family: Schyler
566      fonts:
567        - asset: fonts/Schyler-Regular.ttf
568        - asset: fonts/Schyler-Italic.ttf
569          style: italic
570";
571        let result = parse_pubspec_yaml(yaml, &test_uri());
572        assert!(result.is_ok());
573    }
574}