code_moniker_core/lang/cs/
build.rs1#[derive(Clone, Debug, Eq, PartialEq)]
2pub struct Dep {
3 pub name: String,
4 pub version: Option<String>,
5 pub dep_kind: String,
6 pub import_root: String,
7}
8
9#[derive(Debug)]
10pub enum CsprojError {
11 Parse(roxmltree::Error),
12}
13
14impl std::fmt::Display for CsprojError {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 match self {
17 Self::Parse(e) => write!(f, ".csproj parse error: {e}"),
18 }
19 }
20}
21
22impl std::error::Error for CsprojError {}
23
24pub fn parse(content: &str) -> Result<Vec<Dep>, CsprojError> {
25 let doc = roxmltree::Document::parse(content).map_err(CsprojError::Parse)?;
26 let root = doc.root_element();
27 let mut out = Vec::new();
28
29 if let Some(name) = project_self_name(root) {
30 let version = property_value(root, "Version");
31 out.push(Dep {
32 name: name.clone(),
33 version,
34 dep_kind: "package".into(),
35 import_root: name,
36 });
37 }
38
39 for node in root.descendants() {
40 match node.tag_name().name() {
41 "PackageReference" => {
42 let Some(name) = node.attribute("Include") else {
43 continue;
44 };
45 let version = node
46 .attribute("Version")
47 .map(str::to_string)
48 .or_else(|| element_text(node, "Version"));
49 out.push(Dep {
50 name: name.into(),
51 version,
52 dep_kind: "normal".into(),
53 import_root: name.into(),
54 });
55 }
56 "ProjectReference" => {
57 let Some(path) = node.attribute("Include") else {
58 continue;
59 };
60 let stem = project_path_stem(path);
61 out.push(Dep {
62 name: stem.clone(),
63 version: None,
64 dep_kind: "project".into(),
65 import_root: stem,
66 });
67 }
68 _ => {}
69 }
70 }
71
72 Ok(out)
73}
74
75fn project_self_name(root: roxmltree::Node<'_, '_>) -> Option<String> {
76 let mut fallback: Option<String> = None;
77 for n in root.descendants() {
78 if !n.is_element() {
79 continue;
80 }
81 match n.tag_name().name() {
82 "AssemblyName" => {
83 if let Some(s) = node_trimmed_text(n) {
84 return Some(s);
85 }
86 }
87 "RootNamespace" if fallback.is_none() => {
88 fallback = node_trimmed_text(n);
89 }
90 _ => {}
91 }
92 }
93 fallback
94}
95
96fn property_value(root: roxmltree::Node<'_, '_>, tag: &str) -> Option<String> {
97 root.descendants()
98 .find(|n| n.is_element() && n.tag_name().name() == tag)
99 .and_then(node_trimmed_text)
100}
101
102fn node_trimmed_text(n: roxmltree::Node<'_, '_>) -> Option<String> {
103 n.text()
104 .map(|s| s.trim().to_string())
105 .filter(|s| !s.is_empty())
106}
107
108fn element_text(node: roxmltree::Node<'_, '_>, tag: &str) -> Option<String> {
109 node.children()
110 .find(|n| n.is_element() && n.tag_name().name() == tag)
111 .and_then(|n| n.text())
112 .map(|s| s.trim().to_string())
113 .filter(|s| !s.is_empty())
114}
115
116fn project_path_stem(path: &str) -> String {
117 let leaf = path.rsplit(['/', '\\']).next().unwrap_or(path);
118 leaf.strip_suffix(".csproj").unwrap_or(leaf).to_string()
119}
120
121pub fn package_moniker(project: &[u8], import_root: &str) -> crate::core::moniker::Moniker {
122 let mut b = crate::core::moniker::MonikerBuilder::new();
123 b.project(project);
124 let mut pieces = import_root.split('.').filter(|s| !s.is_empty());
125 if let Some(head) = pieces.next() {
126 b.segment(crate::lang::kinds::EXTERNAL_PKG, head.as_bytes());
127 for piece in pieces {
128 b.segment(crate::lang::kinds::PATH, piece.as_bytes());
129 }
130 }
131 b.build()
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn parse_empty_project_returns_empty_vec() {
140 let xml = r#"<Project Sdk="Microsoft.NET.Sdk"></Project>"#;
141 assert!(parse(xml).unwrap().is_empty());
142 }
143
144 #[test]
145 fn parse_self_name_from_assembly_name() {
146 let xml = r#"<Project Sdk="Microsoft.NET.Sdk">
147 <PropertyGroup>
148 <AssemblyName>MyApp</AssemblyName>
149 <Version>1.2.3</Version>
150 </PropertyGroup>
151 </Project>"#;
152 let deps = parse(xml).unwrap();
153 let pkg = deps.iter().find(|d| d.dep_kind == "package").unwrap();
154 assert_eq!(pkg.name, "MyApp");
155 assert_eq!(pkg.version.as_deref(), Some("1.2.3"));
156 assert_eq!(pkg.import_root, "MyApp");
157 }
158
159 #[test]
160 fn parse_falls_back_to_root_namespace() {
161 let xml = r#"<Project>
162 <PropertyGroup>
163 <RootNamespace>Acme</RootNamespace>
164 </PropertyGroup>
165 </Project>"#;
166 let deps = parse(xml).unwrap();
167 assert!(
168 deps.iter()
169 .any(|d| d.dep_kind == "package" && d.name == "Acme")
170 );
171 }
172
173 #[test]
174 fn parse_package_reference_attribute_version() {
175 let xml = r#"<Project>
176 <ItemGroup>
177 <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
178 </ItemGroup>
179 </Project>"#;
180 let deps = parse(xml).unwrap();
181 let pkg = deps.iter().find(|d| d.name == "Newtonsoft.Json").unwrap();
182 assert_eq!(pkg.version.as_deref(), Some("13.0.1"));
183 assert_eq!(pkg.dep_kind, "normal");
184 assert_eq!(pkg.import_root, "Newtonsoft.Json");
185 }
186
187 #[test]
188 fn parse_package_reference_element_version() {
189 let xml = r#"<Project>
190 <ItemGroup>
191 <PackageReference Include="Serilog">
192 <Version>3.0.0</Version>
193 </PackageReference>
194 </ItemGroup>
195 </Project>"#;
196 let deps = parse(xml).unwrap();
197 let pkg = deps.iter().find(|d| d.name == "Serilog").unwrap();
198 assert_eq!(pkg.version.as_deref(), Some("3.0.0"));
199 }
200
201 #[test]
202 fn parse_project_reference_strips_path_and_extension() {
203 let xml = r#"<Project>
204 <ItemGroup>
205 <ProjectReference Include="..\Other\Other.csproj" />
206 </ItemGroup>
207 </Project>"#;
208 let deps = parse(xml).unwrap();
209 let pr = deps.iter().find(|d| d.dep_kind == "project").unwrap();
210 assert_eq!(pr.name, "Other");
211 assert!(pr.version.is_none());
212 }
213
214 #[test]
215 fn parse_project_reference_handles_unix_paths() {
216 let xml = r#"<Project>
217 <ItemGroup>
218 <ProjectReference Include="../Other/Other.csproj" />
219 </ItemGroup>
220 </Project>"#;
221 let deps = parse(xml).unwrap();
222 assert!(
223 deps.iter()
224 .any(|d| d.name == "Other" && d.dep_kind == "project")
225 );
226 }
227
228 #[test]
229 fn parse_invalid_xml_returns_parse_error() {
230 assert!(matches!(parse("<not closed"), Err(CsprojError::Parse(_))));
231 }
232}