zenith-core 0.0.6

Zenith core: KDL parser adapter, semantic AST, canonical formatter, tokens, validation, and diagnostics.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! Document-level validation passes and orchestration helpers.
//!
//! These are the cohesive helpers the [`validate`](super::driver::validate)
//! driver calls: id collection and registration, footnote-ref resolution, the
//! per-declaration checks for assets/libraries/provenance, and the styles
//! block. `register_id` is re-exported from the check module root because the
//! node submodules call it via `crate::validate::check::register_id`.

use std::collections::{BTreeMap, BTreeSet};

use crate::ast::asset::{AssetDecl, AssetKind};
use crate::ast::library::LibraryDef;
use crate::ast::provenance::ProvenanceDef;
use crate::ast::style::StyleBlock;
use crate::ast::value::PropertyValue;
use crate::diagnostics::Diagnostic;
use crate::tokens::ResolvedToken;

use super::visual::{VisualExpect, check_visual_prop};

/// Recursively collect the LOCAL ids of every id-bearing node in `children`
/// (descending into `group`/`frame`/`instance` containers) into `out`.
///
/// Used to build the per-component descendant-id set so an override `ref` can be
/// checked against the real local ids. Mirrors the container recursion used by
/// the node walk; `Instance` and `Unknown` ids are included where present.
pub(in crate::validate::check) fn collect_local_ids(
    children: &[crate::ast::node::Node],
    out: &mut BTreeSet<String>,
) {
    use crate::ast::node::Node;
    for child in children {
        match child {
            Node::Rect(n) => {
                out.insert(n.id.clone());
            }
            Node::Ellipse(n) => {
                out.insert(n.id.clone());
            }
            Node::Line(n) => {
                out.insert(n.id.clone());
            }
            Node::Text(n) => {
                out.insert(n.id.clone());
            }
            Node::Code(n) => {
                out.insert(n.id.clone());
            }
            Node::Image(n) => {
                out.insert(n.id.clone());
            }
            Node::Polygon(n) => {
                out.insert(n.id.clone());
            }
            Node::Polyline(n) => {
                out.insert(n.id.clone());
            }
            Node::Frame(n) => {
                out.insert(n.id.clone());
                collect_local_ids(&n.children, out);
            }
            Node::Group(n) => {
                out.insert(n.id.clone());
                collect_local_ids(&n.children, out);
            }
            Node::Instance(n) => {
                out.insert(n.id.clone());
            }
            Node::Field(n) => {
                out.insert(n.id.clone());
            }
            Node::Toc(n) => {
                out.insert(n.id.clone());
            }
            Node::Footnote(n) => {
                out.insert(n.id.clone());
            }
            Node::Table(n) => {
                out.insert(n.id.clone());
                for row in &n.rows {
                    for cell in &row.cells {
                        collect_local_ids(&cell.children, out);
                    }
                }
            }
            Node::Shape(n) => {
                out.insert(n.id.clone());
            }
            Node::Connector(n) => {
                out.insert(n.id.clone());
            }
            Node::Pattern(n) => {
                out.insert(n.id.clone());
            }
            Node::Chart(n) => {
                out.insert(n.id.clone());
            }
            Node::Unknown(_) => {}
        }
    }
}

/// Check every text span's `footnote-ref` on `page` against the page's set of
/// footnote ids (the ids of the `footnote` DIRECT children of the page).
///
/// A span whose `footnote-ref` names no footnote on this page → Warning
/// `footnote.unresolved_ref`. Footnotes are page-level furniture (only direct
/// page children count); spans are searched in every text node, descending into
/// `frame`/`group` containers in source order (deterministic).
pub(in crate::validate::check) fn check_footnote_refs(
    page: &crate::ast::document::Page,
    diagnostics: &mut Vec<Diagnostic>,
) {
    use crate::ast::node::Node;

    // Page-local footnote ids (direct children only).
    let mut footnote_ids: BTreeSet<&str> = BTreeSet::new();
    for child in &page.children {
        if let Node::Footnote(fnote) = child {
            footnote_ids.insert(fnote.id.as_str());
        }
    }

    // Cross-check every `footnote_ref`-bearing span on a node (text labels and
    // shape labels both carry `Vec<TextSpan>`) against the page's footnote ids.
    fn check_spans(
        kind: &str,
        node_id: &str,
        spans: &[crate::ast::node::TextSpan],
        source_span: Option<crate::ast::Span>,
        footnote_ids: &BTreeSet<&str>,
        diagnostics: &mut Vec<Diagnostic>,
    ) {
        for span in spans {
            if let Some(fref) = &span.footnote_ref
                && !footnote_ids.contains(fref.as_str())
            {
                diagnostics.push(Diagnostic::warning(
                    "footnote.unresolved_ref",
                    format!(
                        "{kind} '{node_id}': span footnote-ref '{fref}' matches no footnote \
                         on this page"
                    ),
                    source_span,
                    Some(node_id.to_owned()),
                ));
            }
        }
    }

    fn walk(
        children: &[crate::ast::node::Node],
        footnote_ids: &BTreeSet<&str>,
        diagnostics: &mut Vec<Diagnostic>,
    ) {
        use crate::ast::node::Node;
        for child in children {
            match child {
                Node::Text(t) => check_spans(
                    "text",
                    &t.id,
                    &t.spans,
                    t.source_span,
                    footnote_ids,
                    diagnostics,
                ),
                Node::Shape(s) => check_spans(
                    "shape",
                    &s.id,
                    &s.spans,
                    s.source_span,
                    footnote_ids,
                    diagnostics,
                ),
                Node::Frame(f) => walk(&f.children, footnote_ids, diagnostics),
                Node::Group(g) => walk(&g.children, footnote_ids, diagnostics),
                Node::Table(t) => {
                    for row in &t.rows {
                        for cell in &row.cells {
                            walk(&cell.children, footnote_ids, diagnostics);
                        }
                    }
                }
                Node::Rect(_)
                | Node::Ellipse(_)
                | Node::Line(_)
                | Node::Code(_)
                | Node::Image(_)
                | Node::Polygon(_)
                | Node::Polyline(_)
                | Node::Instance(_)
                | Node::Field(_)
                | Node::Toc(_)
                | Node::Footnote(_)
                | Node::Connector(_)
                | Node::Pattern(_)
                | Node::Chart(_)
                | Node::Unknown(_) => {}
            }
        }
    }

    walk(&page.children, &footnote_ids, diagnostics);
}

/// Register a single id; push `id.duplicate` if already seen.
///
/// Used for tokens, styles, body, pages, and all node kinds — any id-bearing
/// element in the document participates in the same global uniqueness check.
pub(in crate::validate::check) fn register_id(
    id: &str,
    seen: &mut BTreeSet<String>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    if !seen.insert(id.to_owned()) {
        diagnostics.push(Diagnostic::error(
            "id.duplicate",
            format!(
                "id '{}' is declared more than once; IDs must be globally unique",
                id
            ),
            None,
            Some(id.to_owned()),
        ));
    }
}

/// Validate a single [`AssetDecl`] beyond ID uniqueness:
/// - unknown kind → `asset.invalid_kind` (Error)
/// - unsafe src path → `asset.invalid_src` (Error)
/// - unknown properties → `asset.unknown_property` (Warning)
pub(in crate::validate::check) fn validate_asset_decl(
    decl: &AssetDecl,
    diagnostics: &mut Vec<Diagnostic>,
) {
    // ── Kind check ────────────────────────────────────────────────────────
    if let AssetKind::Unknown(unknown_kind) = &decl.kind {
        diagnostics.push(Diagnostic::error(
            "asset.invalid_kind",
            format!(
                "asset '{}': unknown kind '{}'; \
                 recognized kinds are: image, svg, font",
                decl.id, unknown_kind
            ),
            decl.source_span,
            Some(decl.id.clone()),
        ));
    }

    // ── Src sanity check ──────────────────────────────────────────────────
    // Reject: absolute paths (starts with `/` or Windows drive `X:\`),
    // parent-traversal segments (`..`), and URLs (contain `://`).
    let src = &decl.src;
    let is_absolute_unix = src.starts_with('/');
    // Windows drive: one ASCII letter followed by `:\` or `:/`
    let is_absolute_windows = src.len() >= 3
        && src.as_bytes()[0].is_ascii_alphabetic()
        && src.as_bytes()[1] == b':'
        && (src.as_bytes()[2] == b'\\' || src.as_bytes()[2] == b'/');
    let is_url = src.contains("://");
    // Parent traversal: segment `..` in any position.
    let has_traversal = src == ".."
        || src.starts_with("../")
        || src.starts_with("..\\")
        || src.contains("/../")
        || src.contains("/..\\")
        || src.contains("\\..\\")
        || src.contains("\\../")
        || src.ends_with("/..")
        || src.ends_with("\\..");

    if is_absolute_unix || is_absolute_windows || is_url || has_traversal {
        diagnostics.push(Diagnostic::error(
            "asset.invalid_src",
            format!(
                "asset '{}': src '{}' is not a safe relative path; \
                 absolute paths, parent-traversal segments ('..'), \
                 and URLs are not allowed",
                decl.id, src
            ),
            decl.source_span,
            Some(decl.id.clone()),
        ));
    }

    // ── Unknown properties ────────────────────────────────────────────────
    for prop_name in decl.unknown_props.keys() {
        diagnostics.push(Diagnostic::warning(
            "asset.unknown_property",
            format!(
                "asset '{}': unknown property '{}' (version-relative; \
                 may be valid in a later schema version)",
                decl.id, prop_name
            ),
            decl.source_span,
            Some(decl.id.clone()),
        ));
    }
}

/// Validate a single [`LibraryDef`] beyond ID uniqueness:
/// - unknown properties → `library.unknown_property` (Warning)
///
/// `version`/`hash` are free-form strings in v0 (a lockfile/external tool owns
/// their format), so no format enforcement is performed here.
pub(in crate::validate::check) fn validate_library_decl(
    decl: &LibraryDef,
    diagnostics: &mut Vec<Diagnostic>,
) {
    for prop_name in decl.unknown_props.keys() {
        diagnostics.push(Diagnostic::warning(
            "library.unknown_property",
            format!(
                "library '{}': unknown property '{}' (version-relative; \
                 may be valid in a later schema version)",
                decl.id, prop_name
            ),
            decl.source_span,
            Some(decl.id.clone()),
        ));
    }
}

/// Validate a single [`ProvenanceDef`] beyond ID uniqueness:
/// - `node` must reference an existing document node OR a declared token OR a
///   declared action → `provenance.unknown_node` (Error). A provenance record
///   links a LOCAL target (a node, a token imported from a library, or a
///   declared action) back to its origin, so declared token and action ids are
///   accepted targets. Mirrors `master.unknown_reference`.
/// - `library` must reference a library declared in the `libraries` block →
///   `provenance.unknown_library` (Error).
/// - unknown properties → `provenance.unknown_property` (Warning).
pub(in crate::validate::check) fn validate_provenance_def(
    prov: &ProvenanceDef,
    all_node_ids: &BTreeSet<String>,
    declared_token_ids: &BTreeSet<String>,
    declared_action_ids: &BTreeSet<String>,
    declared_library_ids: &BTreeSet<String>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    if !all_node_ids.contains(&prov.node)
        && !declared_token_ids.contains(&prov.node)
        && !declared_action_ids.contains(&prov.node)
    {
        diagnostics.push(Diagnostic::error(
            "provenance.unknown_node",
            format!(
                "provenance '{}': references node, token, or action '{}' which does not exist",
                prov.id, prov.node
            ),
            prov.source_span,
            Some(prov.id.clone()),
        ));
    }
    if !declared_library_ids.contains(&prov.library) {
        diagnostics.push(Diagnostic::error(
            "provenance.unknown_library",
            format!(
                "provenance '{}': references library '{}' which is not declared in the \
                 libraries block",
                prov.id, prov.library
            ),
            prov.source_span,
            Some(prov.id.clone()),
        ));
    }
    for prop_name in prov.unknown_props.keys() {
        diagnostics.push(Diagnostic::warning(
            "provenance.unknown_property",
            format!(
                "provenance '{}': unknown property '{}' (version-relative; \
                 may be valid in a later schema version)",
                prov.id, prop_name
            ),
            prov.source_span,
            Some(prov.id.clone()),
        ));
    }
}

// ── Style helpers ─────────────────────────────────────────────────────────────

/// Validate the contents of the `styles` block:
/// - Each `(key, value)` in `Style.properties` is type-checked against the
///   expected token category and tracked as a token reference.
/// - Each entry in `Style.unknown_props` produces a `style.unknown_property`
///   Warning.
pub(in crate::validate::check) fn validate_style_block(
    block: &StyleBlock,
    resolved_tokens: &BTreeMap<String, ResolvedToken>,
    referenced_token_ids: &mut BTreeSet<String>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    for style in &block.styles {
        // Check recognized properties.
        for (key, value) in &style.properties {
            let expect = style_prop_expect(key);
            if let Some(expect) = expect {
                check_visual_prop(
                    &style.id,
                    key,
                    Some(value),
                    expect,
                    referenced_token_ids,
                    resolved_tokens,
                    diagnostics,
                );
            } else {
                // stroke-alignment and font-weight: no strict type check;
                // still track token refs so they count as used.
                if let PropertyValue::TokenRef(tid) = value {
                    referenced_token_ids.insert(tid.clone());
                }
            }
        }

        // Warn on unknown properties.
        for prop_name in style.unknown_props.keys() {
            diagnostics.push(Diagnostic::warning(
                "style.unknown_property",
                format!(
                    "style '{}': unknown property '{}' (not a recognized visual property; \
                     this property will not be applied to nodes that reference this style)",
                    style.id, prop_name
                ),
                style.source_span,
                Some(style.id.clone()),
            ));
        }
    }
}

/// Map a canonical style property key to its expected token type.
///
/// Returns `None` for keys that have no strict type expectation in v0
/// (`stroke-alignment`, `font-weight`).
fn style_prop_expect(key: &str) -> Option<VisualExpect> {
    match key {
        "fill" | "stroke" => Some(VisualExpect::Color),
        "stroke-width" | "font-size" | "line-height" | "radius" | "padding" | "gap" => {
            Some(VisualExpect::Dimension)
        }
        "font-family" => Some(VisualExpect::FontFamily),
        // stroke-alignment: plain enum string, not type-checked.
        // font-weight: fontWeight token type — no VisualExpect variant for it; skip check.
        _ => None,
    }
}