code-moniker-core 0.2.0

Core symbol-graph types and per-language extractors for code-moniker (pure Rust, no pgrx). Consumed by the CLI and the PostgreSQL extension.
Documentation
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Dep {
	pub name: String,
	pub version: Option<String>,
	pub dep_kind: String,
	pub import_root: String,
}

#[derive(Debug)]
pub enum CsprojError {
	Parse(roxmltree::Error),
}

impl std::fmt::Display for CsprojError {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		match self {
			Self::Parse(e) => write!(f, ".csproj parse error: {e}"),
		}
	}
}

impl std::error::Error for CsprojError {}

pub fn parse(content: &str) -> Result<Vec<Dep>, CsprojError> {
	let doc = roxmltree::Document::parse(content).map_err(CsprojError::Parse)?;
	let root = doc.root_element();
	let mut out = Vec::new();

	if let Some(name) = project_self_name(root) {
		let version = property_value(root, "Version");
		out.push(Dep {
			name: name.clone(),
			version,
			dep_kind: "package".into(),
			import_root: name,
		});
	}

	for node in root.descendants() {
		match node.tag_name().name() {
			"PackageReference" => {
				let Some(name) = node.attribute("Include") else {
					continue;
				};
				let version = node
					.attribute("Version")
					.map(str::to_string)
					.or_else(|| element_text(node, "Version"));
				out.push(Dep {
					name: name.into(),
					version,
					dep_kind: "normal".into(),
					import_root: name.into(),
				});
			}
			"ProjectReference" => {
				let Some(path) = node.attribute("Include") else {
					continue;
				};
				let stem = project_path_stem(path);
				out.push(Dep {
					name: stem.clone(),
					version: None,
					dep_kind: "project".into(),
					import_root: stem,
				});
			}
			_ => {}
		}
	}

	Ok(out)
}

fn project_self_name(root: roxmltree::Node<'_, '_>) -> Option<String> {
	let mut fallback: Option<String> = None;
	for n in root.descendants() {
		if !n.is_element() {
			continue;
		}
		match n.tag_name().name() {
			"AssemblyName" => {
				if let Some(s) = node_trimmed_text(n) {
					return Some(s);
				}
			}
			"RootNamespace" if fallback.is_none() => {
				fallback = node_trimmed_text(n);
			}
			_ => {}
		}
	}
	fallback
}

fn property_value(root: roxmltree::Node<'_, '_>, tag: &str) -> Option<String> {
	root.descendants()
		.find(|n| n.is_element() && n.tag_name().name() == tag)
		.and_then(node_trimmed_text)
}

fn node_trimmed_text(n: roxmltree::Node<'_, '_>) -> Option<String> {
	n.text()
		.map(|s| s.trim().to_string())
		.filter(|s| !s.is_empty())
}

fn element_text(node: roxmltree::Node<'_, '_>, tag: &str) -> Option<String> {
	node.children()
		.find(|n| n.is_element() && n.tag_name().name() == tag)
		.and_then(|n| n.text())
		.map(|s| s.trim().to_string())
		.filter(|s| !s.is_empty())
}

fn project_path_stem(path: &str) -> String {
	let leaf = path.rsplit(['/', '\\']).next().unwrap_or(path);
	leaf.strip_suffix(".csproj").unwrap_or(leaf).to_string()
}

pub fn package_moniker(project: &[u8], import_root: &str) -> crate::core::moniker::Moniker {
	let mut b = crate::core::moniker::MonikerBuilder::new();
	b.project(project);
	let mut pieces = import_root.split('.').filter(|s| !s.is_empty());
	if let Some(head) = pieces.next() {
		b.segment(crate::lang::kinds::EXTERNAL_PKG, head.as_bytes());
		for piece in pieces {
			b.segment(crate::lang::kinds::PATH, piece.as_bytes());
		}
	}
	b.build()
}

#[cfg(test)]
mod tests {
	use super::*;

	#[test]
	fn parse_empty_project_returns_empty_vec() {
		let xml = r#"<Project Sdk="Microsoft.NET.Sdk"></Project>"#;
		assert!(parse(xml).unwrap().is_empty());
	}

	#[test]
	fn parse_self_name_from_assembly_name() {
		let xml = r#"<Project Sdk="Microsoft.NET.Sdk">
			<PropertyGroup>
				<AssemblyName>MyApp</AssemblyName>
				<Version>1.2.3</Version>
			</PropertyGroup>
		</Project>"#;
		let deps = parse(xml).unwrap();
		let pkg = deps.iter().find(|d| d.dep_kind == "package").unwrap();
		assert_eq!(pkg.name, "MyApp");
		assert_eq!(pkg.version.as_deref(), Some("1.2.3"));
		assert_eq!(pkg.import_root, "MyApp");
	}

	#[test]
	fn parse_falls_back_to_root_namespace() {
		let xml = r#"<Project>
			<PropertyGroup>
				<RootNamespace>Acme</RootNamespace>
			</PropertyGroup>
		</Project>"#;
		let deps = parse(xml).unwrap();
		assert!(
			deps.iter()
				.any(|d| d.dep_kind == "package" && d.name == "Acme")
		);
	}

	#[test]
	fn parse_package_reference_attribute_version() {
		let xml = r#"<Project>
			<ItemGroup>
				<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
			</ItemGroup>
		</Project>"#;
		let deps = parse(xml).unwrap();
		let pkg = deps.iter().find(|d| d.name == "Newtonsoft.Json").unwrap();
		assert_eq!(pkg.version.as_deref(), Some("13.0.1"));
		assert_eq!(pkg.dep_kind, "normal");
		assert_eq!(pkg.import_root, "Newtonsoft.Json");
	}

	#[test]
	fn parse_package_reference_element_version() {
		let xml = r#"<Project>
			<ItemGroup>
				<PackageReference Include="Serilog">
					<Version>3.0.0</Version>
				</PackageReference>
			</ItemGroup>
		</Project>"#;
		let deps = parse(xml).unwrap();
		let pkg = deps.iter().find(|d| d.name == "Serilog").unwrap();
		assert_eq!(pkg.version.as_deref(), Some("3.0.0"));
	}

	#[test]
	fn parse_project_reference_strips_path_and_extension() {
		let xml = r#"<Project>
			<ItemGroup>
				<ProjectReference Include="..\Other\Other.csproj" />
			</ItemGroup>
		</Project>"#;
		let deps = parse(xml).unwrap();
		let pr = deps.iter().find(|d| d.dep_kind == "project").unwrap();
		assert_eq!(pr.name, "Other");
		assert!(pr.version.is_none());
	}

	#[test]
	fn parse_project_reference_handles_unix_paths() {
		let xml = r#"<Project>
			<ItemGroup>
				<ProjectReference Include="../Other/Other.csproj" />
			</ItemGroup>
		</Project>"#;
		let deps = parse(xml).unwrap();
		assert!(
			deps.iter()
				.any(|d| d.name == "Other" && d.dep_kind == "project")
		);
	}

	#[test]
	fn parse_invalid_xml_returns_parse_error() {
		assert!(matches!(parse("<not closed"), Err(CsprojError::Parse(_))));
	}
}