Skip to main content

code_moniker_core/lang/ts/
build.rs

1use serde_json::Value;
2
3#[derive(Clone, Debug, Eq, PartialEq)]
4pub struct Dep {
5	pub name: String,
6	pub version: Option<String>,
7	pub dep_kind: String,
8	pub import_root: String,
9}
10
11#[derive(Debug)]
12pub enum PackageJsonError {
13	Parse(serde_json::Error),
14	Schema(String),
15}
16
17impl std::fmt::Display for PackageJsonError {
18	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19		match self {
20			Self::Parse(e) => write!(f, "package.json parse error: {e}"),
21			Self::Schema(s) => write!(f, "package.json schema error: {s}"),
22		}
23	}
24}
25
26impl std::error::Error for PackageJsonError {}
27
28pub fn parse(content: &str) -> Result<Vec<Dep>, PackageJsonError> {
29	let value: Value = serde_json::from_str(content).map_err(PackageJsonError::Parse)?;
30	let obj = value
31		.as_object()
32		.ok_or_else(|| PackageJsonError::Schema("top-level value is not a JSON object".into()))?;
33	let mut out = Vec::new();
34
35	if let Some(name) = obj.get("name").and_then(Value::as_str) {
36		let version = obj
37			.get("version")
38			.and_then(Value::as_str)
39			.map(str::to_string);
40		out.push(Dep {
41			name: name.to_string(),
42			version,
43			dep_kind: "package".to_string(),
44			import_root: ts_import_root(name),
45		});
46	}
47
48	for (field, kind_label) in [
49		("dependencies", "normal"),
50		("devDependencies", "dev"),
51		("peerDependencies", "peer"),
52		("optionalDependencies", "optional"),
53	] {
54		let Some(table) = obj.get(field).and_then(Value::as_object) else {
55			continue;
56		};
57		for (name, spec) in table {
58			let version = extract_version(spec);
59			out.push(Dep {
60				name: name.clone(),
61				version,
62				dep_kind: kind_label.to_string(),
63				import_root: ts_import_root(name),
64			});
65		}
66	}
67
68	Ok(out)
69}
70
71pub(crate) fn ts_import_root(name: &str) -> String {
72	name.to_string()
73}
74
75pub fn package_moniker(project: &[u8], import_root: &str) -> crate::core::moniker::Moniker {
76	super::canonicalize::external_pkg_builder(project, import_root).build()
77}
78
79fn extract_version(spec: &Value) -> Option<String> {
80	match spec {
81		Value::String(s) => Some(s.clone()),
82		Value::Object(o) => o.get("version").and_then(Value::as_str).map(str::to_string),
83		_ => None,
84	}
85}
86
87#[cfg(test)]
88mod tests {
89	use super::*;
90
91	#[test]
92	fn parse_minimal_package() {
93		let json = r#"{ "name": "demo", "version": "0.1.0" }"#;
94		let deps = parse(json).unwrap();
95		assert_eq!(
96			deps,
97			vec![Dep {
98				name: "demo".into(),
99				version: Some("0.1.0".into()),
100				dep_kind: "package".into(),
101				import_root: "demo".into(),
102			}]
103		);
104	}
105
106	#[test]
107	fn parse_normal_dep_keeps_version_string() {
108		let json = r#"{
109			"name": "demo",
110			"version": "0.1.0",
111			"dependencies": { "react": "^18.0.0" }
112		}"#;
113		let deps = parse(json).unwrap();
114		assert!(deps.contains(&Dep {
115			name: "react".into(),
116			version: Some("^18.0.0".into()),
117			dep_kind: "normal".into(),
118			import_root: "react".into(),
119		}));
120	}
121
122	#[test]
123	fn parse_object_dep_with_version_field() {
124		let json = r#"{
125			"name": "demo",
126			"version": "0.1.0",
127			"dependencies": { "tsup": { "version": "8.0.0" } }
128		}"#;
129		let deps = parse(json).unwrap();
130		assert!(
131			deps.iter()
132				.any(|d| d.name == "tsup" && d.version.as_deref() == Some("8.0.0"))
133		);
134	}
135
136	#[test]
137	fn parse_dev_peer_optional_kinds() {
138		let json = r#"{
139			"name": "demo",
140			"version": "0.1.0",
141			"devDependencies":      { "vitest":   "1.0.0" },
142			"peerDependencies":     { "react":    "^18.0.0" },
143			"optionalDependencies": { "fsevents": "2.0.0" }
144		}"#;
145		let deps = parse(json).unwrap();
146		assert!(
147			deps.iter()
148				.any(|d| d.name == "vitest" && d.dep_kind == "dev")
149		);
150		assert!(
151			deps.iter()
152				.any(|d| d.name == "react" && d.dep_kind == "peer")
153		);
154		assert!(
155			deps.iter()
156				.any(|d| d.name == "fsevents" && d.dep_kind == "optional")
157		);
158	}
159
160	#[test]
161	fn parse_scoped_package_keeps_full_name_in_import_root() {
162		let json = r#"{
163			"name": "demo",
164			"version": "0.1.0",
165			"dependencies": { "@scope/pkg": "1.0.0" }
166		}"#;
167		let deps = parse(json).unwrap();
168		let scoped = deps.iter().find(|d| d.name == "@scope/pkg").unwrap();
169		assert_eq!(scoped.import_root, "@scope/pkg");
170	}
171
172	#[test]
173	fn parse_invalid_json_returns_parse_error() {
174		assert!(matches!(
175			parse("{not json"),
176			Err(PackageJsonError::Parse(_))
177		));
178	}
179
180	#[test]
181	fn parse_non_object_top_level_is_schema_error() {
182		assert!(matches!(parse("[1,2,3]"), Err(PackageJsonError::Schema(_))));
183	}
184
185	#[test]
186	fn parse_missing_name_omits_package_row() {
187		let json = r#"{
188			"private": true,
189			"dependencies": { "react": "^18.0.0" }
190		}"#;
191		let deps = parse(json).unwrap();
192		assert!(deps.iter().all(|d| d.dep_kind != "package"));
193		assert!(deps.iter().any(|d| d.name == "react"));
194	}
195}