mdforge 0.1.0

Define, validate, and render typed Markdown extensions for LLM-generated content.
Documentation
use std::collections::HashMap;

use crate::{ast::ArgSpec, ArgType, ArgValue, Diagnostic, ErrorCode, InlineExt, Level, Span};

use super::EvalContext;

pub(super) fn parse_args(
    tokens: &[&str],
    span_start: usize,
    diagnostics: &mut Vec<Diagnostic>,
) -> HashMap<String, ArgValue> {
    let mut args = HashMap::new();
    for token in tokens {
        let Some((key, raw_value)) = token.split_once('=') else {
            diagnostics.push(Diagnostic {
                level: Level::Error,
                code: ErrorCode::InvalidType,
                message: format!("invalid argument '{}', expected key=value", token),
                span: Span {
                    start: span_start,
                    end: span_start + token.len(),
                },
                suggestion: None,
            });
            continue;
        };
        let value = if let Ok(i) = raw_value.parse::<i64>() {
            ArgValue::Int(i)
        } else {
            ArgValue::String(raw_value.to_string())
        };
        args.insert(key.to_string(), value);
    }
    args
}

pub(super) fn validate_args(
    actual: &HashMap<String, ArgValue>,
    spec: &[(String, ArgSpec)],
    span: Span,
    diagnostics: &mut Vec<Diagnostic>,
) {
    for (name, arg_spec) in spec {
        if arg_spec.required && !actual.contains_key(name) {
            diagnostics.push(Diagnostic {
                level: Level::Error,
                code: ErrorCode::MissingRequiredArg,
                message: format!("missing required arg '{}'", name),
                span: span.clone(),
                suggestion: None,
            });
        }
    }

    for (name, value) in actual {
        let Some(arg_spec) = spec.iter().find(|(n, _)| n == name).map(|(_, s)| s) else {
            diagnostics.push(Diagnostic {
                level: Level::Error,
                code: ErrorCode::UnknownArg,
                message: format!("unknown arg '{}'", name),
                span: span.clone(),
                suggestion: None,
            });
            continue;
        };

        match (&arg_spec.arg_type, value) {
            (ArgType::Int, ArgValue::Int(_)) => {}
            (ArgType::String, ArgValue::String(_)) => {}
            (ArgType::StaticEnum(allowed), ArgValue::String(s)) => {
                if !allowed.iter().any(|v| v == s) {
                    diagnostics.push(Diagnostic {
                        level: Level::Error,
                        code: ErrorCode::InvalidStaticEnumValue,
                        message: format!("'{}' is not in static enum", s),
                        span: span.clone(),
                        suggestion: Some(format!("allowed: {}", allowed.join(", "))),
                    });
                }
            }
            (ArgType::DynamicEnum(_), ArgValue::String(_)) => {}
            _ => diagnostics.push(Diagnostic {
                level: Level::Error,
                code: ErrorCode::InvalidType,
                message: format!("invalid type for arg '{}'", name),
                span: span.clone(),
                suggestion: None,
            }),
        }
    }
}

pub(super) fn eval_dynamic_args(
    actual: &HashMap<String, ArgValue>,
    spec: &[(String, ArgSpec)],
    span: Span,
    ctx: &EvalContext,
    diagnostics: &mut Vec<Diagnostic>,
) {
    for (name, arg_spec) in spec {
        if let ArgType::DynamicEnum(dynamic_name) = &arg_spec.arg_type {
            if let Some(ArgValue::String(value)) = actual.get(name) {
                let Some(allowed_values) = ctx.dynamic_values.get(*dynamic_name) else {
                    diagnostics.push(Diagnostic {
                        level: Level::Error,
                        code: ErrorCode::InvalidDynamicEnumValue,
                        message: format!("dynamic enum '{}' is not provided", dynamic_name),
                        span: span.clone(),
                        suggestion: None,
                    });
                    continue;
                };

                if !allowed_values.contains(value) {
                    let mut candidates: Vec<_> = allowed_values.iter().cloned().collect();
                    candidates.sort();
                    diagnostics.push(Diagnostic {
                        level: Level::Error,
                        code: ErrorCode::InvalidDynamicEnumValue,
                        message: format!("'{}' is not in dynamic enum '{}'", value, dynamic_name),
                        span: span.clone(),
                        suggestion: Some(format!("allowed: {}", candidates.join(", "))),
                    });
                }
            }
        }
    }
}

pub(super) fn parse_inline_exts(text: &str) -> Vec<InlineExt> {
    let mut out = Vec::new();
    let bytes = text.as_bytes();
    let mut i = 0;

    while i < bytes.len() {
        if bytes[i] != b'{' {
            i += 1;
            continue;
        }
        let start = i;
        let mut j = i + 1;
        while j < bytes.len() && bytes[j] != b'}' {
            j += 1;
        }
        if j >= bytes.len() {
            break;
        }

        let content = &text[i + 1..j];
        let parts = content.split_whitespace().collect::<Vec<_>>();
        if !parts.is_empty() {
            let name = parts[0].to_string();
            let args = parse_args(&parts[1..], start, &mut Vec::new());
            out.push(InlineExt {
                name,
                args,
                span: Span { start, end: j + 1 },
            });
        }

        i = j + 1;
    }

    out
}