use std::path::Path;
use super::error::FkcError;
use super::schema::{FkcFile, FkcFrontMatter, FkcKernel};
pub fn parse_file(text: &str) -> Result<FkcFile, FkcError> {
let (front_src, body) = split_front_matter(text)?;
enforce_restricted_yaml(front_src, 0)?;
let front_matter: FkcFrontMatter =
serde_yaml_ng::from_str(front_src).map_err(|e| FkcError::yaml(None, e))?;
if let Some(line) = find_orphan_fkc_fence(body) {
return Err(FkcError::OrphanFkcBlock { line });
}
let sections = split_sections(body);
let mut kernels = Vec::with_capacity(sections.len());
for section in sections {
let block = extract_fkc_block(§ion)?;
enforce_restricted_yaml(&block.text, block.start_line)?;
let kernel: FkcKernel = serde_yaml_ng::from_str(&block.text)
.map_err(|e| FkcError::yaml(Some(§ion.title), e))?;
kernels.push(kernel);
}
Ok(FkcFile {
front_matter,
kernels,
})
}
pub fn parse_path(path: impl AsRef<Path>) -> Result<FkcFile, FkcError> {
let path = path.as_ref();
let text = std::fs::read_to_string(path).map_err(|e| {
FkcError::MalformedFrontMatter(format!("cannot read `{}`: {e}", path.display()))
})?;
parse_file(&text)
}
fn split_front_matter(text: &str) -> Result<(&str, &str), FkcError> {
let trimmed = text.trim_start_matches('\u{feff}');
let leading_ws = text.len() - trimmed.len();
let mut lines = trimmed.lines();
let first = lines.next().ok_or_else(|| {
FkcError::MalformedFrontMatter("file is empty (expected `---` front-matter)".into())
})?;
if first.trim_end() != "---" {
return Err(FkcError::MalformedFrontMatter(
"file must begin with a `---` front-matter fence".into(),
));
}
let mut offset = first.len();
offset += newline_len(trimmed, offset);
let front_start = offset;
loop {
let rest = &trimmed[offset..];
let line_end = rest.find('\n').map(|i| offset + i).unwrap_or(trimmed.len());
let line = &trimmed[offset..line_end];
if line.trim_end() == "---" {
let front = &trimmed[front_start..offset];
let mut body_off = line_end;
body_off += newline_len(trimmed, body_off);
let body = &text[leading_ws + body_off..];
return Ok((front, body));
}
if line_end >= trimmed.len() {
return Err(FkcError::MalformedFrontMatter(
"unterminated front-matter (missing closing `---`)".into(),
));
}
offset = line_end + newline_len(trimmed, line_end);
}
}
fn newline_len(s: &str, pos: usize) -> usize {
let bytes = s.as_bytes();
match bytes.get(pos) {
Some(b'\r') if bytes.get(pos + 1) == Some(&b'\n') => 2,
Some(b'\n') | Some(b'\r') => 1,
_ => 0,
}
}
struct Section {
title: String,
lines: Vec<(usize, String)>,
}
fn find_orphan_fkc_fence(body: &str) -> Option<usize> {
let mut in_fence = false;
for (idx, raw) in body.lines().enumerate() {
if !in_fence && raw.strip_prefix("## ").is_some() {
return None; }
let trimmed = raw.trim_start();
if !in_fence {
if trimmed.trim_end() == "```fkc" {
return Some(idx + 1);
}
if trimmed.starts_with("```") {
in_fence = true; }
} else if trimmed.trim_end() == "```" {
in_fence = false;
}
}
None
}
fn split_sections(body: &str) -> Vec<Section> {
let mut sections: Vec<Section> = Vec::new();
let mut current: Option<Section> = None;
for (idx, raw) in body.lines().enumerate() {
let line_no = idx + 1; if let Some(title) = raw.strip_prefix("## ") {
if let Some(sec) = current.take() {
sections.push(sec);
}
current = Some(Section {
title: title.trim().to_string(),
lines: Vec::new(),
});
} else if let Some(sec) = current.as_mut() {
sec.lines.push((line_no, raw.to_string()));
}
}
if let Some(sec) = current.take() {
sections.push(sec);
}
sections
}
struct FkcBlock {
text: String,
start_line: usize,
}
fn extract_fkc_block(section: &Section) -> Result<FkcBlock, FkcError> {
let mut blocks: Vec<FkcBlock> = Vec::new();
let mut in_block = false;
let mut buf: Vec<String> = Vec::new();
let mut block_start = 0usize;
for (line_no, raw) in §ion.lines {
let trimmed = raw.trim_start();
if !in_block {
if trimmed.trim_end() == "```fkc" {
in_block = true;
buf.clear();
block_start = line_no + 1;
}
} else {
if trimmed.trim_end() == "```" {
blocks.push(FkcBlock {
text: buf.join("\n"),
start_line: block_start,
});
in_block = false;
} else {
buf.push(raw.clone());
}
}
}
match blocks.len() {
0 => Err(FkcError::MissingFkcBlock {
section: section.title.clone(),
}),
1 => Ok(blocks.pop().expect("len checked == 1")),
n => Err(FkcError::MultipleFkcBlocks {
section: section.title.clone(),
count: n,
}),
}
}
const NORWAY_TOKENS: &[&str] = &["no", "yes", "on", "off", "n", "y"];
fn enforce_restricted_yaml(chunk: &str, base_line: usize) -> Result<(), FkcError> {
for (i, raw) in chunk.lines().enumerate() {
let line = base_line + i;
let indent: String = raw
.chars()
.take_while(|c| *c == ' ' || *c == '\t')
.collect();
if indent.contains('\t') {
return Err(FkcError::TabIndentation { line });
}
let content = strip_comment_and_quotes(raw);
let content_trim = content.trim();
if content_trim.is_empty() {
continue;
}
if content_trim.starts_with("<<:") || content_trim.starts_with("- <<:") {
return Err(FkcError::MergeKeyDisallowed { line });
}
for tok in content.split_whitespace() {
if let Some(rest) = tok.strip_prefix('&')
&& !rest.is_empty()
&& is_anchor_name(rest)
{
return Err(FkcError::AnchorDisallowed { line });
}
if let Some(rest) = tok.strip_prefix('*')
&& !rest.is_empty()
&& is_anchor_name(rest)
{
return Err(FkcError::AliasDisallowed { line });
}
}
if let Some((key, value)) = scalar_key_value_of(&content) {
let v = value.trim();
let key_is_role_name = key.as_deref() == Some("name");
if !key_is_role_name
&& !v.is_empty()
&& NORWAY_TOKENS.contains(&v.to_ascii_lowercase().as_str())
{
return Err(FkcError::NorwayToken {
token: v.to_string(),
line,
});
}
}
}
Ok(())
}
fn is_anchor_name(s: &str) -> bool {
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
}
fn strip_comment_and_quotes(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let chars = raw.chars().peekable();
let mut in_single = false;
let mut in_double = false;
for c in chars {
match c {
'\'' if !in_double => {
in_single = !in_single;
out.push(' ');
}
'"' if !in_single => {
in_double = !in_double;
out.push(' ');
}
'#' if !in_single && !in_double => {
let prev_is_space = out.chars().last().map(|p| p == ' ').unwrap_or(true);
if prev_is_space {
break; }
out.push(c);
}
_ => {
if in_single || in_double {
out.push(' ');
} else {
out.push(c);
}
}
}
}
out
}
fn scalar_key_value_of(content: &str) -> Option<(Option<String>, &str)> {
let trimmed = content.trim_start();
if trimmed.starts_with('{') || trimmed.starts_with('[') {
return None;
}
let after_dash = trimmed.strip_prefix("- ");
let is_seq_item = after_dash.is_some();
let trimmed = after_dash.unwrap_or(trimmed);
if let Some(colon) = trimmed.find(':') {
let key = trimmed[..colon].trim();
let value = &trimmed[colon + 1..];
let vt = value.trim_start();
if vt.starts_with('{') || vt.starts_with('[') {
return None;
}
let key = if key.is_empty() {
None
} else {
Some(key.to_string())
};
return Some((key, value));
}
if is_seq_item {
return Some((None, trimmed));
}
None
}
#[cfg(test)]
mod yaml_typing_pin {
fn resolved_kind(s: &str) -> String {
match serde_yaml_ng::from_str::<serde_yaml_ng::Value>(s) {
Ok(serde_yaml_ng::Value::Null) => "Null".to_string(),
Ok(serde_yaml_ng::Value::Bool(b)) => format!("Bool({b})"),
Ok(serde_yaml_ng::Value::Number(_)) => "Number".to_string(),
Ok(serde_yaml_ng::Value::String(_)) => "String".to_string(),
Ok(serde_yaml_ng::Value::Sequence(_)) => "Sequence".to_string(),
Ok(serde_yaml_ng::Value::Mapping(_)) => "Mapping".to_string(),
Ok(serde_yaml_ng::Value::Tagged(_)) => "Tagged".to_string(),
Err(e) => format!("Err({e})"),
}
}
#[test]
fn yaml_1_1_scalar_typing_is_pinned_for_contract_parsing() {
let pins = [
("no", "String"),
("yes", "String"),
("on", "String"),
("off", "String"),
("1:30", "String"),
("0o17", "Number"),
("0x1f", "Number"),
("017", "String"),
("1.0", "Number"),
("1e3", "Number"),
("42", "Number"),
("~", "Null"),
("null", "Null"),
("1.2.3", "String"),
("true", "Bool(true)"),
("false", "Bool(false)"),
];
for (input, expected) in pins {
let got = resolved_kind(input);
assert_eq!(
got, expected,
"`.fkc.md` contract parsing depends on YAML-1.1 implicit typing (see this \
file's Norway-problem note); serde_yaml_ng now resolves `{input}` as {got} \
instead of {expected} — EXISTING CONTRACTS WILL LOWER DIFFERENTLY, and a \
Spec-B-ingested contract using this spelling would too. Investigate before \
changing the parser/version. See GAP-182."
);
}
}
}