Skip to main content

blockworx/schema/
decode.rs

1//! KDL text → [`Document`](super::model::Document), walking the hand-rolled
2//! parser's node tree ([`super::kdl`]). Every diagnostic carries the byte
3//! span of the offending token so `miette` can point right at it.
4//!
5//! Read-only, and quarantined (D14 — see [`super`]): tutorial levels and the
6//! `.kdl` migration door are its only callers, and both go away in Phase 7.
7
8use std::str::FromStr;
9
10use miette::{NamedSource, SourceSpan};
11
12use super::error::SchemaError;
13use super::kdl::{self, Entry, Node, Span, Value};
14use super::model as m;
15
16/// Wire-format id prefixes: an id is a one-letter kind prefix followed by a
17/// decimal number (e.g. `"b13"` for a block, `"p2"` for a pin). The schema owns
18/// this serialization format, so it validates it here without the app's id
19/// newtypes — keeping the module free of any app dependency.
20const RECT_ID_PREFIX: char = 'b';
21const PIN_ID_PREFIX: char = 'p';
22
23/// Whether `s` is a well-formed id for `prefix`: the prefix followed by a
24/// non-empty run of digits parseable as the number the app stores. Mirrors the
25/// app id newtypes' `FromStr` (strip prefix, parse the rest as `usize`).
26fn id_has_form(s: &str, prefix: char) -> bool {
27    s.strip_prefix(prefix)
28        .is_some_and(|digits| digits.parse::<usize>().is_ok())
29}
30
31/// Whether `s` can name an asset. The writer always emits a content hash and an
32/// extension (`9f3a2c81d4e7b026.png`), but the reader takes any filename-safe
33/// token: requiring a hand-author to compute one before they can inline an SVG
34/// would be hostile, and version-1 documents name their assets `i1`. What is
35/// *not* negotiable is that the token stays filename-shaped (no path
36/// separators, no `.`/`..`) — the writer's own ids always are, and a reader
37/// that let anything else through would accept spellings it can never emit.
38fn is_asset_id(s: &str) -> bool {
39    !s.is_empty()
40        && s != "."
41        && s != ".."
42        && s.chars()
43            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
44}
45
46pub fn parse(src: &str, src_name: &str) -> Result<m::Document, SchemaError> {
47    let ctx = Ctx {
48        name: src_name,
49        src,
50    };
51    let nodes = kdl::parse(src).map_err(|e| ctx.err(&e.span, e.message, "here"))?;
52    ctx.document(&nodes)
53}
54
55struct Ctx<'a> {
56    name: &'a str,
57    src: &'a str,
58}
59
60impl Ctx<'_> {
61    fn err(
62        &self,
63        span: &Span,
64        message: impl Into<String>,
65        label: impl Into<String>,
66    ) -> SchemaError {
67        SchemaError::Kdl {
68            message: message.into(),
69            span: SourceSpan::from((span.start, span.len())),
70            label: label.into(),
71            src: NamedSource::new(self.name, self.src.to_string()),
72        }
73    }
74
75    // ── scalar extraction ──────────────────────────────────────────────────
76
77    fn as_string<'e>(&self, e: &'e Entry) -> Result<&'e str, SchemaError> {
78        e.value
79            .as_str()
80            .ok_or_else(|| self.err(&e.span, "expected a string", "not a string"))
81    }
82
83    fn as_i32(&self, e: &Entry) -> Result<i32, SchemaError> {
84        match &e.value {
85            Value::Int(i) => i32::try_from(*i)
86                .map_err(|_| self.err(&e.span, "integer out of range for i32", "too large")),
87            _ => Err(self.err(&e.span, "expected an integer", "not an integer")),
88        }
89    }
90
91    fn as_u32(&self, e: &Entry) -> Result<u32, SchemaError> {
92        match &e.value {
93            Value::Int(i) => u32::try_from(*i)
94                .map_err(|_| self.err(&e.span, "expected a non-negative integer", "out of range")),
95            _ => Err(self.err(&e.span, "expected an integer", "not an integer")),
96        }
97    }
98
99    fn as_u8(&self, e: &Entry) -> Result<u8, SchemaError> {
100        match &e.value {
101            Value::Int(i) => u8::try_from(*i)
102                .map_err(|_| self.err(&e.span, "expected an integer 0..=255", "out of range")),
103            _ => Err(self.err(&e.span, "expected an integer", "not an integer")),
104        }
105    }
106
107    fn as_f32(&self, e: &Entry) -> Result<f32, SchemaError> {
108        match &e.value {
109            Value::Float(f) => Ok(*f as f32),
110            Value::Int(i) => Ok(*i as f32),
111            _ => Err(self.err(&e.span, "expected a number", "not a number")),
112        }
113    }
114
115    fn as_bool(&self, e: &Entry) -> Result<bool, SchemaError> {
116        match &e.value {
117            Value::Bool(b) => Ok(*b),
118            _ => Err(self.err(&e.span, "expected `true` or `false`", "not a boolean")),
119        }
120    }
121
122    fn as_enum<T: FromStr<Err = String>>(&self, e: &Entry) -> Result<T, SchemaError> {
123        let s = self.as_string(e)?;
124        s.parse()
125            .map_err(|reason: String| self.err(&e.span, reason, "invalid value"))
126    }
127
128    // ── node argument / property accessors ─────────────────────────────────
129
130    fn req_arg<'n>(
131        &self,
132        node: &'n Node,
133        idx: usize,
134        what: &str,
135    ) -> Result<&'n Entry, SchemaError> {
136        node.arg(idx)
137            .ok_or_else(|| self.err(&node.name_span, format!("missing {what}"), "here"))
138    }
139
140    fn req_prop<'n>(&self, node: &'n Node, key: &str) -> Result<&'n Entry, SchemaError> {
141        node.prop(key)
142            .map(|p| &p.value)
143            .ok_or_else(|| self.err(&node.name_span, format!("missing `{key}`"), "here"))
144    }
145
146    fn opt_u8(&self, node: &Node, key: &str) -> Result<Option<u8>, SchemaError> {
147        node.prop(key).map(|p| self.as_u8(&p.value)).transpose()
148    }
149
150    fn opt_string(&self, node: &Node, key: &str) -> Result<Option<String>, SchemaError> {
151        node.prop(key)
152            .map(|p| self.as_string(&p.value).map(str::to_string))
153            .transpose()
154    }
155
156    fn opt_bool(&self, node: &Node, key: &str) -> Result<bool, SchemaError> {
157        Ok(match node.prop(key) {
158            Some(p) => self.as_bool(&p.value)?,
159            None => false,
160        })
161    }
162
163    fn opt_enum<T: FromStr<Err = String>>(
164        &self,
165        node: &Node,
166        key: &str,
167    ) -> Result<Option<T>, SchemaError> {
168        node.prop(key).map(|p| self.as_enum(&p.value)).transpose()
169    }
170
171    /// Validate an id-shaped string (e.g. `"b1"`) at `entry`'s span, returning it
172    /// unchanged for storage in the schema. `kind`/`prefix` name the expected form.
173    fn id_str(&self, e: &Entry, kind: &str, prefix: char) -> Result<String, SchemaError> {
174        let s = self.as_string(e)?;
175        if id_has_form(s, prefix) {
176            Ok(s.to_string())
177        } else {
178            Err(self.err(
179                &e.span,
180                format!(
181                    "{kind} id {s:?} must be `{prefix}<N>` (the `{prefix}` prefix and a number)"
182                ),
183                "invalid id",
184            ))
185        }
186    }
187
188    fn asset_id(&self, e: &Entry) -> Result<String, SchemaError> {
189        let s = self.as_string(e)?;
190        if is_asset_id(s) {
191            Ok(s.to_string())
192        } else {
193            Err(self.err(
194                &e.span,
195                format!("asset id {s:?} is not usable as a file name"),
196                "invalid id",
197            ))
198        }
199    }
200
201    fn anchor(&self, e: &Entry) -> Result<String, SchemaError> {
202        let s = self.as_string(e)?;
203        let ok = match s.split_once(':') {
204            Some((b, p)) => id_has_form(b, RECT_ID_PREFIX) && id_has_form(p, PIN_ID_PREFIX),
205            None => id_has_form(s, PIN_ID_PREFIX),
206        };
207        if ok {
208            Ok(s.to_string())
209        } else {
210            Err(self.err(
211                &e.span,
212                format!("route anchor {s:?} must be `p<N>` (a port) or `b<N>:p<M>` (a child pin)"),
213                "invalid anchor",
214            ))
215        }
216    }
217
218    // ── document ───────────────────────────────────────────────────────────
219
220    fn document(&self, nodes: &[Node]) -> Result<m::Document, SchemaError> {
221        // Ahead of everything else: a document from a newer blockworx may use
222        // syntax this build would otherwise reject with a confusing complaint
223        // about the token rather than about the version.
224        let version = self.version(nodes)?;
225
226        let mut name: Option<String> = None;
227        let mut top: Option<String> = None;
228        let mut blocks = Vec::new();
229        let mut assets: Vec<m::Asset> = Vec::new();
230        for node in nodes {
231            match node.name.as_str() {
232                "version" => {}
233                "name" => {
234                    let e = self.req_arg(node, 0, "the document name")?;
235                    name = Some(self.as_string(e)?.to_string());
236                }
237                "top" => {
238                    let e = self.req_arg(node, 0, "the top block id")?;
239                    top = Some(self.id_str(e, "block", RECT_ID_PREFIX)?);
240                }
241                "block" => blocks.push(self.block(node)?),
242                "asset" => {
243                    let asset = self.asset(node)?;
244                    if assets.iter().any(|a| a.id == asset.id) {
245                        return Err(self.err(
246                            &node.name_span,
247                            format!("duplicate asset id {:?}", asset.id),
248                            "already defined",
249                        ));
250                    }
251                    assets.push(asset);
252                }
253                other => {
254                    return Err(self.err(
255                        &node.name_span,
256                        format!(
257                            "unexpected top-level node `{other}` \
258                             (expected `version`, `name`, `top`, `block` or `asset`)"
259                        ),
260                        "unexpected node",
261                    ));
262                }
263            }
264        }
265        let top = top.ok_or_else(|| self.err(&(0..0), "missing `top` block id", "here"))?;
266        Ok(m::Document {
267            version,
268            name,
269            top,
270            blocks,
271            // The retired format has no root scope: its top level is blocks
272            // and assets, so nothing it can spell lands here.
273            routes: Vec::new(),
274            texts: Vec::new(),
275            areas: Vec::new(),
276            images: Vec::new(),
277            assets,
278        })
279    }
280
281    /// The document's declared format version, refusing one this build cannot
282    /// read. Absent means a document written before the node existed, which is
283    /// version 1 — the node became explicit without the format changing.
284    fn version(&self, nodes: &[Node]) -> Result<u32, SchemaError> {
285        let Some(node) = nodes.iter().find(|n| n.name == "version") else {
286            return Ok(m::pre_versioning());
287        };
288        let e = self.req_arg(node, 0, "the format version")?;
289        let version = self.as_u32(e)?;
290        match m::unsupported_version(version) {
291            Some(why) => Err(self.err(&e.span, why, "written by a newer blockworx")),
292            None => Ok(version),
293        }
294    }
295
296    fn block(&self, node: &Node) -> Result<m::Block, SchemaError> {
297        let id = self.id_str(
298            self.req_arg(node, 0, "the block id")?,
299            "block",
300            RECT_ID_PREFIX,
301        )?;
302        let mut b = m::Block {
303            id,
304            x: self.as_i32(self.req_prop(node, "x")?)?,
305            y: self.as_i32(self.req_prop(node, "y")?)?,
306            w: self.as_u32(self.req_prop(node, "w")?)?,
307            h: self.as_u32(self.req_prop(node, "h")?)?,
308            role: self.opt_u8(node, "role")?,
309            locked: self.opt_bool(node, "locked")?,
310            title: None,
311            type_label: None,
312            pins: Vec::new(),
313            routes: Vec::new(),
314            texts: Vec::new(),
315            areas: Vec::new(),
316            images: Vec::new(),
317            icon: None,
318            children: Vec::new(),
319        };
320        for child in &node.children {
321            match child.name.as_str() {
322                "title" => b.title = Some(self.label(child)?),
323                "type" => b.type_label = Some(self.label(child)?),
324                "pin" => b.pins.push(self.pin(child)?),
325                "route" => b.routes.push(self.route(child)?),
326                "text" => b.texts.push(self.text(child)?),
327                // `comment` is the pre-rename keyword, still accepted so old
328                // `.kdl` files and tutorial embeds import.
329                "area" | "comment" => b.areas.push(self.area(child)?),
330                "image" => b.images.push(self.image(child)?),
331                "icon" => b.icon = Some(self.image(child)?),
332                "children" => {
333                    for e in &child.args {
334                        b.children.push(self.id_str(e, "block", RECT_ID_PREFIX)?);
335                    }
336                }
337                other => {
338                    return Err(self.err(
339                        &child.name_span,
340                        format!("unexpected node `{other}` in a block"),
341                        "unexpected node",
342                    ));
343                }
344            }
345        }
346        Ok(b)
347    }
348
349    fn label(&self, node: &Node) -> Result<m::Label, SchemaError> {
350        Ok(m::Label {
351            name: match node.arg(0) {
352                Some(e) => self.as_string(e)?.to_string(),
353                None => String::new(),
354            },
355            side: self.opt_enum(node, "side")?,
356            offset: match node.prop("offset") {
357                Some(p) => self.as_f32(&p.value)?,
358                None => 0.0,
359            },
360            hidden: self.opt_bool(node, "hidden")?,
361        })
362    }
363
364    fn pin(&self, node: &Node) -> Result<m::Pin, SchemaError> {
365        let id = self.id_str(self.req_arg(node, 0, "the pin id")?, "pin", PIN_ID_PREFIX)?;
366        let loc_entry = self.req_prop(node, "loc")?;
367        let loc_str = self.as_string(loc_entry)?;
368        super::loc::parse_loc(loc_str)
369            .map_err(|reason| self.err(&loc_entry.span, reason, "invalid loc"))?;
370        let loc = Some(loc_str.to_string());
371        Ok(m::Pin {
372            id,
373            name: match node.arg(1) {
374                Some(e) => self.as_string(e)?.to_string(),
375                None => String::new(),
376            },
377            type_label: self.opt_string(node, "type")?.unwrap_or_default(),
378            tag: self.opt_string(node, "tag")?.unwrap_or_default(),
379            tag_hidden: self.opt_bool(node, "tag-hidden")?,
380            loc,
381            x: node.prop("x").map(|p| self.as_i32(&p.value)).transpose()?,
382            y: node.prop("y").map(|p| self.as_i32(&p.value)).transpose()?,
383            w: node.prop("w").map(|p| self.as_u32(&p.value)).transpose()?,
384            dir: self.opt_enum(node, "dir")?,
385            pin_accent: self.opt_u8(node, "pin-accent")?,
386            port_accent: self.opt_u8(node, "port-accent")?,
387            port_pin_accent: self.opt_u8(node, "port-pin-accent")?,
388            fliplr: self.opt_bool(node, "fliplr")?,
389        })
390    }
391
392    fn route(&self, node: &Node) -> Result<m::Route, SchemaError> {
393        let from = self.anchor(self.req_arg(node, 0, "the route's `from` anchor")?)?;
394        let to = self.anchor(self.req_arg(node, 1, "the route's `to` anchor")?)?;
395        let mut waypoints = Vec::new();
396        let mut labels = Vec::new();
397        for child in &node.children {
398            match child.name.as_str() {
399                "wp" => waypoints.push(m::Waypoint {
400                    x: self.as_i32(self.req_arg(child, 0, "the waypoint x")?)?,
401                    y: self.as_i32(self.req_arg(child, 1, "the waypoint y")?)?,
402                    locked: self.opt_bool(child, "locked")?,
403                }),
404                "label" => {
405                    labels.push(self.as_f32(self.req_arg(child, 0, "the label position")?)?);
406                }
407                other => {
408                    return Err(self.err(
409                        &child.name_span,
410                        format!("unexpected node `{other}` in a route"),
411                        "unexpected node",
412                    ));
413                }
414            }
415        }
416        Ok(m::Route {
417            name: self.opt_string(node, "name")?.unwrap_or_default(),
418            from,
419            to,
420            role: self.opt_u8(node, "role")?,
421            waypoints,
422            labels,
423        })
424    }
425
426    fn text(&self, node: &Node) -> Result<m::Text, SchemaError> {
427        Ok(m::Text {
428            text: self
429                .as_string(self.req_arg(node, 0, "the text content")?)?
430                .to_string(),
431            x: self.opt_i32(node, "x")?,
432            y: self.opt_i32(node, "y")?,
433            role: self.opt_u8(node, "role")?,
434        })
435    }
436
437    fn area(&self, node: &Node) -> Result<m::Area, SchemaError> {
438        Ok(m::Area {
439            x: self.as_i32(self.req_prop(node, "x")?)?,
440            y: self.as_i32(self.req_prop(node, "y")?)?,
441            w: self.as_u32(self.req_prop(node, "w")?)?,
442            h: self.as_u32(self.req_prop(node, "h")?)?,
443            role: self.opt_u8(node, "role")?,
444            title: node.child("title").map(|c| self.label(c)).transpose()?,
445        })
446    }
447
448    /// An image placement (`image`/`icon`): the asset it draws, and the box it
449    /// fills.
450    fn image(&self, node: &Node) -> Result<m::Image, SchemaError> {
451        if let Some(inline) = node.child("svg").or_else(|| node.child("png")) {
452            return Err(self.err(
453                &inline.name_span,
454                "inline image data is no longer supported",
455                "move it to a top-level `asset` node and reference it by id",
456            ));
457        }
458        Ok(m::Image {
459            asset: self.asset_id(self.req_arg(node, 0, "the asset id")?)?,
460            x: self.as_f32(self.req_prop(node, "x")?)?,
461            y: self.as_f32(self.req_prop(node, "y")?)?,
462            w: self.as_f32(self.req_prop(node, "w")?)?,
463            h: self.as_f32(self.req_prop(node, "h")?)?,
464        })
465    }
466
467    /// An image asset: its id and its `svg`/`png` content.
468    fn asset(&self, node: &Node) -> Result<m::Asset, SchemaError> {
469        let id = self.asset_id(self.req_arg(node, 0, "the asset id")?)?;
470        let image = match (node.child("svg"), node.child("png")) {
471            (Some(_), Some(png)) => {
472                return Err(self.err(
473                    &png.name_span,
474                    "an asset has both `svg` and `png`",
475                    "remove one",
476                ));
477            }
478            (Some(svg), None) => m::ImageData::Svg(
479                self.as_string(self.req_arg(svg, 0, "the svg source")?)?
480                    .to_string(),
481            ),
482            (None, Some(png)) => m::ImageData::Png(
483                self.as_string(self.req_arg(png, 0, "the png data")?)?
484                    .to_string(),
485            ),
486            (None, None) => {
487                return Err(self.err(
488                    &node.name_span,
489                    "an asset needs an `svg` or `png` child",
490                    "here",
491                ));
492            }
493        };
494        Ok(m::Asset { id, image })
495    }
496
497    fn opt_i32(&self, node: &Node, key: &str) -> Result<i32, SchemaError> {
498        Ok(match node.prop(key) {
499            Some(p) => self.as_i32(&p.value)?,
500            None => 0,
501        })
502    }
503}