Skip to main content

code_moniker_core/lang/ts/
build.rs

1use serde_json::Value;
2
3use crate::core::moniker::{Moniker, MonikerBuilder};
4
5use super::kinds;
6
7#[derive(Clone, Debug, Eq, PartialEq)]
8pub struct Dep {
9	pub name: String,
10	pub version: Option<String>,
11	pub dep_kind: String,
12	pub import_root: String,
13}
14
15#[derive(Debug)]
16pub enum PackageJsonError {
17	Parse(serde_json::Error),
18	Schema(String),
19}
20
21impl std::fmt::Display for PackageJsonError {
22	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23		match self {
24			Self::Parse(e) => write!(f, "package.json parse error: {e}"),
25			Self::Schema(s) => write!(f, "package.json schema error: {s}"),
26		}
27	}
28}
29
30impl std::error::Error for PackageJsonError {}
31
32pub fn parse(content: &str) -> Result<Vec<Dep>, PackageJsonError> {
33	let value: Value = serde_json::from_str(content).map_err(PackageJsonError::Parse)?;
34	let obj = value
35		.as_object()
36		.ok_or_else(|| PackageJsonError::Schema("top-level value is not a JSON object".into()))?;
37	let mut out = Vec::new();
38
39	if let Some(name) = obj.get("name").and_then(Value::as_str) {
40		let version = obj
41			.get("version")
42			.and_then(Value::as_str)
43			.map(str::to_string);
44		out.push(Dep {
45			name: name.to_string(),
46			version,
47			dep_kind: "package".to_string(),
48			import_root: ts_import_root(name),
49		});
50	}
51
52	for (field, kind_label) in [
53		("dependencies", "normal"),
54		("devDependencies", "dev"),
55		("peerDependencies", "peer"),
56		("optionalDependencies", "optional"),
57	] {
58		let Some(table) = obj.get(field).and_then(Value::as_object) else {
59			continue;
60		};
61		for (name, spec) in table {
62			let version = extract_version(spec);
63			out.push(Dep {
64				name: name.clone(),
65				version,
66				dep_kind: kind_label.to_string(),
67				import_root: ts_import_root(name),
68			});
69		}
70	}
71
72	Ok(out)
73}
74
75pub(crate) fn ts_import_root(name: &str) -> String {
76	name.to_string()
77}
78
79pub fn package_moniker(project: &[u8], import_root: &str) -> Moniker {
80	external_pkg_builder(project, import_root).build()
81}
82
83pub(in crate::lang::ts) fn external_pkg_builder(project: &[u8], pkg: &str) -> MonikerBuilder {
84	let (head, tail) = split_package_specifier(pkg);
85	let mut b = MonikerBuilder::new();
86	b.project(project);
87	b.segment(kinds::EXTERNAL_PKG, head.as_bytes());
88	for piece in tail.split('/').filter(|s| !s.is_empty()) {
89		b.segment(kinds::PATH, piece.as_bytes());
90	}
91	b
92}
93
94pub(in crate::lang::ts) fn module_specifier_builder(project: &[u8], spec: &str) -> MonikerBuilder {
95	if let Some((node_module, tail)) = node_builtin_specifier(spec) {
96		let mut builder = crate::lang::sdk::sdk_target_builder(project, b"ts");
97		builder.segment(kinds::PATH, node_module.as_bytes());
98		for piece in tail.split('/').filter(|piece| !piece.is_empty()) {
99			builder.segment(kinds::PATH, piece.as_bytes());
100		}
101		return builder;
102	}
103	external_pkg_builder(project, spec)
104}
105
106fn node_builtin_specifier(spec: &str) -> Option<(String, &str)> {
107	if let Some(explicit) = spec.strip_prefix("node:") {
108		let (root, tail) = explicit
109			.split_once('/')
110			.map_or((explicit, ""), |(root, tail)| (root, tail));
111		return (!root.is_empty()).then(|| (format!("node:{root}"), tail));
112	}
113	let (root, tail) = spec
114		.split_once('/')
115		.map_or((spec, ""), |(root, tail)| (root, tail));
116	NODE_BARE_BUILTINS
117		.contains(&root)
118		.then(|| (format!("node:{root}"), tail))
119}
120
121const NODE_BARE_BUILTINS: &[&str] = &[
122	"assert",
123	"async_hooks",
124	"buffer",
125	"child_process",
126	"cluster",
127	"console",
128	"constants",
129	"crypto",
130	"dgram",
131	"diagnostics_channel",
132	"dns",
133	"domain",
134	"events",
135	"fs",
136	"http",
137	"http2",
138	"https",
139	"inspector",
140	"module",
141	"net",
142	"os",
143	"path",
144	"perf_hooks",
145	"process",
146	"punycode",
147	"querystring",
148	"readline",
149	"repl",
150	"stream",
151	"string_decoder",
152	"sys",
153	"timers",
154	"tls",
155	"trace_events",
156	"tty",
157	"url",
158	"util",
159	"v8",
160	"vm",
161	"wasi",
162	"worker_threads",
163	"zlib",
164];
165
166fn split_package_specifier(spec: &str) -> (&str, &str) {
167	if let Some(after_scope) = spec.strip_prefix('@') {
168		let mut parts = after_scope.splitn(3, '/');
169		let scope = parts.next().unwrap_or("");
170		let name = parts.next().unwrap_or("");
171		let tail = parts.next().unwrap_or("");
172		let head_end = if name.is_empty() {
173			1 + scope.len()
174		} else {
175			1 + scope.len() + 1 + name.len()
176		};
177		(&spec[..head_end], tail)
178	} else {
179		match spec.find('/') {
180			Some(i) => (&spec[..i], &spec[i + 1..]),
181			None => (spec, ""),
182		}
183	}
184}
185
186fn extract_version(spec: &Value) -> Option<String> {
187	match spec {
188		Value::String(s) => Some(s.clone()),
189		Value::Object(o) => o.get("version").and_then(Value::as_str).map(str::to_string),
190		_ => None,
191	}
192}
193
194#[cfg(test)]
195mod tests {
196	use super::*;
197
198	#[test]
199	fn parse_minimal_package() {
200		let json = r#"{ "name": "demo", "version": "0.1.0" }"#;
201		let deps = parse(json).unwrap();
202		assert_eq!(
203			deps,
204			vec![Dep {
205				name: "demo".into(),
206				version: Some("0.1.0".into()),
207				dep_kind: "package".into(),
208				import_root: "demo".into(),
209			}]
210		);
211	}
212
213	#[test]
214	fn parse_normal_dep_keeps_version_string() {
215		let json = r#"{
216			"name": "demo",
217			"version": "0.1.0",
218			"dependencies": { "react": "^18.0.0" }
219		}"#;
220		let deps = parse(json).unwrap();
221		assert!(deps.contains(&Dep {
222			name: "react".into(),
223			version: Some("^18.0.0".into()),
224			dep_kind: "normal".into(),
225			import_root: "react".into(),
226		}));
227	}
228
229	#[test]
230	fn parse_object_dep_with_version_field() {
231		let json = r#"{
232			"name": "demo",
233			"version": "0.1.0",
234			"dependencies": { "tsup": { "version": "8.0.0" } }
235		}"#;
236		let deps = parse(json).unwrap();
237		assert!(
238			deps.iter()
239				.any(|d| d.name == "tsup" && d.version.as_deref() == Some("8.0.0"))
240		);
241	}
242
243	#[test]
244	fn parse_dev_peer_optional_kinds() {
245		let json = r#"{
246			"name": "demo",
247			"version": "0.1.0",
248			"devDependencies":      { "vitest":   "1.0.0" },
249			"peerDependencies":     { "react":    "^18.0.0" },
250			"optionalDependencies": { "fsevents": "2.0.0" }
251		}"#;
252		let deps = parse(json).unwrap();
253		assert!(
254			deps.iter()
255				.any(|d| d.name == "vitest" && d.dep_kind == "dev")
256		);
257		assert!(
258			deps.iter()
259				.any(|d| d.name == "react" && d.dep_kind == "peer")
260		);
261		assert!(
262			deps.iter()
263				.any(|d| d.name == "fsevents" && d.dep_kind == "optional")
264		);
265	}
266
267	#[test]
268	fn parse_scoped_package_keeps_full_name_in_import_root() {
269		let json = r#"{
270			"name": "demo",
271			"version": "0.1.0",
272			"dependencies": { "@scope/pkg": "1.0.0" }
273		}"#;
274		let deps = parse(json).unwrap();
275		let scoped = deps.iter().find(|d| d.name == "@scope/pkg").unwrap();
276		assert_eq!(scoped.import_root, "@scope/pkg");
277	}
278
279	#[test]
280	fn package_moniker_keeps_scoped_package_head() {
281		let moniker = package_moniker(b"app", "@scope/pkg/sub/path");
282		let segments = moniker.as_view().segments().collect::<Vec<_>>();
283		assert_eq!(segments[0].kind, b"external_pkg");
284		assert_eq!(segments[0].name, b"@scope/pkg");
285		assert_eq!(segments[1].kind, b"path");
286		assert_eq!(segments[1].name, b"sub");
287		assert_eq!(segments[2].kind, b"path");
288		assert_eq!(segments[2].name, b"path");
289	}
290
291	#[test]
292	fn node_prefixed_and_bare_builtins_share_one_sdk_identity() {
293		let prefixed = module_specifier_builder(b"app", "node:fs/promises").build();
294		let bare = module_specifier_builder(b"app", "fs/promises").build();
295
296		assert_eq!(bare, prefixed);
297		let segments = bare.as_view().segments().collect::<Vec<_>>();
298		assert_eq!(
299			segments
300				.iter()
301				.map(|segment| (segment.kind, segment.name))
302				.collect::<Vec<_>>(),
303			vec![
304				(b"sdk".as_slice(), b"ts".as_slice()),
305				(b"path".as_slice(), b"node:fs".as_slice()),
306				(b"path".as_slice(), b"promises".as_slice()),
307			]
308		);
309	}
310
311	#[test]
312	fn ordinary_package_specifier_remains_external() {
313		let package = module_specifier_builder(b"app", "react").build();
314		let first = package.as_view().segments().next().unwrap();
315
316		assert_eq!(first.kind, b"external_pkg");
317		assert_eq!(first.name, b"react");
318	}
319
320	#[test]
321	fn parse_invalid_json_returns_parse_error() {
322		assert!(matches!(
323			parse("{not json"),
324			Err(PackageJsonError::Parse(_))
325		));
326	}
327
328	#[test]
329	fn parse_non_object_top_level_is_schema_error() {
330		assert!(matches!(parse("[1,2,3]"), Err(PackageJsonError::Schema(_))));
331	}
332
333	#[test]
334	fn parse_missing_name_omits_package_row() {
335		let json = r#"{
336			"private": true,
337			"dependencies": { "react": "^18.0.0" }
338		}"#;
339		let deps = parse(json).unwrap();
340		assert!(deps.iter().all(|d| d.dep_kind != "package"));
341		assert!(deps.iter().any(|d| d.name == "react"));
342	}
343}