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
75// A DefinitelyTyped package types the runtime package it shadows:
76// @types/vscode declares the `vscode` import root and the scoped mangling
77// @types/babel__core declares `@babel/core`.
78pub(crate) fn ts_import_root(name: &str) -> String {
79	let Some(types) = name.strip_prefix("@types/") else {
80		return name.to_string();
81	};
82	match types.split_once("__") {
83		Some((scope, package)) => format!("@{scope}/{package}"),
84		None => types.to_string(),
85	}
86}
87
88pub fn package_moniker(project: &[u8], import_root: &str) -> Moniker {
89	external_pkg_builder(project, import_root).build()
90}
91
92pub(in crate::lang::ts) fn external_pkg_builder(project: &[u8], pkg: &str) -> MonikerBuilder {
93	let (head, tail) = split_package_specifier(pkg);
94	let mut b = MonikerBuilder::new();
95	b.project(project);
96	b.segment(kinds::EXTERNAL_PKG, head.as_bytes());
97	for piece in tail.split('/').filter(|s| !s.is_empty()) {
98		b.segment(kinds::PATH, piece.as_bytes());
99	}
100	b
101}
102
103pub(in crate::lang::ts) fn module_specifier_builder(project: &[u8], spec: &str) -> MonikerBuilder {
104	if let Some((node_module, tail)) = node_builtin_specifier(spec) {
105		let mut builder = crate::lang::sdk::sdk_target_builder(project, b"ts");
106		builder.segment(kinds::PATH, node_module.as_bytes());
107		for piece in tail.split('/').filter(|piece| !piece.is_empty()) {
108			builder.segment(kinds::PATH, piece.as_bytes());
109		}
110		return builder;
111	}
112	external_pkg_builder(project, spec)
113}
114
115fn node_builtin_specifier(spec: &str) -> Option<(String, &str)> {
116	if let Some(explicit) = spec.strip_prefix("node:") {
117		let (root, tail) = explicit
118			.split_once('/')
119			.map_or((explicit, ""), |(root, tail)| (root, tail));
120		return (!root.is_empty()).then(|| (format!("node:{root}"), tail));
121	}
122	let (root, tail) = spec
123		.split_once('/')
124		.map_or((spec, ""), |(root, tail)| (root, tail));
125	NODE_BARE_BUILTINS
126		.contains(&root)
127		.then(|| (format!("node:{root}"), tail))
128}
129
130const NODE_BARE_BUILTINS: &[&str] = &[
131	"assert",
132	"async_hooks",
133	"buffer",
134	"child_process",
135	"cluster",
136	"console",
137	"constants",
138	"crypto",
139	"dgram",
140	"diagnostics_channel",
141	"dns",
142	"domain",
143	"events",
144	"fs",
145	"http",
146	"http2",
147	"https",
148	"inspector",
149	"module",
150	"net",
151	"os",
152	"path",
153	"perf_hooks",
154	"process",
155	"punycode",
156	"querystring",
157	"readline",
158	"repl",
159	"stream",
160	"string_decoder",
161	"sys",
162	"timers",
163	"tls",
164	"trace_events",
165	"tty",
166	"url",
167	"util",
168	"v8",
169	"vm",
170	"wasi",
171	"worker_threads",
172	"zlib",
173];
174
175fn split_package_specifier(spec: &str) -> (&str, &str) {
176	if let Some(after_scope) = spec.strip_prefix('@') {
177		let mut parts = after_scope.splitn(3, '/');
178		let scope = parts.next().unwrap_or("");
179		let name = parts.next().unwrap_or("");
180		let tail = parts.next().unwrap_or("");
181		let head_end = if name.is_empty() {
182			1 + scope.len()
183		} else {
184			1 + scope.len() + 1 + name.len()
185		};
186		(&spec[..head_end], tail)
187	} else {
188		match spec.find('/') {
189			Some(i) => (&spec[..i], &spec[i + 1..]),
190			None => (spec, ""),
191		}
192	}
193}
194
195fn extract_version(spec: &Value) -> Option<String> {
196	match spec {
197		Value::String(s) => Some(s.clone()),
198		Value::Object(o) => o.get("version").and_then(Value::as_str).map(str::to_string),
199		_ => None,
200	}
201}
202
203#[cfg(test)]
204mod tests {
205	use super::*;
206
207	#[test]
208	fn types_package_declares_the_runtime_import_root() {
209		let json = r#"{
210			"name": "demo",
211			"version": "0.1.0",
212			"devDependencies": {
213				"@types/vscode": "^1.90.0",
214				"@types/babel__core": "^7.20.0"
215			}
216		}"#;
217		let deps = parse(json).unwrap();
218		assert!(
219			deps.iter()
220				.any(|d| d.name == "@types/vscode" && d.import_root == "vscode"),
221			"@types/vscode must declare the vscode import root: {deps:#?}"
222		);
223		assert!(
224			deps.iter()
225				.any(|d| d.name == "@types/babel__core" && d.import_root == "@babel/core"),
226			"DefinitelyTyped scoped mangling must unfold: {deps:#?}"
227		);
228	}
229
230	#[test]
231	fn parse_minimal_package() {
232		let json = r#"{ "name": "demo", "version": "0.1.0" }"#;
233		let deps = parse(json).unwrap();
234		assert_eq!(
235			deps,
236			vec![Dep {
237				name: "demo".into(),
238				version: Some("0.1.0".into()),
239				dep_kind: "package".into(),
240				import_root: "demo".into(),
241			}]
242		);
243	}
244
245	#[test]
246	fn parse_normal_dep_keeps_version_string() {
247		let json = r#"{
248			"name": "demo",
249			"version": "0.1.0",
250			"dependencies": { "react": "^18.0.0" }
251		}"#;
252		let deps = parse(json).unwrap();
253		assert!(deps.contains(&Dep {
254			name: "react".into(),
255			version: Some("^18.0.0".into()),
256			dep_kind: "normal".into(),
257			import_root: "react".into(),
258		}));
259	}
260
261	#[test]
262	fn parse_object_dep_with_version_field() {
263		let json = r#"{
264			"name": "demo",
265			"version": "0.1.0",
266			"dependencies": { "tsup": { "version": "8.0.0" } }
267		}"#;
268		let deps = parse(json).unwrap();
269		assert!(
270			deps.iter()
271				.any(|d| d.name == "tsup" && d.version.as_deref() == Some("8.0.0"))
272		);
273	}
274
275	#[test]
276	fn parse_dev_peer_optional_kinds() {
277		let json = r#"{
278			"name": "demo",
279			"version": "0.1.0",
280			"devDependencies":      { "vitest":   "1.0.0" },
281			"peerDependencies":     { "react":    "^18.0.0" },
282			"optionalDependencies": { "fsevents": "2.0.0" }
283		}"#;
284		let deps = parse(json).unwrap();
285		assert!(
286			deps.iter()
287				.any(|d| d.name == "vitest" && d.dep_kind == "dev")
288		);
289		assert!(
290			deps.iter()
291				.any(|d| d.name == "react" && d.dep_kind == "peer")
292		);
293		assert!(
294			deps.iter()
295				.any(|d| d.name == "fsevents" && d.dep_kind == "optional")
296		);
297	}
298
299	#[test]
300	fn parse_scoped_package_keeps_full_name_in_import_root() {
301		let json = r#"{
302			"name": "demo",
303			"version": "0.1.0",
304			"dependencies": { "@scope/pkg": "1.0.0" }
305		}"#;
306		let deps = parse(json).unwrap();
307		let scoped = deps.iter().find(|d| d.name == "@scope/pkg").unwrap();
308		assert_eq!(scoped.import_root, "@scope/pkg");
309	}
310
311	#[test]
312	fn package_moniker_keeps_scoped_package_head() {
313		let moniker = package_moniker(b"app", "@scope/pkg/sub/path");
314		let segments = moniker.as_view().segments().collect::<Vec<_>>();
315		assert_eq!(segments[0].kind, b"external_pkg");
316		assert_eq!(segments[0].name, b"@scope/pkg");
317		assert_eq!(segments[1].kind, b"path");
318		assert_eq!(segments[1].name, b"sub");
319		assert_eq!(segments[2].kind, b"path");
320		assert_eq!(segments[2].name, b"path");
321	}
322
323	#[test]
324	fn node_prefixed_and_bare_builtins_share_one_sdk_identity() {
325		let prefixed = module_specifier_builder(b"app", "node:fs/promises").build();
326		let bare = module_specifier_builder(b"app", "fs/promises").build();
327
328		assert_eq!(bare, prefixed);
329		let segments = bare.as_view().segments().collect::<Vec<_>>();
330		assert_eq!(
331			segments
332				.iter()
333				.map(|segment| (segment.kind, segment.name))
334				.collect::<Vec<_>>(),
335			vec![
336				(b"sdk".as_slice(), b"ts".as_slice()),
337				(b"path".as_slice(), b"node:fs".as_slice()),
338				(b"path".as_slice(), b"promises".as_slice()),
339			]
340		);
341	}
342
343	#[test]
344	fn ordinary_package_specifier_remains_external() {
345		let package = module_specifier_builder(b"app", "react").build();
346		let first = package.as_view().segments().next().unwrap();
347
348		assert_eq!(first.kind, b"external_pkg");
349		assert_eq!(first.name, b"react");
350	}
351
352	#[test]
353	fn parse_invalid_json_returns_parse_error() {
354		assert!(matches!(
355			parse("{not json"),
356			Err(PackageJsonError::Parse(_))
357		));
358	}
359
360	#[test]
361	fn parse_non_object_top_level_is_schema_error() {
362		assert!(matches!(parse("[1,2,3]"), Err(PackageJsonError::Schema(_))));
363	}
364
365	#[test]
366	fn parse_missing_name_omits_package_row() {
367		let json = r#"{
368			"private": true,
369			"dependencies": { "react": "^18.0.0" }
370		}"#;
371		let deps = parse(json).unwrap();
372		assert!(deps.iter().all(|d| d.dep_kind != "package"));
373		assert!(deps.iter().any(|d| d.name == "react"));
374	}
375}