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
94fn split_package_specifier(spec: &str) -> (&str, &str) {
95 if let Some(after_scope) = spec.strip_prefix('@') {
96 let mut parts = after_scope.splitn(3, '/');
97 let scope = parts.next().unwrap_or("");
98 let name = parts.next().unwrap_or("");
99 let tail = parts.next().unwrap_or("");
100 let head_end = if name.is_empty() {
101 1 + scope.len()
102 } else {
103 1 + scope.len() + 1 + name.len()
104 };
105 (&spec[..head_end], tail)
106 } else {
107 match spec.find('/') {
108 Some(i) => (&spec[..i], &spec[i + 1..]),
109 None => (spec, ""),
110 }
111 }
112}
113
114fn extract_version(spec: &Value) -> Option<String> {
115 match spec {
116 Value::String(s) => Some(s.clone()),
117 Value::Object(o) => o.get("version").and_then(Value::as_str).map(str::to_string),
118 _ => None,
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn parse_minimal_package() {
128 let json = r#"{ "name": "demo", "version": "0.1.0" }"#;
129 let deps = parse(json).unwrap();
130 assert_eq!(
131 deps,
132 vec![Dep {
133 name: "demo".into(),
134 version: Some("0.1.0".into()),
135 dep_kind: "package".into(),
136 import_root: "demo".into(),
137 }]
138 );
139 }
140
141 #[test]
142 fn parse_normal_dep_keeps_version_string() {
143 let json = r#"{
144 "name": "demo",
145 "version": "0.1.0",
146 "dependencies": { "react": "^18.0.0" }
147 }"#;
148 let deps = parse(json).unwrap();
149 assert!(deps.contains(&Dep {
150 name: "react".into(),
151 version: Some("^18.0.0".into()),
152 dep_kind: "normal".into(),
153 import_root: "react".into(),
154 }));
155 }
156
157 #[test]
158 fn parse_object_dep_with_version_field() {
159 let json = r#"{
160 "name": "demo",
161 "version": "0.1.0",
162 "dependencies": { "tsup": { "version": "8.0.0" } }
163 }"#;
164 let deps = parse(json).unwrap();
165 assert!(
166 deps.iter()
167 .any(|d| d.name == "tsup" && d.version.as_deref() == Some("8.0.0"))
168 );
169 }
170
171 #[test]
172 fn parse_dev_peer_optional_kinds() {
173 let json = r#"{
174 "name": "demo",
175 "version": "0.1.0",
176 "devDependencies": { "vitest": "1.0.0" },
177 "peerDependencies": { "react": "^18.0.0" },
178 "optionalDependencies": { "fsevents": "2.0.0" }
179 }"#;
180 let deps = parse(json).unwrap();
181 assert!(
182 deps.iter()
183 .any(|d| d.name == "vitest" && d.dep_kind == "dev")
184 );
185 assert!(
186 deps.iter()
187 .any(|d| d.name == "react" && d.dep_kind == "peer")
188 );
189 assert!(
190 deps.iter()
191 .any(|d| d.name == "fsevents" && d.dep_kind == "optional")
192 );
193 }
194
195 #[test]
196 fn parse_scoped_package_keeps_full_name_in_import_root() {
197 let json = r#"{
198 "name": "demo",
199 "version": "0.1.0",
200 "dependencies": { "@scope/pkg": "1.0.0" }
201 }"#;
202 let deps = parse(json).unwrap();
203 let scoped = deps.iter().find(|d| d.name == "@scope/pkg").unwrap();
204 assert_eq!(scoped.import_root, "@scope/pkg");
205 }
206
207 #[test]
208 fn package_moniker_keeps_scoped_package_head() {
209 let moniker = package_moniker(b"app", "@scope/pkg/sub/path");
210 let segments = moniker.as_view().segments().collect::<Vec<_>>();
211 assert_eq!(segments[0].kind, b"external_pkg");
212 assert_eq!(segments[0].name, b"@scope/pkg");
213 assert_eq!(segments[1].kind, b"path");
214 assert_eq!(segments[1].name, b"sub");
215 assert_eq!(segments[2].kind, b"path");
216 assert_eq!(segments[2].name, b"path");
217 }
218
219 #[test]
220 fn parse_invalid_json_returns_parse_error() {
221 assert!(matches!(
222 parse("{not json"),
223 Err(PackageJsonError::Parse(_))
224 ));
225 }
226
227 #[test]
228 fn parse_non_object_top_level_is_schema_error() {
229 assert!(matches!(parse("[1,2,3]"), Err(PackageJsonError::Schema(_))));
230 }
231
232 #[test]
233 fn parse_missing_name_omits_package_row() {
234 let json = r#"{
235 "private": true,
236 "dependencies": { "react": "^18.0.0" }
237 }"#;
238 let deps = parse(json).unwrap();
239 assert!(deps.iter().all(|d| d.dep_kind != "package"));
240 assert!(deps.iter().any(|d| d.name == "react"));
241 }
242}