Skip to main content

fuel_dispatch/fkc/
parse.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! FKC markdown + restricted-YAML parser (§3.1 file anatomy, §3.8 YAML subset).
3//!
4//! Pipeline:
5//! 1. Split the file-level `---`-fenced YAML front-matter off the top (§3.1).
6//! 2. Scan `## ` headings; for each section extract its single fenced
7//!    ` ```fkc ` block (§3.1: exactly one per section — zero or >1 is an error).
8//! 3. Run the §3.8 restricted-subset pre-pass on every YAML chunk BEFORE
9//!    deserialization: reject tab indentation, anchors (`&name`), aliases
10//!    (`*name`), merge keys (`<<:`), and unquoted Norway-problem tokens in
11//!    scalar value positions.
12//! 4. `serde_yaml_ng::from_str` each chunk into the schema structs and assemble
13//!    [`FkcFile`].
14//!
15//! Why hand-rolled and not `pulldown-cmark`: the adoption plan (§1.1) permits
16//! "a minimal hand-rolled section/fence scanner", and the file anatomy is
17//! deliberately simple (front-matter, `## ` headings, fenced blocks). A line
18//! scanner keeps the dependency surface to just `serde` + `serde_yaml_ng`.
19//!
20//! ## The Norway problem (§3.8)
21//!
22//! `serde_yaml_ng` (libyaml-family) resolves YAML-1.1-ish implicit types, so an
23//! unquoted `no` / `yes` / `on` / `off` / `n` / `y` in a *scalar value*
24//! position can coerce to a bool. FKC defends with TWO layers, both applied
25//! here: (a) every token-bearing schema field is typed `String`, so even if the
26//! deserializer produced a bool it could not target a string field; and (b)
27//! this pre-pass rejects an unquoted Norway token in a value position outright
28//! ([`FkcError::NorwayToken`]) so the contract author is told to quote it
29//! rather than getting a silently-coerced value. Quoted forms (`"no"`,
30//! `'NO'`), block/flow keys, and tokens appearing only inside a quoted
31//! expression string are untouched.
32
33use std::path::Path;
34
35use super::error::FkcError;
36use super::schema::{FkcFile, FkcFrontMatter, FkcKernel};
37
38/// Parse an FKC file's text into an [`FkcFile`] (front-matter + kernels).
39///
40/// Pure: no I/O of its own (tests pass `&str`). Every failure is a typed
41/// [`FkcError`]; never panics.
42pub fn parse_file(text: &str) -> Result<FkcFile, FkcError> {
43    let (front_src, body) = split_front_matter(text)?;
44
45    // §3.8 pre-pass on the front-matter chunk.
46    enforce_restricted_yaml(front_src, 0)?;
47    let front_matter: FkcFrontMatter =
48        serde_yaml_ng::from_str(front_src).map_err(|e| FkcError::yaml(None, e))?;
49
50    if let Some(line) = find_orphan_fkc_fence(body) {
51        return Err(FkcError::OrphanFkcBlock { line });
52    }
53
54    let sections = split_sections(body);
55    let mut kernels = Vec::with_capacity(sections.len());
56    for section in sections {
57        let block = extract_fkc_block(&section)?;
58        // §3.8 pre-pass on this kernel's fkc block, reporting absolute lines.
59        enforce_restricted_yaml(&block.text, block.start_line)?;
60        let kernel: FkcKernel = serde_yaml_ng::from_str(&block.text)
61            .map_err(|e| FkcError::yaml(Some(&section.title), e))?;
62        kernels.push(kernel);
63    }
64
65    Ok(FkcFile {
66        front_matter,
67        kernels,
68    })
69}
70
71/// Read a file at `path`, then [`parse_file`].
72pub fn parse_path(path: impl AsRef<Path>) -> Result<FkcFile, FkcError> {
73    let path = path.as_ref();
74    let text = std::fs::read_to_string(path).map_err(|e| {
75        FkcError::MalformedFrontMatter(format!("cannot read `{}`: {e}", path.display()))
76    })?;
77    parse_file(&text)
78}
79
80// ===========================================================================
81// §3.1 front-matter
82// ===========================================================================
83
84/// Split the leading `---`-fenced YAML front-matter off the top of the file.
85/// Returns `(front_matter_yaml, remaining_body)`.
86fn split_front_matter(text: &str) -> Result<(&str, &str), FkcError> {
87    // Tolerate a leading BOM / blank lines before the opening fence.
88    let trimmed = text.trim_start_matches('\u{feff}');
89    let leading_ws = text.len() - trimmed.len();
90
91    let mut lines = trimmed.lines();
92    let first = lines.next().ok_or_else(|| {
93        FkcError::MalformedFrontMatter("file is empty (expected `---` front-matter)".into())
94    })?;
95    if first.trim_end() != "---" {
96        return Err(FkcError::MalformedFrontMatter(
97            "file must begin with a `---` front-matter fence".into(),
98        ));
99    }
100
101    // Find the closing `---`. Compute byte offsets so we can slice the original.
102    // Offsets are relative to `trimmed`; add `leading_ws` back for the body.
103    let mut offset = first.len();
104    // Account for the newline after the first line.
105    offset += newline_len(trimmed, offset);
106    let front_start = offset;
107
108    loop {
109        let rest = &trimmed[offset..];
110        let line_end = rest.find('\n').map(|i| offset + i).unwrap_or(trimmed.len());
111        let line = &trimmed[offset..line_end];
112        if line.trim_end() == "---" {
113            let front = &trimmed[front_start..offset];
114            // Body begins after this closing fence's line + newline.
115            let mut body_off = line_end;
116            body_off += newline_len(trimmed, body_off);
117            let body = &text[leading_ws + body_off..];
118            return Ok((front, body));
119        }
120        if line_end >= trimmed.len() {
121            return Err(FkcError::MalformedFrontMatter(
122                "unterminated front-matter (missing closing `---`)".into(),
123            ));
124        }
125        offset = line_end + newline_len(trimmed, line_end);
126    }
127}
128
129/// Length (0, 1, or 2) of the newline sequence at `pos` in `s` (`\n` or `\r\n`).
130fn newline_len(s: &str, pos: usize) -> usize {
131    let bytes = s.as_bytes();
132    match bytes.get(pos) {
133        Some(b'\r') if bytes.get(pos + 1) == Some(&b'\n') => 2,
134        Some(b'\n') | Some(b'\r') => 1,
135        _ => 0,
136    }
137}
138
139// ===========================================================================
140// §3.1 `## ` sections
141// ===========================================================================
142
143/// A `## ` kernel section: its heading title + body lines (with absolute line
144/// numbers so the §3.8 pre-pass can report meaningfully).
145struct Section {
146    title: String,
147    /// Body lines paired with their absolute (1-based) line number in the file.
148    lines: Vec<(usize, String)>,
149}
150
151/// Detect a `` ```fkc `` opening fence that appears BEFORE the first `## `
152/// heading (or anywhere in a file with no `## ` headings). Such a block is
153/// silently dropped by `split_sections`, yielding an Ok-but-empty import.
154/// Only a `` ```fkc `` opener triggers; a `` ```yaml `` (or other) intro fence
155/// is skipped. Line numbers are 1-based within `body` (same convention as
156/// `split_sections`).
157///
158/// The heading test deliberately mirrors `split_sections`'s column-0 `## `
159/// match exactly (`raw.strip_prefix("## ")`, NOT a whitespace-tolerant
160/// `trim_start()`ed check): the two must agree on where "before the first
161/// section" ends, or an indented pseudo-heading (e.g. `  ## foo`, ignored by
162/// `split_sections` because it isn't at column 0) could make this detector
163/// stop scanning early while `split_sections` keeps dropping lines — hiding a
164/// real orphan block that follows.
165fn find_orphan_fkc_fence(body: &str) -> Option<usize> {
166    let mut in_fence = false;
167    for (idx, raw) in body.lines().enumerate() {
168        if !in_fence && raw.strip_prefix("## ").is_some() {
169            return None; // first `## ` reached: the rest belongs to sections
170        }
171        let trimmed = raw.trim_start();
172        if !in_fence {
173            if trimmed.trim_end() == "```fkc" {
174                return Some(idx + 1);
175            }
176            if trimmed.starts_with("```") {
177                in_fence = true; // some other intro fence — skip its body
178            }
179        } else if trimmed.trim_end() == "```" {
180            in_fence = false;
181        }
182    }
183    None
184}
185
186/// Split the markdown body into `## `-delimited sections. A leading `# ` (H1)
187/// title and any prose before the first `## ` are dropped (intro prose, §3.1).
188fn split_sections(body: &str) -> Vec<Section> {
189    let mut sections: Vec<Section> = Vec::new();
190    let mut current: Option<Section> = None;
191
192    for (idx, raw) in body.lines().enumerate() {
193        let line_no = idx + 1; // 1-based within `body`
194        if let Some(title) = raw.strip_prefix("## ") {
195            if let Some(sec) = current.take() {
196                sections.push(sec);
197            }
198            current = Some(Section {
199                title: title.trim().to_string(),
200                lines: Vec::new(),
201            });
202        } else if let Some(sec) = current.as_mut() {
203            sec.lines.push((line_no, raw.to_string()));
204        }
205        // else: prose before the first `## ` — ignored.
206    }
207    if let Some(sec) = current.take() {
208        sections.push(sec);
209    }
210    sections
211}
212
213// ===========================================================================
214// §3.1 fenced ```fkc block extraction
215// ===========================================================================
216
217/// An extracted fkc block: its YAML text + the absolute start line of the
218/// first content line (so §3.8 errors point at the real file line).
219struct FkcBlock {
220    text: String,
221    /// Absolute (1-based, within the section body) line of the first YAML line.
222    start_line: usize,
223}
224
225/// Extract the single ` ```fkc ` fenced block from a section (§3.1: exactly
226/// one). Zero → `MissingFkcBlock`; more than one → `MultipleFkcBlocks`.
227fn extract_fkc_block(section: &Section) -> Result<FkcBlock, FkcError> {
228    let mut blocks: Vec<FkcBlock> = Vec::new();
229    let mut in_block = false;
230    let mut buf: Vec<String> = Vec::new();
231    let mut block_start = 0usize;
232
233    for (line_no, raw) in &section.lines {
234        let trimmed = raw.trim_start();
235        if !in_block {
236            // Opening fence: ```fkc (allow trailing whitespace).
237            if trimmed.trim_end() == "```fkc" {
238                in_block = true;
239                buf.clear();
240                block_start = line_no + 1;
241            }
242        } else {
243            // Inside a block: a bare ``` closes it.
244            if trimmed.trim_end() == "```" {
245                blocks.push(FkcBlock {
246                    text: buf.join("\n"),
247                    start_line: block_start,
248                });
249                in_block = false;
250            } else {
251                buf.push(raw.clone());
252            }
253        }
254    }
255
256    match blocks.len() {
257        0 => Err(FkcError::MissingFkcBlock {
258            section: section.title.clone(),
259        }),
260        1 => Ok(blocks.pop().expect("len checked == 1")),
261        n => Err(FkcError::MultipleFkcBlocks {
262            section: section.title.clone(),
263            count: n,
264        }),
265    }
266}
267
268// ===========================================================================
269// §3.8 restricted-YAML pre-pass
270// ===========================================================================
271
272/// The Norway-problem tokens (lowercase) that YAML 1.1 coerces to bools.
273const NORWAY_TOKENS: &[&str] = &["no", "yes", "on", "off", "n", "y"];
274
275/// Enforce the §3.8 restricted YAML subset on a raw YAML chunk BEFORE
276/// deserialization. `base_line` is added to the 0-based in-chunk line index to
277/// produce an absolute file line for error reporting.
278///
279/// Rejects: tab indentation; anchors (`&name`); aliases (`*name`); merge keys
280/// (`<<:`); and unquoted Norway tokens in a scalar value position.
281fn enforce_restricted_yaml(chunk: &str, base_line: usize) -> Result<(), FkcError> {
282    for (i, raw) in chunk.lines().enumerate() {
283        let line = base_line + i;
284
285        // --- tab indentation (leading whitespace only) ---
286        let indent: String = raw
287            .chars()
288            .take_while(|c| *c == ' ' || *c == '\t')
289            .collect();
290        if indent.contains('\t') {
291            return Err(FkcError::TabIndentation { line });
292        }
293
294        // Strip a trailing `# ...` comment that is NOT inside a quoted string,
295        // so tokens / sigils inside comments and quoted values are ignored.
296        let content = strip_comment_and_quotes(raw);
297        let content_trim = content.trim();
298        if content_trim.is_empty() {
299            continue;
300        }
301
302        // --- merge keys (`<<:`) ---
303        if content_trim.starts_with("<<:") || content_trim.starts_with("- <<:") {
304            return Err(FkcError::MergeKeyDisallowed { line });
305        }
306
307        // --- anchors / aliases — scan tokens outside quotes ---
308        // We scan the comment/quote-stripped `content` (quoted spans were blanked
309        // to spaces), so a `&`/`*` only triggers when it is a YAML sigil.
310        for tok in content.split_whitespace() {
311            // An anchor sigil `&name` appears as a token starting with `&`.
312            if let Some(rest) = tok.strip_prefix('&')
313                && !rest.is_empty()
314                && is_anchor_name(rest)
315            {
316                return Err(FkcError::AnchorDisallowed { line });
317            }
318            // An alias `*name` appears as a token starting with `*`.
319            if let Some(rest) = tok.strip_prefix('*')
320                && !rest.is_empty()
321                && is_anchor_name(rest)
322            {
323                return Err(FkcError::AliasDisallowed { line });
324            }
325        }
326
327        // --- Norway tokens in a scalar VALUE position ---
328        // Value position = text after the first unquoted `:` (mapping value) or
329        // after a `- ` (sequence item). We use the quote-blanked `content`.
330        if let Some((key, value)) = scalar_key_value_of(&content) {
331            let v = value.trim();
332            // §3.8 exemption: a `name:` operand-ROLE value is explicitly a
333            // diagnostic STRING that "stays the string" even for `n`/`y`
334            // (the spec's own example: "A `name: n` operand role stays the
335            // string `"n"`"). The schema field is `String`, so it cannot
336            // coerce to a bool; do NOT reject it. Every OTHER value-position
337            // Norway token is still flagged so a bool-targeting field cannot
338            // silently coerce.
339            let key_is_role_name = key.as_deref() == Some("name");
340            if !key_is_role_name
341                && !v.is_empty()
342                && NORWAY_TOKENS.contains(&v.to_ascii_lowercase().as_str())
343            {
344                return Err(FkcError::NorwayToken {
345                    token: v.to_string(),
346                    line,
347                });
348            }
349        }
350    }
351    Ok(())
352}
353
354/// Is `s` a plausible YAML anchor/alias name (alnum / `-` / `_`)? Used to avoid
355/// false positives on `*` used as a glob or `&` inside odd text.
356fn is_anchor_name(s: &str) -> bool {
357    s.chars()
358        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
359}
360
361/// Return a copy of `raw` with quoted spans (`"..."` / `'...'`) replaced by
362/// spaces and any trailing unquoted `#` comment stripped. This lets the §3.8
363/// sigil/Norway scans look only at unquoted content.
364fn strip_comment_and_quotes(raw: &str) -> String {
365    let mut out = String::with_capacity(raw.len());
366    let chars = raw.chars().peekable();
367    let mut in_single = false;
368    let mut in_double = false;
369
370    for c in chars {
371        match c {
372            '\'' if !in_double => {
373                in_single = !in_single;
374                out.push(' ');
375            }
376            '"' if !in_single => {
377                in_double = !in_double;
378                out.push(' ');
379            }
380            '#' if !in_single && !in_double => {
381                // Comment start (only honored when preceded by whitespace or at
382                // column 0; YAML requires a space before an inline `#`). If the
383                // previous emitted char is non-space and non-empty, treat `#`
384                // literally.
385                let prev_is_space = out.chars().last().map(|p| p == ' ').unwrap_or(true);
386                if prev_is_space {
387                    break; // rest of line is a comment
388                }
389                out.push(c);
390            }
391            _ => {
392                if in_single || in_double {
393                    out.push(' ');
394                } else {
395                    out.push(c);
396                }
397            }
398        }
399    }
400    out
401}
402
403/// Given a quote-blanked, comment-stripped line, return `(key, value)` for the
404/// scalar VALUE part: the text after the first `:` (mapping value), or after a
405/// leading `- ` (sequence scalar; `key` is `None` then). Returns `None` if the
406/// line is a bare key, a nested mapping opener, or a flow collection
407/// (`{...}` / `[...]`) we don't unpack here.
408fn scalar_key_value_of(content: &str) -> Option<(Option<String>, &str)> {
409    let trimmed = content.trim_start();
410
411    // Flow collections are handled token-wise elsewhere; skip whole-line value
412    // extraction for them to avoid mis-parsing `{ a: no }` here (the inner
413    // `family: no` in `quant: { family: no, ... }` IS still caught because the
414    // deserializer would target a String field — and a bare top-level
415    // `family: no` line is caught below).
416    if trimmed.starts_with('{') || trimmed.starts_with('[') {
417        return None;
418    }
419
420    // `- scalar` sequence item (no key).
421    let after_dash = trimmed.strip_prefix("- ");
422    let is_seq_item = after_dash.is_some();
423    let trimmed = after_dash.unwrap_or(trimmed);
424
425    // `key: value` — split on the first `:`.
426    if let Some(colon) = trimmed.find(':') {
427        let key = trimmed[..colon].trim();
428        let value = &trimmed[colon + 1..];
429        // A value that itself opens a flow collection or nested map is not a
430        // bare scalar.
431        let vt = value.trim_start();
432        if vt.starts_with('{') || vt.starts_with('[') {
433            return None;
434        }
435        // A `- name: y` sequence-item-with-key still yields key="name".
436        let key = if key.is_empty() {
437            None
438        } else {
439            Some(key.to_string())
440        };
441        return Some((key, value));
442    }
443    // A bare `- scalar` sequence item carries no key.
444    if is_seq_item {
445        return Some((None, trimmed));
446    }
447    None
448}
449
450#[cfg(test)]
451mod yaml_typing_pin {
452    //! **GAP-182 typing pin — a standing invariant, not a migration check.**
453    //!
454    //! `.fkc.md` contract parsing depends on the YAML-1.1-family implicit typing
455    //! that `serde_yaml_ng` (libyaml family) applies — see the "Norway problem"
456    //! note at the top of this file. Because contracts also arrive over the
457    //! Spec-B ingestion seam from sibling projects, this is NOT a property of
458    //! today's checked-in corpus; it must hold for tomorrow's contract too. So
459    //! we pin the resolved scalar type for the cases that distinguish YAML 1.1
460    //! from 1.2, and a future `serde_yaml_ng` bump that shifts typing fails HERE
461    //! with a message about what breaks — rather than silently mis-lowering a
462    //! contract. (These expectations were verified byte-identical to the
463    //! replaced `serde_yml` across all 114 `.fkc.md` at the swap — GAP-182.)
464
465    fn resolved_kind(s: &str) -> String {
466        match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(s) {
467            Ok(serde_yaml_ng::Value::Null) => "Null".to_string(),
468            Ok(serde_yaml_ng::Value::Bool(b)) => format!("Bool({b})"),
469            Ok(serde_yaml_ng::Value::Number(_)) => "Number".to_string(),
470            Ok(serde_yaml_ng::Value::String(_)) => "String".to_string(),
471            Ok(serde_yaml_ng::Value::Sequence(_)) => "Sequence".to_string(),
472            Ok(serde_yaml_ng::Value::Mapping(_)) => "Mapping".to_string(),
473            Ok(serde_yaml_ng::Value::Tagged(_)) => "Tagged".to_string(),
474            Err(e) => format!("Err({e})"),
475        }
476    }
477
478    #[test]
479    fn yaml_1_1_scalar_typing_is_pinned_for_contract_parsing() {
480        // (input, resolved kind). Cases chosen to distinguish YAML 1.1 from 1.2.
481        let pins = [
482            // Norway booleans — resolved as STRINGS by the libyaml family, which
483            // is what FKC's `String`-typed fields and pre-pass assume.
484            ("no", "String"),
485            ("yes", "String"),
486            ("on", "String"),
487            ("off", "String"),
488            // Sexagesimal is NOT coerced to a number.
489            ("1:30", "String"),
490            // Octal/hex: `0o17` / `0x1f` parse as numbers; a leading-zero `017`
491            // does NOT (it stays a string — YAML 1.2 behavior).
492            ("0o17", "Number"),
493            ("0x1f", "Number"),
494            ("017", "String"),
495            // Plain numbers.
496            ("1.0", "Number"),
497            ("1e3", "Number"),
498            ("42", "Number"),
499            // Null spellings.
500            ("~", "Null"),
501            ("null", "Null"),
502            // Version-like unquoted scalar stays a string (not a float).
503            ("1.2.3", "String"),
504            // Canonical booleans (sanity anchor).
505            ("true", "Bool(true)"),
506            ("false", "Bool(false)"),
507        ];
508        for (input, expected) in pins {
509            let got = resolved_kind(input);
510            assert_eq!(
511                got, expected,
512                "`.fkc.md` contract parsing depends on YAML-1.1 implicit typing (see this \
513                 file's Norway-problem note); serde_yaml_ng now resolves `{input}` as {got} \
514                 instead of {expected} — EXISTING CONTRACTS WILL LOWER DIFFERENTLY, and a \
515                 Spec-B-ingested contract using this spelling would too. Investigate before \
516                 changing the parser/version. See GAP-182."
517            );
518        }
519    }
520}