code_moniker_core/declare/
serialize.rs1use serde_json::{Value, json};
2
3use super::{EdgeKind, Lang};
4use crate::core::code_graph::CodeGraph;
5use crate::core::kinds::{REF_CALLS, REF_DI_REGISTER, REF_DI_REQUIRE, REF_IMPORTS_MODULE};
6use crate::core::moniker::Moniker;
7use crate::core::uri::{UriConfig, to_uri};
8
9#[derive(Clone, Debug, Eq, PartialEq)]
10pub enum SerializeError {
11 RootHasNoLangSegment {
12 root: String,
13 },
14 UnknownLangSegment {
15 lang: String,
16 },
17 LangMismatch {
18 expected: &'static str,
19 actual: String,
20 },
21 UriRender {
22 reason: String,
23 },
24 Utf8 {
25 what: &'static str,
26 },
27}
28
29impl std::fmt::Display for SerializeError {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 match self {
32 Self::RootHasNoLangSegment { root } => write!(
33 f,
34 "graph root `{root}` has no `lang:` segment ; cannot infer the spec's `lang` field"
35 ),
36 Self::UnknownLangSegment { lang } => write!(
37 f,
38 "graph root carries `lang:{lang}` which is not a recognised declarative profile"
39 ),
40 Self::LangMismatch { expected, actual } => write!(
41 f,
42 "graph root carries `lang:{actual}` which does not match the typed extractor's `{expected}` (use the dynamic-dispatch entry point if you do not know the language ahead of time)"
43 ),
44 Self::UriRender { reason } => write!(f, "moniker URI render error: {reason}"),
45 Self::Utf8 { what } => write!(f, "{what} contains non-UTF-8 bytes"),
46 }
47 }
48}
49
50impl std::error::Error for SerializeError {}
51
52pub fn graph_to_spec(graph: &CodeGraph) -> Result<Value, SerializeError> {
53 let root = graph.root();
54 let lang = lang_from_root(root)?;
55 let cfg = UriConfig::default();
56 let defs: Vec<&_> = graph.defs().collect();
57
58 let mut symbols: Vec<Value> = Vec::with_capacity(defs.len().saturating_sub(1));
59 for (i, d) in defs.iter().enumerate() {
60 if i == 0 {
61 continue;
62 }
63 let parent_moniker = defs
64 .get(d.parent.unwrap_or(0))
65 .map(|p| &p.moniker)
66 .unwrap_or(root);
67 let mut sym = serde_json::Map::with_capacity(5);
68 sym.insert(
69 "moniker".to_string(),
70 Value::String(render(&d.moniker, &cfg)?),
71 );
72 sym.insert(
73 "kind".to_string(),
74 Value::String(utf8(&d.kind, "def kind")?.to_string()),
75 );
76 sym.insert(
77 "parent".to_string(),
78 Value::String(render(parent_moniker, &cfg)?),
79 );
80 if !d.visibility.is_empty() {
81 sym.insert(
82 "visibility".to_string(),
83 Value::String(utf8(&d.visibility, "def visibility")?.to_string()),
84 );
85 }
86 if !d.signature.is_empty() {
87 sym.insert(
88 "signature".to_string(),
89 Value::String(utf8(&d.signature, "def signature")?.to_string()),
90 );
91 }
92 symbols.push(Value::Object(sym));
93 }
94
95 let mut edges: Vec<Value> = Vec::with_capacity(graph.ref_count());
96 for r in graph.refs() {
97 let Some(canonical) = lift_ref_kind(&r.kind) else {
98 continue;
99 };
100 let from_moniker = &defs
101 .get(r.source)
102 .ok_or_else(|| SerializeError::UriRender {
103 reason: format!("ref source index {} out of bounds", r.source),
104 })?
105 .moniker;
106 edges.push(json!({
107 "from": render(from_moniker, &cfg)?,
108 "kind": canonical.tag(),
109 "to": render(&r.target, &cfg)?,
110 }));
111 }
112
113 Ok(json!({
114 "root": render(root, &cfg)?,
115 "lang": lang.tag(),
116 "symbols": symbols,
117 "edges": edges,
118 }))
119}
120
121fn lang_from_root(root: &Moniker) -> Result<Lang, SerializeError> {
122 let cfg = UriConfig::default();
123 let view = root.as_view();
124 let lang_bytes = view
125 .lang_segment()
126 .ok_or_else(|| SerializeError::RootHasNoLangSegment {
127 root: render(root, &cfg).unwrap_or_else(|_| "<unrenderable>".to_string()),
128 })?;
129 let lang_str = std::str::from_utf8(lang_bytes).map_err(|_| SerializeError::Utf8 {
130 what: "lang segment",
131 })?;
132 Lang::from_tag(lang_str).ok_or_else(|| SerializeError::UnknownLangSegment {
133 lang: lang_str.to_string(),
134 })
135}
136
137fn lift_ref_kind(kind: &[u8]) -> Option<EdgeKind> {
138 match kind {
139 k if k == REF_IMPORTS_MODULE => Some(EdgeKind::DependsOn),
140 k if k == REF_CALLS => Some(EdgeKind::Calls),
141 k if k == REF_DI_REGISTER => Some(EdgeKind::InjectsProvide),
142 k if k == REF_DI_REQUIRE => Some(EdgeKind::InjectsRequire),
143 _ => None,
144 }
145}
146
147fn render(m: &Moniker, cfg: &UriConfig<'_>) -> Result<String, SerializeError> {
148 to_uri(m, cfg).map_err(|e| SerializeError::UriRender {
149 reason: e.to_string(),
150 })
151}
152
153fn utf8<'a>(bytes: &'a [u8], what: &'static str) -> Result<&'a str, SerializeError> {
154 std::str::from_utf8(bytes).map_err(|_| SerializeError::Utf8 { what })
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use crate::declare::{declare_from_json_value, parse_spec};
161 use serde_json::json;
162
163 fn round_trip(input: Value) -> Value {
164 let g = declare_from_json_value(&input).unwrap();
165 graph_to_spec(&g).unwrap()
166 }
167
168 #[test]
169 fn lang_field_is_inferred_from_root_lang_segment() {
170 let v = json!({
171 "root": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo",
172 "lang": "java",
173 "symbols": []
174 });
175 let out = round_trip(v);
176 assert_eq!(out.get("lang").unwrap().as_str().unwrap(), "java");
177 }
178
179 #[test]
180 fn root_field_is_preserved() {
181 let root = "code+moniker://app/srcset:main/lang:java/package:com/module:Foo";
182 let v = json!({
183 "root": root,
184 "lang": "java",
185 "symbols": []
186 });
187 let out = round_trip(v);
188 assert_eq!(out.get("root").unwrap().as_str().unwrap(), root);
189 }
190
191 #[test]
192 fn symbols_are_emitted_for_each_non_root_def() {
193 let v = json!({
194 "root": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo",
195 "lang": "java",
196 "symbols": [
197 {
198 "moniker": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo/class:Foo",
199 "kind": "class",
200 "parent": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo",
201 "visibility": "public"
202 },
203 {
204 "moniker": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo/class:Foo/method:bar()",
205 "kind": "method",
206 "parent": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo/class:Foo",
207 "visibility": "public",
208 "signature": "bar(): void"
209 }
210 ]
211 });
212 let out = round_trip(v);
213 let symbols = out.get("symbols").unwrap().as_array().unwrap();
214 assert_eq!(symbols.len(), 2);
215 }
216
217 #[test]
218 fn edges_lift_canonical_kinds() {
219 let v = json!({
220 "root": "code+moniker://app/srcset:main/lang:rs/module:svc",
221 "lang": "rs",
222 "symbols": [{
223 "moniker": "code+moniker://app/srcset:main/lang:rs/module:svc/fn:f()",
224 "kind": "fn",
225 "parent": "code+moniker://app/srcset:main/lang:rs/module:svc"
226 }],
227 "edges": [
228 { "from": "code+moniker://app/srcset:main/lang:rs/module:svc/fn:f()",
229 "kind": "depends_on",
230 "to": "code+moniker://app/external_pkg:cargo/path:serde" },
231 { "from": "code+moniker://app/srcset:main/lang:rs/module:svc/fn:f()",
232 "kind": "calls",
233 "to": "code+moniker://app/srcset:main/lang:rs/module:other/fn:g()" },
234 { "from": "code+moniker://app/srcset:main/lang:rs/module:svc/fn:f()",
235 "kind": "injects:provide",
236 "to": "code+moniker://app/srcset:main/lang:rs/module:di/trait:Repo" },
237 { "from": "code+moniker://app/srcset:main/lang:rs/module:svc/fn:f()",
238 "kind": "injects:require",
239 "to": "code+moniker://app/srcset:main/lang:rs/module:di/trait:Bus" }
240 ]
241 });
242 let out = round_trip(v);
243 let edges = out.get("edges").unwrap().as_array().unwrap();
244 assert_eq!(edges.len(), 4);
245 let kinds: Vec<&str> = edges
246 .iter()
247 .map(|e| e.get("kind").unwrap().as_str().unwrap())
248 .collect();
249 assert!(kinds.contains(&"depends_on"));
250 assert!(kinds.contains(&"calls"));
251 assert!(kinds.contains(&"injects:provide"));
252 assert!(kinds.contains(&"injects:require"));
253 }
254
255 #[test]
256 fn non_canonical_ref_kinds_are_dropped() {
257 use crate::core::code_graph::CodeGraph;
258 use crate::core::moniker::MonikerBuilder;
259 let root = MonikerBuilder::new()
260 .project(b"app")
261 .segment(b"srcset", b"main")
262 .segment(b"lang", b"rs")
263 .segment(b"module", b"svc")
264 .build();
265 let foo = MonikerBuilder::from_view(root.as_view())
266 .segment(b"fn", b"f()")
267 .build();
268 let mut g = CodeGraph::new(root.clone(), b"module");
269 g.add_def(foo.clone(), b"fn", &root, None).unwrap();
270 g.add_ref(
271 &foo,
272 MonikerBuilder::new()
273 .project(b"app")
274 .segment(b"srcset", b"main")
275 .segment(b"lang", b"rs")
276 .segment(b"module", b"svc")
277 .segment(b"struct", b"X")
278 .build(),
279 b"uses_type",
280 None,
281 )
282 .unwrap();
283
284 let out = graph_to_spec(&g).unwrap();
285 let edges = out.get("edges").unwrap().as_array().unwrap();
286 assert!(edges.is_empty(), "non-canonical refs should be dropped");
287 }
288
289 #[test]
290 fn errors_when_root_has_no_lang_segment() {
291 use crate::core::code_graph::CodeGraph;
293 use crate::core::moniker::MonikerBuilder;
294 let root = MonikerBuilder::new()
295 .project(b"app")
296 .segment(b"srcset", b"main")
297 .build();
298 let g = CodeGraph::new(root, b"srcset");
299 let err = graph_to_spec(&g).unwrap_err();
300 assert!(matches!(err, SerializeError::RootHasNoLangSegment { .. }));
301 }
302
303 #[test]
304 fn round_trip_preserves_structure() {
305 let original = json!({
306 "root": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo",
307 "lang": "java",
308 "symbols": [
309 {
310 "moniker": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo/class:Foo",
311 "kind": "class",
312 "parent": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo",
313 "visibility": "public"
314 },
315 {
316 "moniker": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo/class:Foo/method:bar()",
317 "kind": "method",
318 "parent": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo/class:Foo",
319 "visibility": "public"
320 }
321 ],
322 "edges": [
323 {
324 "from": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo/class:Foo/method:bar()",
325 "kind": "calls",
326 "to": "code+moniker://app/srcset:main/lang:java/package:com/module:Other/class:Other/method:baz()"
327 }
328 ]
329 });
330 let g1 = declare_from_json_value(&original).unwrap();
331 let spec1 = graph_to_spec(&g1).unwrap();
332 let _ = parse_spec(&spec1).unwrap();
334 let g2 = declare_from_json_value(&spec1).unwrap();
335 let spec2 = graph_to_spec(&g2).unwrap();
336 assert_eq!(spec1, spec2);
338 }
339
340 #[test]
341 fn declared_origin_preserved_after_round_trip() {
342 let v = json!({
343 "root": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo",
344 "lang": "java",
345 "symbols": [{
346 "moniker": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo/class:Foo",
347 "kind": "class",
348 "parent": "code+moniker://app/srcset:main/lang:java/package:com/module:Foo",
349 "visibility": "public"
350 }]
351 });
352 let g1 = declare_from_json_value(&v).unwrap();
353 let spec = graph_to_spec(&g1).unwrap();
354 let g2 = declare_from_json_value(&spec).unwrap();
355 let class_def = g2.defs().nth(1).unwrap();
357 assert_eq!(
358 class_def.origin,
359 crate::core::kinds::ORIGIN_DECLARED.to_vec()
360 );
361 }
362}