Skip to main content

code_moniker_core/lang/
build_manifest.rs

1//! Filename-keyed dispatch over the per-language manifest parsers, with
2//! `package_moniker` attached to each declared dep.
3
4use std::path::Path;
5
6use crate::core::moniker::Moniker;
7#[cfg(test)]
8use crate::core::moniker::MonikerBuilder;
9#[cfg(test)]
10use crate::lang::kinds;
11use crate::lang::{cs, go, java, python, rs, ts};
12
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct Dep {
15	pub package_moniker: Moniker,
16	pub name: String,
17	pub import_root: String,
18	pub version: Option<String>,
19	pub dep_kind: String,
20}
21
22#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
23pub enum Manifest {
24	Cargo,
25	PackageJson,
26	PomXml,
27	Pyproject,
28	GoMod,
29	Csproj,
30}
31
32impl Manifest {
33	pub const ALL: &'static [Manifest] = &[
34		Self::Cargo,
35		Self::PackageJson,
36		Self::PomXml,
37		Self::Pyproject,
38		Self::GoMod,
39		Self::Csproj,
40	];
41
42	pub fn tag(self) -> &'static str {
43		match self {
44			Self::Cargo => "cargo",
45			Self::PackageJson => "package_json",
46			Self::PomXml => "pom_xml",
47			Self::Pyproject => "pyproject",
48			Self::GoMod => "go_mod",
49			Self::Csproj => "csproj",
50		}
51	}
52
53	pub fn for_filename(path: &Path) -> Option<Self> {
54		let name = path.file_name()?.to_str()?;
55		match name {
56			"Cargo.toml" => Some(Self::Cargo),
57			"package.json" => Some(Self::PackageJson),
58			"pom.xml" => Some(Self::PomXml),
59			"pyproject.toml" => Some(Self::Pyproject),
60			"go.mod" => Some(Self::GoMod),
61			_ if name.ends_with(".csproj") => Some(Self::Csproj),
62			_ => None,
63		}
64	}
65}
66
67#[derive(Debug)]
68pub enum ManifestError {
69	Cargo(rs::build::CargoError),
70	PackageJson(ts::build::PackageJsonError),
71	PomXml(java::build::PomXmlError),
72	Pyproject(python::build::PyprojectError),
73	GoMod(go::build::GoModError),
74	Csproj(cs::build::CsprojError),
75}
76
77impl std::fmt::Display for ManifestError {
78	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79		match self {
80			Self::Cargo(e) => e.fmt(f),
81			Self::PackageJson(e) => e.fmt(f),
82			Self::PomXml(e) => e.fmt(f),
83			Self::Pyproject(e) => e.fmt(f),
84			Self::GoMod(e) => e.fmt(f),
85			Self::Csproj(e) => e.fmt(f),
86		}
87	}
88}
89
90impl std::error::Error for ManifestError {
91	fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
92		match self {
93			Self::Cargo(e) => Some(e),
94			Self::PackageJson(e) => Some(e),
95			Self::PomXml(e) => Some(e),
96			Self::Pyproject(e) => Some(e),
97			Self::GoMod(e) => Some(e),
98			Self::Csproj(e) => Some(e),
99		}
100	}
101}
102
103pub fn parse(manifest: Manifest, project: &[u8], content: &str) -> Result<Vec<Dep>, ManifestError> {
104	match manifest {
105		Manifest::Cargo => rs::build::parse(content)
106			.map_err(ManifestError::Cargo)
107			.map(|v| {
108				v.into_iter()
109					.map(|d| project_into(manifest, project, d))
110					.collect()
111			}),
112		Manifest::PackageJson => ts::build::parse(content)
113			.map_err(ManifestError::PackageJson)
114			.map(|v| {
115				v.into_iter()
116					.map(|d| project_into(manifest, project, d))
117					.collect()
118			}),
119		Manifest::PomXml => java::build::parse(content)
120			.map_err(ManifestError::PomXml)
121			.map(|v| {
122				v.into_iter()
123					.map(|d| project_into(manifest, project, d))
124					.collect()
125			}),
126		Manifest::Pyproject => python::build::parse(content)
127			.map_err(ManifestError::Pyproject)
128			.map(|v| {
129				v.into_iter()
130					.map(|d| project_into(manifest, project, d))
131					.collect()
132			}),
133		Manifest::GoMod => go::build::parse(content)
134			.map_err(ManifestError::GoMod)
135			.map(|v| {
136				v.into_iter()
137					.map(|d| project_into(manifest, project, d))
138					.collect()
139			}),
140		Manifest::Csproj => cs::build::parse(content)
141			.map_err(ManifestError::Csproj)
142			.map(|v| {
143				v.into_iter()
144					.map(|d| project_into(manifest, project, d))
145					.collect()
146			}),
147	}
148}
149
150pub fn package_moniker(manifest: Manifest, project: &[u8], import_root: &str) -> Moniker {
151	match manifest {
152		Manifest::Cargo => rs::build::package_moniker(project, import_root),
153		Manifest::PackageJson => ts::build::package_moniker(project, import_root),
154		Manifest::PomXml => java::build::package_moniker(project, import_root),
155		Manifest::Pyproject => python::build::package_moniker(project, import_root),
156		Manifest::GoMod => go::build::package_moniker(project, import_root),
157		Manifest::Csproj => cs::build::package_moniker(project, import_root),
158	}
159}
160
161trait IntoDep {
162	fn into_dep(self, manifest: Manifest, project: &[u8]) -> Dep;
163}
164
165fn project_into<T: IntoDep>(manifest: Manifest, project: &[u8], dep: T) -> Dep {
166	dep.into_dep(manifest, project)
167}
168
169macro_rules! impl_into_dep {
170	($($t:ty),* $(,)?) => {
171		$(
172			impl IntoDep for $t {
173				fn into_dep(self, manifest: Manifest, project: &[u8]) -> Dep {
174					let package_moniker = package_moniker(manifest, project, &self.import_root);
175					Dep {
176						package_moniker,
177						name: self.name,
178						import_root: self.import_root,
179						version: self.version,
180						dep_kind: self.dep_kind,
181					}
182				}
183			}
184		)*
185	};
186}
187
188impl_into_dep!(
189	rs::build::Dep,
190	ts::build::Dep,
191	java::build::Dep,
192	python::build::Dep,
193	go::build::Dep,
194	cs::build::Dep,
195);
196
197#[cfg(test)]
198mod tests {
199	use super::*;
200	use std::path::PathBuf;
201
202	#[test]
203	fn for_filename_recognises_each_manifest() {
204		for (name, want) in [
205			("Cargo.toml", Manifest::Cargo),
206			("package.json", Manifest::PackageJson),
207			("pom.xml", Manifest::PomXml),
208			("pyproject.toml", Manifest::Pyproject),
209			("go.mod", Manifest::GoMod),
210			("MyApp.csproj", Manifest::Csproj),
211		] {
212			assert_eq!(
213				Manifest::for_filename(&PathBuf::from(name)),
214				Some(want),
215				"{name}"
216			);
217		}
218	}
219
220	#[test]
221	fn for_filename_ignores_unknown_and_directories() {
222		assert!(Manifest::for_filename(&PathBuf::from("README.md")).is_none());
223		assert!(Manifest::for_filename(&PathBuf::from("")).is_none());
224	}
225
226	#[test]
227	fn package_moniker_round_trips_through_uri() {
228		use crate::core::uri::{UriConfig, to_uri};
229		let m = package_moniker(Manifest::PackageJson, b".", "react");
230		let cfg = UriConfig {
231			scheme: "code+moniker://",
232		};
233		let uri = to_uri(&m, &cfg).expect("utf-8 segments");
234		assert_eq!(uri, "code+moniker://./external_pkg:react");
235	}
236
237	#[test]
238	fn parse_cargo_includes_package_moniker_for_each_row() {
239		let toml = r#"
240			[package]
241			name = "demo"
242			version = "0.1.0"
243
244			[dependencies]
245			serde-json = "1.0"
246		"#;
247		let deps = parse(Manifest::Cargo, b".", toml).expect("ok");
248		let demo = deps.iter().find(|d| d.name == "demo").unwrap();
249		assert_eq!(
250			demo.package_moniker,
251			package_moniker(Manifest::Cargo, b".", "demo")
252		);
253		let sj = deps.iter().find(|d| d.name == "serde-json").unwrap();
254		assert_eq!(sj.import_root, "serde_json");
255		assert_eq!(
256			sj.package_moniker,
257			package_moniker(Manifest::Cargo, b".", "serde_json")
258		);
259	}
260
261	#[test]
262	fn parse_pyproject_normalises_import_root_in_moniker() {
263		let toml = r#"
264			[project]
265			name = "demo"
266			dependencies = ["requests-html >=1.0"]
267		"#;
268		let deps = parse(Manifest::Pyproject, b".", toml).expect("ok");
269		let rh = deps
270			.iter()
271			.find(|d| d.name == "requests-html")
272			.expect("dep parsed");
273		assert_eq!(rh.import_root, "requests_html");
274		assert_eq!(
275			rh.package_moniker,
276			package_moniker(Manifest::Pyproject, b".", "requests_html")
277		);
278	}
279
280	#[test]
281	fn package_moniker_splits_go_module_path_on_slash() {
282		let m = package_moniker(Manifest::GoMod, b"app", "github.com/gorilla/mux");
283		use crate::core::uri::{UriConfig, to_uri};
284		let uri = to_uri(
285			&m,
286			&UriConfig {
287				scheme: "code+moniker://",
288			},
289		)
290		.expect("utf-8");
291		assert_eq!(
292			uri,
293			"code+moniker://app/external_pkg:github.com/path:gorilla/path:mux"
294		);
295	}
296
297	#[test]
298	fn package_moniker_splits_csharp_namespace_on_dot() {
299		let m = package_moniker(Manifest::Csproj, b"app", "Newtonsoft.Json");
300		use crate::core::uri::{UriConfig, to_uri};
301		let uri = to_uri(
302			&m,
303			&UriConfig {
304				scheme: "code+moniker://",
305			},
306		)
307		.expect("utf-8");
308		assert_eq!(uri, "code+moniker://app/external_pkg:Newtonsoft/path:Json");
309	}
310
311	#[test]
312	fn parse_dispatches_each_manifest_kind() {
313		let cases: Vec<(Manifest, &str, &str)> = vec![
314			(
315				Manifest::Cargo,
316				r#"[package]
317name = "x"
318version = "0""#,
319				"x",
320			),
321			(Manifest::PackageJson, r#"{"name":"x","version":"0"}"#, "x"),
322			(
323				Manifest::GoMod,
324				r#"module x
325go 1.21"#,
326				"x",
327			),
328		];
329		for (m, content, head) in cases {
330			let deps =
331				parse(m, b".", content).unwrap_or_else(|e| panic!("{} parse failed: {e}", m.tag()));
332			assert!(
333				deps.iter().any(|d| d.import_root == head),
334				"{} did not yield head {head}",
335				m.tag()
336			);
337		}
338	}
339
340	#[test]
341	fn parse_propagates_per_lang_error_variant() {
342		let err = parse(Manifest::Cargo, b".", "not [valid toml").unwrap_err();
343		assert!(matches!(err, ManifestError::Cargo(_)));
344		let err = parse(Manifest::PackageJson, b".", "{not json").unwrap_err();
345		assert!(matches!(err, ManifestError::PackageJson(_)));
346	}
347
348	fn first_external_target(
349		g: &crate::core::code_graph::CodeGraph,
350		head_name: &str,
351	) -> Option<Moniker> {
352		g.refs()
353			.find(|r| {
354				let mut segs = r.target.as_view().segments();
355				match segs.next() {
356					Some(s) => s.kind == kinds::EXTERNAL_PKG && s.name == head_name.as_bytes(),
357					None => false,
358				}
359			})
360			.map(|r| r.target.clone())
361	}
362
363	/// `package_moniker(import_root)` must be `@>`-ancestor of (or equal
364	/// to) the ref target the extractor emits for the same import. Python
365	/// uses `os` because only stdlib goes through `external_pkg`; Java is
366	/// excluded since non-stdlib imports use `lang:java/package:…`.
367	#[test]
368	fn package_moniker_binds_extractor_ref_per_language() {
369		use crate::lang::{cs, go, python, rs, ts};
370		let anchor = MonikerBuilder::new().project(b"app").build();
371
372		struct Case {
373			lang: &'static str,
374			manifest: Manifest,
375			extractor_head: &'static str,
376			import_root: &'static str,
377			run: fn(&Moniker) -> crate::core::code_graph::CodeGraph,
378		}
379
380		fn run_ts(a: &Moniker) -> crate::core::code_graph::CodeGraph {
381			ts::extract(
382				"util.ts",
383				"import { x } from 'react';",
384				a,
385				false,
386				&ts::Presets::default(),
387			)
388		}
389		fn run_rs(a: &Moniker) -> crate::core::code_graph::CodeGraph {
390			rs::extract(
391				"util.rs",
392				"use serde_json;",
393				a,
394				false,
395				&rs::Presets::default(),
396			)
397		}
398		fn run_python(a: &Moniker) -> crate::core::code_graph::CodeGraph {
399			python::extract("m.py", "import os\n", a, false, &python::Presets::default())
400		}
401		fn run_go(a: &Moniker) -> crate::core::code_graph::CodeGraph {
402			go::extract(
403				"foo.go",
404				"package foo\nimport \"github.com/gorilla/mux\"\n",
405				a,
406				false,
407				&go::Presets::default(),
408			)
409		}
410		fn run_cs(a: &Moniker) -> crate::core::code_graph::CodeGraph {
411			cs::extract(
412				"F.cs",
413				"using Newtonsoft.Json;\n",
414				a,
415				false,
416				&cs::Presets::default(),
417			)
418		}
419
420		let cases = [
421			Case {
422				lang: "ts",
423				manifest: Manifest::PackageJson,
424				extractor_head: "react",
425				import_root: "react",
426				run: run_ts,
427			},
428			Case {
429				lang: "rs",
430				manifest: Manifest::Cargo,
431				extractor_head: "serde_json",
432				import_root: "serde_json",
433				run: run_rs,
434			},
435			Case {
436				lang: "python",
437				manifest: Manifest::Pyproject,
438				extractor_head: "os",
439				import_root: "os",
440				run: run_python,
441			},
442			Case {
443				lang: "go",
444				manifest: Manifest::GoMod,
445				extractor_head: "github.com",
446				import_root: "github.com/gorilla/mux",
447				run: run_go,
448			},
449			Case {
450				lang: "cs",
451				manifest: Manifest::Csproj,
452				extractor_head: "Newtonsoft",
453				import_root: "Newtonsoft.Json",
454				run: run_cs,
455			},
456		];
457
458		for case in cases {
459			let g = (case.run)(&anchor);
460			let target = first_external_target(&g, case.extractor_head).unwrap_or_else(|| {
461				panic!(
462					"lang={}: no ref target with head external_pkg:{}",
463					case.lang, case.extractor_head
464				)
465			});
466			let pkg = package_moniker(case.manifest, b"app", case.import_root);
467			assert!(
468				pkg.as_view().is_ancestor_of(&target.as_view()) || pkg == target,
469				"lang={}: package_moniker({}) must be @>-ancestor of ref target (pkg={:?} target={:?})",
470				case.lang,
471				case.import_root,
472				pkg.as_bytes(),
473				target.as_bytes(),
474			);
475		}
476	}
477
478	#[test]
479	fn ts_scoped_package_moniker_binds_extractor_ref() {
480		use crate::lang::ts;
481		let anchor = MonikerBuilder::new().project(b"app").build();
482		let g = ts::extract(
483			"util.ts",
484			"import x from '@scope/pkg';",
485			&anchor,
486			false,
487			&ts::Presets::default(),
488		);
489		let target = first_external_target(&g, "@scope/pkg").expect("scoped ref");
490		let pkg = package_moniker(Manifest::PackageJson, b"app", "@scope/pkg");
491		assert!(
492			pkg.as_view().is_ancestor_of(&target.as_view()) || pkg == target,
493			"scoped pkg must bind extractor ref via @>"
494		);
495	}
496}