markplus_core 1.2.0

Universal Markdown - AST (JSON) compiler for the MarkPlus ecosystem
Documentation
//    Copyright [2026] [Purnendu Kumar]
//
//    Licensed under the Apache License, Version 2.0 (the "License");
//    you may not use this file except in compliance with the License.
//    You may obtain a copy of the License at
//
//        http://www.apache.org/licenses/LICENSE-2.0
//
//    Unless required by applicable law or agreed to in writing, software
//    distributed under the License is distributed on an "AS IS" BASIS,
//    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//    See the License for the specific language governing permissions and
//    limitations under the License.

//! Minimal pure-Rust YAML subset parser for frontmatter.
//!
//! Replaces `serde_yml` to eliminate dependencies and unify native/wasm behaviour.
//! Supports basic scalars (strings, quoted strings, ints, floats, bools, dates),
//! block lists, flow lists, and nested objects.

use crate::CompileError;
use serde_json::{Map, Value};

/// Parse raw YAML frontmatter text into a JSON Value.
/// Returns Ok(None) for empty/missing input.
pub(crate) fn parse_yaml_frontmatter(raw: Option<&str>) -> Result<Option<Value>, CompileError> {
    let raw = match raw {
        Some(s) => s.trim(),
        None => return Ok(None),
    };
    if raw.is_empty() {
        return Ok(None);
    }

    let mut lines = raw.lines().peekable();
    if let Some(Value::Object(map)) = parse_block(&mut lines, 0) {
        if map.is_empty() {
            Ok(None)
        } else {
            Ok(Some(Value::Object(map)))
        }
    } else {
        Err(CompileError::InvalidFrontmatter("invalid block".into()))
    }
}

fn parse_block(
    lines: &mut std::iter::Peekable<std::str::Lines>,
    current_indent: usize,
) -> Option<Value> {
    let mut map = Map::new();
    let mut list = Vec::new();
    let mut is_list: Option<bool> = None;

    while let Some(&line) = lines.peek() {
        if line.trim().is_empty() {
            lines.next();
            continue;
        }

        let indent = get_indent(line);
        if indent < current_indent {
            break; // End of this block
        }
        if indent > current_indent {
            break;
        }

        let line = lines.next().unwrap();
        let trimmed = line.trim();

        if let Some(rest) = trimmed.strip_prefix("- ") {
            if is_list == Some(false) {
                return None;
            }
            is_list = Some(true);
            let val_str = rest.trim();
            if val_str.is_empty() {
                if let Some(&next) = lines.peek() {
                    let next_ind = get_indent(next);
                    if next_ind > current_indent {
                        if let Some(v) = parse_block(lines, next_ind) {
                            list.push(v);
                        }
                    } else {
                        list.push(Value::Null);
                    }
                }
            } else {
                list.push(parse_scalar(val_str));
            }
        } else if let Some((key, val)) = split_key_val(trimmed) {
            if is_list == Some(true) {
                return None;
            }
            is_list = Some(false);
            let key = key.to_string();
            if val.is_empty() {
                if let Some(&next) = lines.peek() {
                    let next_ind = get_indent(next);
                    if next_ind > current_indent {
                        if let Some(v) = parse_block(lines, next_ind) {
                            map.insert(key, v);
                        }
                    } else {
                        map.insert(key, Value::Null);
                    }
                } else {
                    map.insert(key, Value::Null);
                }
            } else {
                map.insert(key, parse_scalar(val));
            }
        } else {
            return None;
        }
    }

    match is_list {
        Some(true) => Some(Value::Array(list)),
        Some(false) => Some(Value::Object(map)),
        None => None,
    }
}

fn get_indent(s: &str) -> usize {
    s.chars().take_while(|c| *c == ' ').count()
}

fn split_key_val(s: &str) -> Option<(&str, &str)> {
    let colon = s.find(':')?;
    // Colon must be followed by space or end of line in YAML, unless in quotes (but keys aren't usually quoted here)
    if colon == s.len() - 1 || s[colon + 1..].starts_with(' ') {
        Some((s[..colon].trim(), s[colon + 1..].trim()))
    } else {
        None
    }
}

fn parse_scalar(s: &str) -> Value {
    if s == "true" {
        Value::Bool(true)
    } else if s == "false" {
        Value::Bool(false)
    } else if s.starts_with('[') && s.ends_with(']') {
        // Flow list
        let inner = &s[1..s.len() - 1];
        let items: Vec<Value> = inner.split(',').map(|x| parse_scalar(x.trim())).collect();
        Value::Array(items)
    } else if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\''))
    {
        Value::String(s[1..s.len() - 1].to_string())
    } else if let Ok(i) = s.parse::<i64>() {
        Value::Number(i.into())
    } else if let Ok(f) = s.parse::<f64>() {
        if let Some(n) = serde_json::Number::from_f64(f) {
            Value::Number(n)
        } else {
            Value::String(s.to_string())
        }
    } else {
        // Treat as string (includes dates)
        Value::String(s.to_string())
    }
}

/// Parse a YAML string and return a JSON Value.
pub fn parse_yaml_str(yaml_text: &str) -> Result<Value, String> {
    match parse_yaml_frontmatter(Some(yaml_text)) {
        Ok(Some(v)) => Ok(v),
        Ok(None) => Ok(Value::Null),
        Err(e) => Err(e.to_string()),
    }
}

/// Read a YAML file and return a JSON Value.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_yaml_file(path: &std::path::Path) -> Result<Value, String> {
    let s = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
    parse_yaml_str(&s)
}