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 PyprojectError {
	Parse(toml::de::Error),
	Schema(String),
}

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

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

pub fn parse(content: &str) -> Result<Vec<Dep>, PyprojectError> {
	let value: toml::Value = toml::from_str(content).map_err(PyprojectError::Parse)?;
	let mut out = Vec::new();

	if let Some(project) = value.get("project").and_then(|v| v.as_table()) {
		if let Some(name) = project.get("name").and_then(|v| v.as_str()) {
			let version = project
				.get("version")
				.and_then(|v| v.as_str())
				.map(str::to_string);
			out.push(Dep {
				name: name.to_string(),
				version,
				dep_kind: "package".to_string(),
				import_root: python_import_root(name),
			});
		}
		if let Some(deps) = project.get("dependencies").and_then(|v| v.as_array()) {
			for spec in deps.iter().filter_map(|v| v.as_str()) {
				if let Some(dep) = parse_pep508(spec, "normal") {
					out.push(dep);
				}
			}
		}
		if let Some(opt) = project
			.get("optional-dependencies")
			.and_then(|v| v.as_table())
		{
			for (group, list) in opt {
				let Some(arr) = list.as_array() else { continue };
				let kind = format!("optional:{group}");
				for spec in arr.iter().filter_map(|v| v.as_str()) {
					if let Some(dep) = parse_pep508(spec, &kind) {
						out.push(dep);
					}
				}
			}
		}
	}

	if let Some(poetry) = value
		.get("tool")
		.and_then(|t| t.get("poetry"))
		.and_then(|p| p.as_table())
	{
		if out.iter().all(|d| d.dep_kind != "package")
			&& let Some(name) = poetry.get("name").and_then(|v| v.as_str())
		{
			let version = poetry
				.get("version")
				.and_then(|v| v.as_str())
				.map(str::to_string);
			out.push(Dep {
				name: name.to_string(),
				version,
				dep_kind: "package".to_string(),
				import_root: python_import_root(name),
			});
		}
		for (table_key, kind_label) in [("dependencies", "normal"), ("dev-dependencies", "dev")] {
			let Some(table) = poetry.get(table_key).and_then(|v| v.as_table()) else {
				continue;
			};
			for (name, spec) in table {
				if name == "python" {
					continue;
				}
				let version = crate::lang::rs::build::extract_version(spec);
				out.push(Dep {
					name: name.clone(),
					version,
					dep_kind: kind_label.to_string(),
					import_root: python_import_root(name),
				});
			}
		}
	}

	Ok(out)
}

fn parse_pep508(spec: &str, dep_kind: &str) -> Option<Dep> {
	let trimmed = spec.split(';').next()?.trim();
	if trimmed.is_empty() {
		return None;
	}
	let mut name_end = trimmed.len();
	for (i, ch) in trimmed.char_indices() {
		if matches!(ch, '=' | '<' | '>' | '!' | '~' | ' ' | '\t' | '[' | '(') {
			name_end = i;
			break;
		}
	}
	let name = trimmed[..name_end].trim();
	if name.is_empty() {
		return None;
	}
	let after_extras = match trimmed[name_end..].find(']') {
		Some(close) => trimmed[name_end + close + 1..].trim(),
		None => trimmed[name_end..].trim(),
	};
	let version = if after_extras.is_empty() {
		None
	} else {
		Some(after_extras.trim().to_string())
	};
	Some(Dep {
		name: name.to_string(),
		version,
		dep_kind: dep_kind.to_string(),
		import_root: python_import_root(name),
	})
}

pub(crate) fn python_import_root(name: &str) -> String {
	name.replace('-', "_").to_ascii_lowercase()
}

pub fn package_moniker(project: &[u8], import_root: &str) -> crate::core::moniker::Moniker {
	let mut b = crate::core::moniker::MonikerBuilder::new();
	b.project(project);
	b.segment(crate::lang::kinds::EXTERNAL_PKG, import_root.as_bytes());
	b.build()
}

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

	#[test]
	fn parse_pep621_project_emits_package_row() {
		let src = r#"
            [project]
            name = "demo"
            version = "0.2.0"
        "#;
		let deps = parse(src).unwrap();
		assert!(deps.contains(&Dep {
			name: "demo".into(),
			version: Some("0.2.0".into()),
			dep_kind: "package".into(),
			import_root: "demo".into(),
		}));
	}

	#[test]
	fn parse_pep621_dependencies_keeps_version_constraints() {
		let src = r#"
            [project]
            name = "demo"
            version = "0.1.0"
            dependencies = ["httpx==0.27.2", "anyio>=3.7"]
        "#;
		let deps = parse(src).unwrap();
		let httpx = deps.iter().find(|d| d.name == "httpx").unwrap();
		assert_eq!(httpx.version.as_deref(), Some("==0.27.2"));
		assert_eq!(httpx.dep_kind, "normal");
		let anyio = deps.iter().find(|d| d.name == "anyio").unwrap();
		assert_eq!(anyio.version.as_deref(), Some(">=3.7"));
	}

	#[test]
	fn parse_pep621_strips_extras_marker_and_environment() {
		let src = r#"
            [project]
            name = "demo"
            dependencies = ["requests[security]>=2.31; python_version >= '3.8'"]
        "#;
		let deps = parse(src).unwrap();
		let req = deps.iter().find(|d| d.name == "requests").unwrap();
		assert_eq!(req.version.as_deref(), Some(">=2.31"));
	}

	#[test]
	fn parse_pep621_optional_dependencies_emit_grouped_kind() {
		let src = r#"
            [project]
            name = "demo"
            [project.optional-dependencies]
            test = ["pytest>=7.0"]
            docs = ["sphinx"]
        "#;
		let deps = parse(src).unwrap();
		assert!(
			deps.iter()
				.any(|d| d.name == "pytest" && d.dep_kind == "optional:test")
		);
		assert!(
			deps.iter()
				.any(|d| d.name == "sphinx" && d.dep_kind == "optional:docs")
		);
	}

	#[test]
	fn parse_poetry_dependencies_skip_python_marker() {
		let src = r#"
            [tool.poetry]
            name = "demo"
            version = "0.1.0"
            [tool.poetry.dependencies]
            python = "^3.10"
            httpx = "^0.27"
            [tool.poetry.dev-dependencies]
            pytest = "^7.0"
        "#;
		let deps = parse(src).unwrap();
		assert!(!deps.iter().any(|d| d.name == "python"));
		assert!(
			deps.iter()
				.any(|d| d.name == "httpx" && d.dep_kind == "normal")
		);
		assert!(
			deps.iter()
				.any(|d| d.name == "pytest" && d.dep_kind == "dev")
		);
	}

	#[test]
	fn parse_poetry_table_uses_version_field() {
		let src = r#"
            [tool.poetry]
            name = "demo"
            [tool.poetry.dependencies]
            sqlalchemy = { version = "^2.0", extras = ["asyncio"] }
        "#;
		let deps = parse(src).unwrap();
		let sa = deps.iter().find(|d| d.name == "sqlalchemy").unwrap();
		assert_eq!(sa.version.as_deref(), Some("^2.0"));
	}

	#[test]
	fn parse_normalizes_hyphenated_import_root_to_underscore_lowercase() {
		let src = r#"
            [project]
            name = "Some-Project"
            dependencies = ["python-dateutil"]
        "#;
		let deps = parse(src).unwrap();
		let proj = deps.iter().find(|d| d.dep_kind == "package").unwrap();
		assert_eq!(proj.import_root, "some_project");
		let pd = deps.iter().find(|d| d.name == "python-dateutil").unwrap();
		assert_eq!(pd.import_root, "python_dateutil");
	}

	#[test]
	fn parse_invalid_toml_returns_parse_error() {
		assert!(matches!(parse("not toml ["), Err(PyprojectError::Parse(_))));
	}
}