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