code_moniker_core/lang/ts/
build.rs1use 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}