1use crate::types::NuGetDependency;
27use deps_core::lsp_helpers::LineOffsetTable;
28use deps_core::{DepsError, Result};
29use quick_xml::Reader;
30use quick_xml::events::{BytesStart, BytesText, Event};
31use tower_lsp_server::ls_types::{Range, Uri};
32
33use crate::types::NuGetParseResult;
34
35pub fn parse_project_file(content: &str, doc_uri: &Uri) -> Result<NuGetParseResult> {
42 parse_reference_elements(content, doc_uri, "PackageReference")
43}
44
45pub fn parse_directory_packages_props(content: &str, doc_uri: &Uri) -> Result<NuGetParseResult> {
48 parse_reference_elements(content, doc_uri, "PackageVersion")
49}
50
51pub fn parse_packages_config(content: &str, doc_uri: &Uri) -> Result<NuGetParseResult> {
58 let line_table = LineOffsetTable::new(content);
59 let mut reader = Reader::from_str(content);
60 reader.config_mut().trim_text(true);
61
62 let mut dependencies = Vec::new();
63
64 loop {
65 let event = reader.read_event().map_err(|e| DepsError::ParseError {
66 file_type: "NuGet project file".into(),
67 source: Box::new(std::io::Error::other(e.to_string())),
68 })?;
69
70 match event {
71 Event::Empty(ref e) | Event::Start(ref e) if e.local_name().as_ref() == "package" => {
72 if let Some(dep) = parse_package_element(content, &line_table, e) {
73 dependencies.push(dep);
74 }
75 }
76 Event::Eof => break,
77 _ => {}
78 }
79 }
80
81 Ok(NuGetParseResult {
82 dependencies,
83 uri: doc_uri.clone(),
84 resolved_chains: Vec::new(),
85 })
86}
87
88fn parse_package_element(
89 content: &str,
90 line_table: &LineOffsetTable,
91 e: &BytesStart<'_>,
92) -> Option<NuGetDependency> {
93 let mut name = None;
94 let mut name_span = (0usize, 0usize);
95 let mut version = None;
96 let mut version_span = (0usize, 0usize);
97
98 for attr in e.attributes().flatten() {
99 match attr.key.local_name().as_ref() {
100 "id" => {
101 name_span = attribute_byte_range(content, &attr.value);
102 name = Some(decode_attr_value(&attr.value));
103 }
104 "version" => {
105 version_span = attribute_byte_range(content, &attr.value);
106 version = Some(decode_attr_value(&attr.value));
107 }
108 _ => {}
109 }
110 }
111
112 let name = name?;
113 let name_range = span_to_range(content, line_table, name_span);
114 let (version_requirement, version_range) = match version {
120 Some(v) if !v.trim().is_empty() && !v.contains("$(") => (
121 Some(format!("[{}]", v.trim())),
122 Some(span_to_range(content, line_table, version_span)),
123 ),
124 _ => (None, None),
125 };
126
127 Some(NuGetDependency {
128 name: name.into(),
129 name_range,
130 version_requirement: version_requirement.map(Into::into),
131 version_range,
132 source: deps_core::parser::DependencySource::Registry,
133 })
134}
135
136#[derive(Default)]
138struct DepAccum {
139 name: Option<String>,
140 name_span: (usize, usize),
141 version: Option<String>,
142 version_span: (usize, usize),
143}
144
145fn parse_reference_elements(
146 content: &str,
147 doc_uri: &Uri,
148 tag_name: &str,
149) -> Result<NuGetParseResult> {
150 let line_table = LineOffsetTable::new(content);
151 let mut reader = Reader::from_str(content);
152 reader.config_mut().trim_text(true);
153
154 let mut dependencies = Vec::new();
155 let mut current: Option<DepAccum> = None;
156 let mut in_version_child = false;
157
158 loop {
159 let text_pos = reader.buffer_position();
160 let event = reader.read_event().map_err(|e| DepsError::ParseError {
161 file_type: "NuGet project file".into(),
162 source: Box::new(std::io::Error::other(e.to_string())),
163 })?;
164
165 match event {
166 Event::Empty(ref e) => {
167 if e.local_name().as_ref() == tag_name
168 && let Some(dep) =
169 finalize_dep(content, &line_table, accum_from_attrs(content, e))
170 {
171 dependencies.push(dep);
172 }
173 }
174 Event::Start(ref e) => {
175 if e.local_name().as_ref() == tag_name {
176 current = Some(accum_from_attrs(content, e));
177 } else if current.is_some() && e.local_name().as_ref() == "Version" {
178 in_version_child = true;
179 }
180 }
181 Event::Text(ref e) if in_version_child => {
182 if let Some(accum) = current.as_mut() {
183 let text_end = reader.buffer_position();
184 accum.version = Some(decode_text(e));
185 accum.version_span = (text_pos as usize, text_end as usize);
186 }
187 }
188 Event::End(ref e) => {
189 let local = e.local_name();
190 if local.as_ref() == "Version" && in_version_child {
191 in_version_child = false;
192 } else if local.as_ref() == tag_name
193 && let Some(accum) = current.take()
194 && let Some(dep) = finalize_dep(content, &line_table, accum)
195 {
196 dependencies.push(dep);
197 }
198 }
199 Event::Eof => break,
200 _ => {}
201 }
202 }
203
204 Ok(NuGetParseResult {
205 dependencies,
206 uri: doc_uri.clone(),
207 resolved_chains: Vec::new(),
208 })
209}
210
211fn accum_from_attrs(content: &str, e: &BytesStart<'_>) -> DepAccum {
212 let mut accum = DepAccum::default();
213 for attr in e.attributes().flatten() {
214 match attr.key.local_name().as_ref() {
215 "Include" => {
216 accum.name_span = attribute_byte_range(content, &attr.value);
217 accum.name = Some(decode_attr_value(&attr.value));
218 }
219 "Version" => {
220 accum.version_span = attribute_byte_range(content, &attr.value);
221 accum.version = Some(decode_attr_value(&attr.value));
222 }
223 _ => {}
224 }
225 }
226 accum
227}
228
229fn finalize_dep(
230 content: &str,
231 line_table: &LineOffsetTable,
232 accum: DepAccum,
233) -> Option<NuGetDependency> {
234 let name = accum.name?;
235 let name_range = span_to_range(content, line_table, accum.name_span);
236 let (version_requirement, version_range) =
237 resolve_version_field(content, line_table, accum.version, accum.version_span);
238
239 Some(NuGetDependency {
240 name: name.into(),
241 name_range,
242 version_requirement: version_requirement.map(Into::into),
243 version_range,
244 source: deps_core::parser::DependencySource::Registry,
245 })
246}
247
248fn resolve_version_field(
254 content: &str,
255 line_table: &LineOffsetTable,
256 version: Option<String>,
257 span: (usize, usize),
258) -> (Option<String>, Option<Range>) {
259 match version {
260 Some(ref v) if !v.trim().is_empty() && !v.contains("$(") => (
261 Some(v.trim().to_string()),
262 Some(span_to_range(content, line_table, span)),
263 ),
264 _ => (None, None),
265 }
266}
267
268fn attribute_byte_range(content: &str, raw: &str) -> (usize, usize) {
277 let Some(offset) = (raw.as_ptr() as usize).checked_sub(content.as_ptr() as usize) else {
278 return (0, 0);
279 };
280 (offset, offset + raw.len())
281}
282
283fn span_to_range(content: &str, line_table: &LineOffsetTable, span: (usize, usize)) -> Range {
284 Range::new(
285 line_table.byte_offset_to_position(content, span.0),
286 line_table.byte_offset_to_position(content, span.1),
287 )
288}
289
290fn decode_attr_value(raw: &str) -> String {
291 quick_xml::escape::unescape(raw)
292 .map(|c| c.into_owned())
293 .unwrap_or_else(|_| raw.to_string())
294}
295
296fn decode_text(e: &BytesText<'_>) -> String {
297 let s = e.trim().to_string();
298 quick_xml::escape::unescape(&s)
299 .map(|c| c.into_owned())
300 .unwrap_or(s)
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 use std::assert_matches;
308
309 fn test_uri() -> Uri {
310 deps_core::test_util::test_uri("/test/App.csproj")
311 }
312
313 #[test]
314 fn test_parse_attribute_form() {
315 let xml = r#"<Project>
316 <ItemGroup>
317 <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
318 </ItemGroup>
319</Project>"#;
320 let result = parse_project_file(xml, &test_uri()).unwrap();
321 assert_eq!(result.dependencies.len(), 1);
322 let dep = &result.dependencies[0];
323 assert_eq!(dep.name, "Newtonsoft.Json");
324 assert_eq!(dep.version_requirement, Some("13.0.3".into()));
325 assert!(dep.version_range.is_some());
326 }
327
328 #[test]
329 fn test_parse_child_element_form() {
330 let xml = r#"<Project>
331 <ItemGroup>
332 <PackageReference Include="Serilog"><Version>3.1.1</Version></PackageReference>
333 </ItemGroup>
334</Project>"#;
335 let result = parse_project_file(xml, &test_uri()).unwrap();
336 assert_eq!(result.dependencies.len(), 1);
337 let dep = &result.dependencies[0];
338 assert_eq!(dep.name, "Serilog");
339 assert_eq!(dep.version_requirement, Some("3.1.1".into()));
340 }
341
342 #[test]
343 fn test_parse_child_element_form_multiline_whitespace() {
344 let xml = "<Project><ItemGroup><PackageReference Include=\"Serilog\"><Version>\n 3.1.1\n </Version></PackageReference></ItemGroup></Project>";
348 let result = parse_project_file(xml, &test_uri()).unwrap();
349 assert_eq!(result.dependencies.len(), 1);
350 assert_eq!(
351 result.dependencies[0].version_requirement,
352 Some("3.1.1".into())
353 );
354 }
355
356 #[test]
357 fn test_parse_central_package_management_no_version() {
358 let xml = r#"<Project>
359 <ItemGroup>
360 <PackageReference Include="Serilog" />
361 </ItemGroup>
362</Project>"#;
363 let result = parse_project_file(xml, &test_uri()).unwrap();
364 assert_eq!(result.dependencies.len(), 1);
365 assert_eq!(result.dependencies[0].name, "Serilog");
366 assert!(result.dependencies[0].version_requirement.is_none());
367 assert!(result.dependencies[0].version_range.is_none());
368 }
369
370 #[test]
371 fn test_parse_multiple_references() {
372 let xml = r#"<Project>
373 <ItemGroup>
374 <PackageReference Include="A" Version="1.0.0" />
375 <PackageReference Include="B" Version="2.0.0" />
376 </ItemGroup>
377</Project>"#;
378 let result = parse_project_file(xml, &test_uri()).unwrap();
379 assert_eq!(result.dependencies.len(), 2);
380 assert_eq!(result.dependencies[0].name, "A");
381 assert_eq!(result.dependencies[1].name, "B");
382 }
383
384 #[test]
385 fn test_attribute_order_version_before_include() {
386 let xml = r#"<Project><ItemGroup><PackageReference Version="1.2.3" Include="Foo" /></ItemGroup></Project>"#;
387 let result = parse_project_file(xml, &test_uri()).unwrap();
388 assert_eq!(result.dependencies.len(), 1);
389 assert_eq!(result.dependencies[0].name, "Foo");
390 assert_eq!(
391 result.dependencies[0].version_requirement,
392 Some("1.2.3".into())
393 );
394 }
395
396 #[test]
397 fn test_single_and_double_quotes() {
398 let xml = r"<Project><ItemGroup><PackageReference Include='Foo' Version='1.0.0' /></ItemGroup></Project>";
399 let result = parse_project_file(xml, &test_uri()).unwrap();
400 assert_eq!(result.dependencies.len(), 1);
401 assert_eq!(result.dependencies[0].name, "Foo");
402 assert_eq!(
403 result.dependencies[0].version_requirement,
404 Some("1.0.0".into())
405 );
406 }
407
408 #[test]
409 fn test_whitespace_around_equals() {
410 let xml = r#"<Project><ItemGroup><PackageReference Include = "Foo" Version = "1.0.0" /></ItemGroup></Project>"#;
411 let result = parse_project_file(xml, &test_uri()).unwrap();
412 assert_eq!(result.dependencies.len(), 1);
413 assert_eq!(result.dependencies[0].name, "Foo");
414 assert_eq!(
415 result.dependencies[0].version_requirement,
416 Some("1.0.0".into())
417 );
418 }
419
420 #[test]
421 fn test_self_closing_vs_paired_tags() {
422 let self_closing = r#"<Project><ItemGroup><PackageReference Include="Foo" Version="1.0.0" /></ItemGroup></Project>"#;
423 let paired = r#"<Project><ItemGroup><PackageReference Include="Foo" Version="1.0.0"></PackageReference></ItemGroup></Project>"#;
424
425 let r1 = parse_project_file(self_closing, &test_uri()).unwrap();
426 let r2 = parse_project_file(paired, &test_uri()).unwrap();
427
428 assert_eq!(r1.dependencies.len(), 1);
429 assert_eq!(r2.dependencies.len(), 1);
430 assert_eq!(r1.dependencies[0].name, r2.dependencies[0].name);
431 assert_eq!(
432 r1.dependencies[0].version_requirement,
433 r2.dependencies[0].version_requirement
434 );
435 }
436
437 #[test]
438 fn test_condition_attribute_with_literal_version_text() {
439 let xml = r#"<Project><ItemGroup><PackageReference Include="Foo" Condition="'$(Version)' == 'Version="9.9.9"'" Version="1.0.0" /></ItemGroup></Project>"#;
442 let result = parse_project_file(xml, &test_uri()).unwrap();
443 assert_eq!(result.dependencies.len(), 1);
444 assert_eq!(result.dependencies[0].name, "Foo");
445 assert_eq!(
446 result.dependencies[0].version_requirement,
447 Some("1.0.0".into())
448 );
449 }
450
451 #[test]
452 fn test_attribute_byte_range_matches_source_bytes() {
453 let xml = r#"<PackageReference Include="Foo" Version="1.2.3" />"#;
457 let mut reader = Reader::from_str(xml);
458 let event = reader.read_event().unwrap();
459 let Event::Empty(e) = event else {
460 panic!("expected Empty event");
461 };
462 let mut checked = false;
463 for attr in e.attributes().flatten() {
464 if attr.key.local_name().as_ref() == "Version" {
465 let (start, end) = attribute_byte_range(xml, &attr.value);
466 assert_eq!(&xml[start..end], attr.value.as_ref());
467 assert_eq!(&xml[start..end], "1.2.3");
468 checked = true;
469 }
470 }
471 assert!(
472 checked,
473 "Version attribute was never found — guarded assertions did not run"
474 );
475 }
476
477 #[test]
478 fn test_attribute_byte_range_checked_sub_fallback_on_invariant_violation() {
479 let buffer = "0123456789";
484 let content = &buffer[5..];
485 let raw = &buffer[0..3];
486 assert_eq!(attribute_byte_range(content, raw), (0, 0));
487 }
488
489 #[test]
490 fn test_directory_packages_props() {
491 let xml = r#"<Project>
492 <ItemGroup>
493 <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
494 </ItemGroup>
495</Project>"#;
496 let uri = deps_core::test_util::test_uri("/test/Directory.Packages.props");
497 let result = parse_directory_packages_props(xml, &uri).unwrap();
498 assert_eq!(result.dependencies.len(), 1);
499 assert_eq!(result.dependencies[0].name, "Newtonsoft.Json");
500 assert_eq!(
501 result.dependencies[0].version_requirement,
502 Some("13.0.3".into())
503 );
504 }
505
506 #[test]
507 fn test_packages_config_normalizes_exact_pin() {
508 let xml = r#"<packages>
509 <package id="Newtonsoft.Json" version="13.0.3" targetFramework="net48" />
510</packages>"#;
511 let uri = deps_core::test_util::test_uri("/test/packages.config");
512 let result = parse_packages_config(xml, &uri).unwrap();
513 assert_eq!(result.dependencies.len(), 1);
514 assert_eq!(result.dependencies[0].name, "Newtonsoft.Json");
515 assert_eq!(
516 result.dependencies[0].version_requirement,
517 Some("[13.0.3]".into())
518 );
519 }
520
521 #[test]
522 fn test_packages_config_empty_version_degrades_to_none() {
523 let xml = r#"<packages>
524 <package id="Foo" version="" targetFramework="net48" />
525</packages>"#;
526 let uri = deps_core::test_util::test_uri("/test/packages.config");
527 let result = parse_packages_config(xml, &uri).unwrap();
528 assert_eq!(result.dependencies.len(), 1);
529 assert_eq!(result.dependencies[0].name, "Foo");
530 assert_eq!(result.dependencies[0].version_requirement, None);
531 assert_eq!(result.dependencies[0].version_range, None);
532 }
533
534 #[test]
537 fn test_packages_config_whitespace_only_version_degrades_to_none() {
538 let xml = r#"<packages>
539 <package id="Foo" version=" " targetFramework="net48" />
540</packages>"#;
541 let uri = deps_core::test_util::test_uri("/test/packages.config");
542 let result = parse_packages_config(xml, &uri).unwrap();
543 assert_eq!(result.dependencies.len(), 1);
544 assert_eq!(result.dependencies[0].version_requirement, None);
545 assert_eq!(result.dependencies[0].version_range, None);
546 }
547
548 #[test]
552 fn test_packages_config_version_with_surrounding_whitespace_is_trimmed() {
553 let xml = r#"<packages>
554 <package id="Foo" version=" 1.0.0 " targetFramework="net48" />
555</packages>"#;
556 let uri = deps_core::test_util::test_uri("/test/packages.config");
557 let result = parse_packages_config(xml, &uri).unwrap();
558 assert_eq!(result.dependencies.len(), 1);
559 assert_eq!(
560 result.dependencies[0].version_requirement,
561 Some("[1.0.0]".into())
562 );
563 }
564
565 #[test]
566 fn test_packages_config_multiple_packages() {
567 let xml = r#"<packages>
568 <package id="A" version="1.0.0" targetFramework="net48" />
569 <package id="B" version="2.0.0" targetFramework="net48" />
570</packages>"#;
571 let uri = deps_core::test_util::test_uri("/test/packages.config");
572 let result = parse_packages_config(xml, &uri).unwrap();
573 assert_eq!(result.dependencies.len(), 2);
574 }
575
576 #[test]
577 fn test_unresolved_msbuild_property_degrades_to_none() {
578 let xml = r#"<Project><ItemGroup><PackageReference Include="Serilog" Version="$(SerilogVersion)" /></ItemGroup></Project>"#;
579 let result = parse_project_file(xml, &test_uri()).unwrap();
580 assert_eq!(result.dependencies.len(), 1);
581 assert_eq!(result.dependencies[0].name, "Serilog");
582 assert!(result.dependencies[0].version_requirement.is_none());
583 assert!(result.dependencies[0].version_range.is_none());
584 }
585
586 #[test]
589 fn test_package_reference_empty_version_degrades_to_none() {
590 let xml = r#"<Project><ItemGroup><PackageReference Include="Foo" Version="" /></ItemGroup></Project>"#;
591 let result = parse_project_file(xml, &test_uri()).unwrap();
592 assert_eq!(result.dependencies.len(), 1);
593 assert_eq!(result.dependencies[0].name, "Foo");
594 assert!(result.dependencies[0].version_requirement.is_none());
595 assert!(result.dependencies[0].version_range.is_none());
596 }
597
598 #[test]
600 fn test_package_reference_whitespace_only_version_degrades_to_none() {
601 let xml = r#"<Project><ItemGroup><PackageReference Include="Foo" Version=" " /></ItemGroup></Project>"#;
602 let result = parse_project_file(xml, &test_uri()).unwrap();
603 assert_eq!(result.dependencies.len(), 1);
604 assert!(result.dependencies[0].version_requirement.is_none());
605 assert!(result.dependencies[0].version_range.is_none());
606 }
607
608 #[test]
611 fn test_package_reference_version_with_surrounding_whitespace_is_trimmed() {
612 let xml = r#"<Project><ItemGroup><PackageReference Include="Foo" Version=" 1.0.0 " /></ItemGroup></Project>"#;
613 let result = parse_project_file(xml, &test_uri()).unwrap();
614 assert_eq!(result.dependencies.len(), 1);
615 assert_eq!(
616 result.dependencies[0].version_requirement,
617 Some("1.0.0".into())
618 );
619 }
620
621 #[test]
622 fn test_empty_project() {
623 let xml = "<Project></Project>";
624 let result = parse_project_file(xml, &test_uri()).unwrap();
625 assert!(result.dependencies.is_empty());
626 }
627
628 #[test]
629 fn test_invalid_xml_errors() {
630 let xml = r#"<Project attr="unclosed></Project>"#;
631 let result = parse_project_file(xml, &test_uri());
632 assert_matches!(
633 result,
634 Err(DepsError::ParseError { file_type, .. }) if file_type == "NuGet project file"
635 );
636 }
637
638 #[test]
639 fn test_packages_config_invalid_xml_errors() {
640 let xml = r#"<packages attr="unclosed></packages>"#;
641 let result = parse_packages_config(xml, &test_uri());
642 assert_matches!(
643 result,
644 Err(DepsError::ParseError { file_type, .. }) if file_type == "NuGet project file"
645 );
646 }
647
648 #[test]
649 fn test_parse_result_trait() {
650 use deps_core::ParseResult;
651
652 let xml = r#"<Project><ItemGroup><PackageReference Include="Foo" Version="1.0.0" /></ItemGroup></Project>"#;
653 let result = parse_project_file(xml, &test_uri()).unwrap();
654 assert_eq!(result.dependencies().len(), 1);
655 assert!(result.workspace_root().is_none());
656 assert!(result.as_any().is::<NuGetParseResult>());
657 }
658
659 #[test]
660 fn test_position_tracking() {
661 let xml = "<Project>\n <ItemGroup>\n <PackageReference Include=\"Foo\" Version=\"1.0.0\" />\n </ItemGroup>\n</Project>";
662 let result = parse_project_file(xml, &test_uri()).unwrap();
663 let dep = &result.dependencies[0];
664 assert_eq!(dep.name_range.start.line, 2);
665 }
666}