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(§ion)?;
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(§ion.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 §ion.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}