Skip to main content

code_moniker_core/lang/cs/
build.rs

1#[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}