1use 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 if let Some(env) = doc["environment"]["sdk"].as_str() {
63 sdk_constraint = Some(env.to_string());
64 }
65
66 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 §ions {
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 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 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
158fn 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 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 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 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 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 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); assert_eq!(dep.name_range.end.character, 6); }
455
456 #[test]
457 fn test_version_range_crosses_multibyte_emoji_in_same_line() {
458 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; 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 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 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 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 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 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}