Skip to main content

deps_nuget/
parser.rs

1//! Manifest parsers for `.csproj`/`.fsproj`/`.vbproj`, `Directory.Packages.props`, and
2//! `packages.config`, with byte-accurate LSP position tracking.
3//!
4//! # Attribute byte spans
5//!
6//! NuGet carries its values in XML *attributes* (`Include="..."`, `Version="..."`), and
7//! `quick-xml`'s `Attribute` exposes no span API. This module uses the borrowed-slice
8//! offset instead of a text scan: `Reader::from_str(content)` + `reader.read_event()`
9//! (mirroring `deps-maven`'s reader setup exactly, `crates/deps-maven/src/parser.rs:51,62`)
10//! yields `Event<'a>` borrowed from `content` itself, so an attribute's raw `Cow<'a, str>`
11//! value is `Cow::Borrowed` pointing directly into `content`'s bytes. The byte offset is then
12//! simple pointer arithmetic:
13//!
14//! ```ignore
15//! let offset = value.as_ptr() as usize - content.as_ptr() as usize;
16//! ```
17//!
18//! This is O(1) and immune to the false-match a text scan would hit on an MSBuild
19//! `Condition` attribute whose *value* happens to contain the literal text `Version="`
20//! (see `test_condition_attribute_with_literal_version_text` below). Switching the reader
21//! to `Reader::from_reader` + `read_event_into(&mut buf)` would make attributes borrow from
22//! the scratch buffer instead of `content`, silently breaking this arithmetic — it would
23//! compile and produce garbage ranges. `test_attribute_byte_range_matches_source_bytes`
24//! guards against that regression.
25
26use 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
35/// Parses a `.csproj`/`.fsproj`/`.vbproj` MSBuild project file, extracting `PackageReference` entries.
36///
37/// Supports both the attribute form (`Version="1.0"`) and the child-element metadata form
38/// (`<Version>1.0</Version>`). Central package management entries (no `Version` attribute
39/// or child) are emitted with `version_requirement: None` so hover/completion on the name
40/// still works.
41pub fn parse_project_file(content: &str, doc_uri: &Uri) -> Result<NuGetParseResult> {
42    parse_reference_elements(content, doc_uri, "PackageReference")
43}
44
45/// Parses a `Directory.Packages.props` central package management file, extracting
46/// `PackageVersion` entries.
47pub fn parse_directory_packages_props(content: &str, doc_uri: &Uri) -> Result<NuGetParseResult> {
48    parse_reference_elements(content, doc_uri, "PackageVersion")
49}
50
51/// Parses a legacy `packages.config` file, extracting `package` entries.
52///
53/// `packages.config` `version="..."` semantics are an **exact pin**, unlike the floor
54/// semantics of a bare `PackageReference` `Version="..."`. That difference is normalized at
55/// parse time into a bracketed exact range (`"1.0.0"` → `"[1.0.0]"`) so the existing interval
56/// parser (`crate::version::satisfies`) handles it with no new formatter state.
57pub 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    // An empty `version=""` attribute must degrade to "no requirement" like a `$(...)`
115    // MSBuild property reference does — left unguarded, it gets wrapped into the
116    // syntactically valid but bogus exact pin `"[]"` (parses as version `0.0.0.0`), which
117    // silently disguises "no version" as a real, always-unsatisfiable requirement instead
118    // of being skipped by the empty-`VersionReq` guards downstream.
119    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/// Accumulator for a single `PackageReference`/`PackageVersion` entry being parsed.
137#[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
248/// Unresolvable MSBuild property expressions (`Version="$(SerilogVersion)"`), central
249/// package management entries (no `Version` at all), and an empty/whitespace-only
250/// `Version=""` attribute all degrade to `version_requirement: None` rather than a bogus
251/// or unresolved-looking requirement (spec §3, deferred scope) — matching the
252/// `packages.config` path's `version=""` guard in `parse_package_element` above.
253fn 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
268/// Computes the byte offset range of an attribute's raw value slice within `content`.
269///
270/// Sound only when `raw` borrows directly from `content` (`Cow::Borrowed`), which holds for
271/// `Reader::from_str` + `read_event()` per the module docs above. `checked_sub` guards
272/// against that invariant being violated by a future reader-setup regression: release
273/// builds have no overflow checks, so an unchecked subtraction would wrap silently into a
274/// garbage offset instead of failing loudly — falling back to an empty `(0, 0)` range is
275/// safe (worst case, a wrong/empty LSP range) where a wrapped `usize` is not.
276fn 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        // Whitespace-padded child-element form: `<Version>\n  3.1.1\n</Version>`.
345        // decode_text() trims the extracted value regardless of surrounding whitespace
346        // (matches deps-maven's identical text-node handling).
347        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        // A `Condition` attribute value containing the literal text `Version="` would
440        // false-match a naive text scan. The attribute-key-based parser must not be fooled.
441        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        // Guards the reader-setup constraint: attribute values must borrow directly from
454        // `content` (Reader::from_str + read_event()), not a scratch buffer, or this
455        // pointer-arithmetic offset silently produces garbage ranges.
456        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        // Deterministically construct `raw` at a lower memory address than `content` by
480        // slicing the same backing buffer in reverse order — this violates the "raw
481        // borrows from content" invariant and must return the safe (0, 0) fallback instead
482        // of wrapping.
483        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    /// M1: a whitespace-only `version` attribute must degrade to `None` the same as a
535    /// truly empty one — otherwise it produces the bogus `"[   ]"` exact pin.
536    #[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    /// M11: a non-empty `version` attribute with leading/trailing whitespace must have
549    /// that whitespace trimmed before being wrapped into the exact-pin syntax, or the
550    /// stray spaces end up baked into `"[ 1.0.0 ]"` instead of `"[1.0.0]"`.
551    #[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    /// M2: `Version=""` on a `PackageReference` must degrade to `None`, aligning with the
587    /// `packages.config` `version=""` guard instead of yielding `Some("")`.
588    #[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    /// M1/M2 sibling: whitespace-only `Version` must also degrade to `None`.
599    #[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    /// M11 sibling: same whitespace trimming for the `PackageReference`/central-package
609    /// resolution path (`resolve_version_field`).
610    #[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}