Skip to main content

md_tmpl_core/frontmatter/
params.rs

1//! Parameter declaration parsing for frontmatter `params:` blocks.
2//!
3//! Handles both inline (`[name = str, count = int]`) and block
4//! (`- name = str`) formats, including default values and nested types.
5
6use alloc::{
7    boxed::Box,
8    string::{String, ToString},
9    sync::Arc,
10    vec::Vec,
11};
12
13use super::ImportedNamespace;
14use crate::{
15    compat::HashMap,
16    error::TemplateError,
17    types::{VarDecl, VarType},
18    value::Value,
19};
20
21/// Join YAML continuation lines into one logical line per top-level entry.
22///
23/// Any line starting with whitespace is appended to the preceding logical line.
24///
25/// Blank lines and full-line `#` comments are layout/documentation only: they
26/// are skipped entirely and, crucially, do **not** terminate an in-progress
27/// block list. This lets block entries be separated by blank lines or
28/// interleaved with comments for readability (e.g. a documented `consts:`
29/// block) while still joining every entry onto its section's logical line
30/// instead of orphaning entries after the first blank onto a stray line that
31/// no section prefix matches.
32pub(crate) fn join_continuation_lines(block: &str) -> Vec<String> {
33    let mut logical: Vec<String> = Vec::new();
34    for raw in block.lines() {
35        let trimmed = raw.trim();
36        // Skip blanks and full-line comments without breaking continuation.
37        if trimmed.is_empty() || trimmed.starts_with(crate::consts::FM_COMMENT_PREFIX) {
38            continue;
39        }
40        // For block list items, strip a YAML-consistent inline `#` comment from
41        // the item scalar before joining (see `strip_list_item_comment`). Other
42        // lines keep their original form so existing layout behavior is intact.
43        let cleaned: Option<String> = match trimmed.strip_prefix(crate::consts::LIST_ITEM_PREFIX) {
44            Some(scalar) => {
45                let kept = strip_list_item_comment(scalar.trim_start());
46                if kept.is_empty() {
47                    // `- # comment` → empty list item; skip like a comment line.
48                    continue;
49                }
50                Some(alloc::format!("{}{kept}", crate::consts::LIST_ITEM_PREFIX))
51            }
52            None => None,
53        };
54        if raw.starts_with(' ') || raw.starts_with('\t') {
55            // Continuation of previous logical line.
56            if let Some(prev) = logical.last_mut() {
57                prev.push(' ');
58                prev.push_str(cleaned.as_deref().unwrap_or(trimmed));
59            } else {
60                logical.push(cleaned.unwrap_or_else(|| raw.to_string()));
61            }
62        } else {
63            logical.push(cleaned.unwrap_or_else(|| raw.to_string()));
64        }
65    }
66    logical
67}
68
69/// Strip a YAML-consistent inline `#` comment from a block list-item scalar.
70///
71/// `scalar` is the text following the `- ` block-sequence marker. Matches real
72/// YAML plain-scalar comment semantics: a `#` that begins the scalar or is
73/// preceded by whitespace starts a comment running to end of line.
74///
75/// A scalar wholly wrapped in a YAML quote (`"..."` / `'...'`) protects any `#`
76/// inside the quotes — only a `#` appearing after the closing quote is treated
77/// as a comment. This is intentionally NOT md-tmpl-string-aware: the `"` inside
78/// an unquoted (plain) scalar such as `x = str := "a # b"` are ordinary
79/// characters, so ` #` still starts a comment, mirroring real YAML.
80///
81/// The returned slice has trailing whitespace trimmed when a comment was
82/// removed.
83pub(crate) fn strip_list_item_comment(scalar: &str) -> &str {
84    match scalar.chars().next() {
85        Some(crate::consts::QUOTE_DOUBLE) => {
86            match closing_double_quote_end(scalar) {
87                Some(end) => match find_yaml_comment(&scalar[end..], false) {
88                    Some(pos) => scalar[..end + pos].trim_end(),
89                    None => scalar,
90                },
91                // Unterminated quote — leave untouched; downstream reports it.
92                None => scalar,
93            }
94        }
95        Some(crate::consts::QUOTE_SINGLE) => match closing_single_quote_end(scalar) {
96            Some(end) => match find_yaml_comment(&scalar[end..], false) {
97                Some(pos) => scalar[..end + pos].trim_end(),
98                None => scalar,
99            },
100            None => scalar,
101        },
102        _ => match find_yaml_comment(scalar, true) {
103            Some(pos) => scalar[..pos].trim_end(),
104            None => scalar,
105        },
106    }
107}
108
109/// Find the byte index of a `#` that begins a YAML comment.
110///
111/// A `#` starts a comment when preceded by ASCII whitespace, or — when
112/// `start_is_comment` is `true` — when it is the first character of the string.
113fn find_yaml_comment(s: &str, start_is_comment: bool) -> Option<usize> {
114    let mut prev: Option<char> = None;
115    for (i, c) in s.char_indices() {
116        if c == crate::consts::FM_COMMENT_PREFIX {
117            let is_comment = match prev {
118                None => start_is_comment,
119                Some(p) => p == ' ' || p == '\t',
120            };
121            if is_comment {
122                return Some(i);
123            }
124        }
125        prev = Some(c);
126    }
127    None
128}
129
130/// Return the byte index just past the closing `"` of a YAML double-quoted
131/// scalar that starts at index 0, honoring `\`-escapes. Returns `None` if the
132/// quote is never closed.
133fn closing_double_quote_end(s: &str) -> Option<usize> {
134    let mut escaped = false;
135    for (i, c) in s.char_indices().skip(1) {
136        if escaped {
137            escaped = false;
138        } else if c == crate::consts::BACKSLASH {
139            escaped = true;
140        } else if c == crate::consts::QUOTE_DOUBLE {
141            return Some(i + c.len_utf8());
142        }
143    }
144    None
145}
146
147/// Return the byte index just past the closing `'` of a YAML single-quoted
148/// scalar that starts at index 0. In YAML single-quoted scalars, `''` is an
149/// escaped literal quote. Returns `None` if the quote is never closed.
150fn closing_single_quote_end(s: &str) -> Option<usize> {
151    let mut it = s.char_indices().skip(1).peekable();
152    while let Some((i, c)) = it.next() {
153        if c == crate::consts::QUOTE_SINGLE {
154            if it.peek().map(|&(_, c2)| c2) == Some(crate::consts::QUOTE_SINGLE) {
155                it.next(); // consume the second quote of an escaped `''`
156                continue;
157            }
158            return Some(i + c.len_utf8());
159        }
160    }
161    None
162}
163
164/// Map of param name → `(import_stem, imported_type_name)` for params whose
165/// top-level type is a dotted import reference resolving to an **enum**.
166///
167/// Enables codegen backends (currently the Rust proc-macro) to reference the
168/// imported, already-generated type directly instead of emitting a duplicate
169/// per-template copy.
170pub(crate) type ImportedTypeRefs = HashMap<String, ImportedTypeRef>;
171
172/// A single imported-enum reference: `(import_stem, type_name)`.
173///
174/// E.g. a param typed `role = artist.WorkRole` yields `("artist", "WorkRole")`.
175pub(crate) type ImportedTypeRef = (String, String);
176
177/// A parsed declaration paired with the optional imported-enum reference for
178/// its top-level type (see [`imported_enum_type_ref`]).
179type ParsedDeclaration = (VarDecl, Option<ImportedTypeRef>);
180
181/// Parse the value part after `params:` or `consts:`.
182///
183/// Supports both inline and block list formats:
184/// - Inline: `[name = str, count = int]`
185///
186/// Returns the parsed declarations plus a map of any params whose top-level
187/// type is a dotted import reference to an enum (see [`ImportedTypeRefs`]).
188pub(crate) fn parse_declarations(
189    rest: &str,
190    type_aliases: &HashMap<String, VarType>,
191    resolved_imports: &HashMap<String, ImportedNamespace>,
192    is_constant: bool,
193    available_consts: &HashMap<String, Value>,
194) -> Result<(Vec<VarDecl>, ImportedTypeRefs), TemplateError> {
195    let rest = rest.trim();
196    if rest.is_empty() {
197        // `params:` with no value and no continuation lines → empty params.
198        return Ok((vec![], HashMap::new()));
199    }
200
201    // Strip only the outermost `[` and `]` (inline YAML flow sequence).
202    let inner = rest
203        .strip_prefix(crate::consts::BRACKET_OPEN)
204        .and_then(|s| s.strip_suffix(crate::consts::BRACKET_CLOSE))
205        .unwrap_or(rest);
206
207    // Handle block list format: entries are `- name = type` joined by spaces
208    // (after continuation line joining, the `- ` markers are preserved).
209    let entries = if inner.contains("- ") {
210        // Split on ` - ` to separate entries, then strip leading `- ` from
211        // the first entry if present.
212        let mut result = Vec::new();
213        for part in inner.split(" - ") {
214            let part = part.trim().strip_prefix('-').unwrap_or(part).trim();
215            if !part.is_empty() {
216                result.push(part.to_string());
217            }
218        }
219        result
220    } else {
221        // Inline format: split on commas at bracket-depth 0.
222        split_at_depth_zero(inner)
223            .into_iter()
224            .map(ToString::to_string)
225            .collect()
226    };
227
228    let mut decls = Vec::new();
229    let mut import_refs = ImportedTypeRefs::new();
230    let mut seen_names = crate::compat::HashSet::new();
231    let mut current_consts = available_consts.clone();
232    for entry in &entries {
233        let e = entry.trim();
234        // A decl may be wrapped in an outer YAML quoted scalar
235        // (e.g. `"name = str := \"a # b\""`). Strip those quotes and apply YAML
236        // double-quote unescaping so the inner md-tmpl declaration is recovered
237        // (this protects `#` inside the outer quotes from comment stripping).
238        let unescaped =
239            crate::consts::strip_string_literal(e).map(crate::consts::unescape_string_literal);
240        let trimmed = unescaped.as_deref().map_or(e, str::trim);
241        if let Some((decl, import_ref)) = parse_single_declaration(
242            trimmed,
243            type_aliases,
244            resolved_imports,
245            is_constant,
246            &mut current_consts,
247            &mut seen_names,
248        )? {
249            if let Some(r) = import_ref {
250                import_refs.insert(decl.name.clone(), r);
251            }
252            decls.push(decl);
253        }
254    }
255
256    Ok((decls, import_refs))
257}
258
259/// If `type_str` is a bare dotted import reference (`stem.TypeName`) resolving
260/// to an **enum** in `resolved_imports`, return `(stem, type_name)`.
261///
262/// Only whole-annotation plain enum references qualify — not nested positions,
263/// options, lists, or structs. Mirrors the dotted-path resolution in
264/// [`parse_type_annotation`].
265fn imported_enum_type_ref(
266    type_str: &str,
267    resolved_imports: &HashMap<String, ImportedNamespace>,
268) -> Option<ImportedTypeRef> {
269    let s = crate::consts::strip_string_literal(type_str.trim())
270        .unwrap_or(type_str.trim())
271        .trim();
272    let dot = s.find(crate::consts::PATH_SEP)?;
273    let stem = &s[..dot];
274    let type_name = &s[dot + crate::consts::PATH_SEP.len_utf8()..];
275    let ns = resolved_imports.get(stem)?;
276    let var_type = ns
277        .type_aliases
278        .get(type_name)
279        .or_else(|| ns.param_types.get(type_name))?;
280    matches!(var_type, VarType::Enum(_)).then(|| (stem.to_string(), type_name.to_string()))
281}
282
283/// Parse a single declaration entry (e.g. `name = str := "default"`) into a
284/// [`VarDecl`], plus the optional imported-enum reference for its top-level
285/// type (see [`imported_enum_type_ref`]).
286fn parse_single_declaration(
287    trimmed: &str,
288    type_aliases: &HashMap<String, VarType>,
289    resolved_imports: &HashMap<String, ImportedNamespace>,
290    is_constant: bool,
291    current_consts: &mut HashMap<String, Value>,
292    seen_names: &mut crate::compat::HashSet<String>,
293) -> Result<Option<ParsedDeclaration>, TemplateError> {
294    if trimmed.is_empty() {
295        return Ok(None);
296    }
297
298    // Find `=` at depth 0 to split name from type+default.
299    let Some(eq_pos) = find_char_at_depth_zero(trimmed, crate::consts::EQUALS) else {
300        let label = if is_constant { "constant" } else { "param" };
301        return Err(TemplateError::syntax(format!(
302            "{label} '{trimmed}' is missing a type annotation (expected 'name = type')"
303        )));
304    };
305
306    let name = trimmed[..eq_pos].trim().to_string();
307    let type_and_default = trimmed[eq_pos + 1..].trim();
308
309    // If the matched `=` is actually the `=` of a `:=` operator, the declaration
310    // supplies a default but no explicit type (e.g. `x := "hello"`).
311    if eq_pos > 0 && trimmed.as_bytes()[eq_pos - 1] == crate::consts::COLON_BYTE {
312        let label = if is_constant { "constant" } else { "param" };
313        let bare_name = trimmed[..eq_pos - 1].trim();
314        return Err(TemplateError::syntax(format!(
315            "{label} '{bare_name}' must have an explicit type (expected 'name = type := value')"
316        )));
317    }
318
319    // Check duplicate names.
320    if !seen_names.insert(name.clone()) {
321        let err = if is_constant {
322            crate::consts::ERR_DUPLICATE_CONST
323        } else {
324            crate::consts::ERR_DUPLICATE_PARAM
325        };
326        return Err(TemplateError::syntax(format!("{err}: '{name}'")));
327    }
328
329    // Check reserved keywords.
330    if crate::consts::RESERVED_NAMES.contains(&name.as_str()) {
331        return Err(TemplateError::syntax(format!(
332            "{}: '{name}'",
333            crate::consts::ERR_RESERVED_KEYWORD
334        )));
335    }
336
337    // Find `:=` at depth 0 to split type from default value.
338    let (type_str, default_part) =
339        if let Some(assign_pos) = find_assign_default_at_depth_zero(type_and_default) {
340            (
341                type_and_default[..assign_pos].trim(),
342                Some(type_and_default[assign_pos + 2..].trim()),
343            )
344        } else {
345            (type_and_default, None)
346        };
347
348    let var_type = parse_type_annotation(type_str, type_aliases, resolved_imports)
349        .map_err(|e| TemplateError::syntax(format!("declaration '{name}': {e}")))?;
350
351    let default_value = if let Some(dp) = default_part {
352        let default = parse_default_value_full(
353            dp,
354            &var_type,
355            current_consts,
356            type_aliases,
357            resolved_imports,
358        )
359        .or_else(|| resolve_const_default(dp, current_consts))
360        .or_else(|| resolve_kinds_default(dp, type_aliases, resolved_imports))
361        .ok_or_else(|| {
362            // A qualified `Type.Variant` reference is only valid in expression
363            // position; in a default it must be the bare variant name.
364            if let Some(msg) = qualified_variant_default_error(dp, &var_type) {
365                return TemplateError::syntax(format!("declaration '{name}': {msg}"));
366            }
367            TemplateError::syntax(format!(
368                "invalid default value '{dp}' for declaration '{name}' (strings must be quoted)"
369            ))
370        })?;
371        current_consts.insert(name.clone(), default.clone());
372        Some(default)
373    } else {
374        None
375    };
376
377    // For constants, the default value is mandatory.
378    if is_constant && default_value.is_none() {
379        return Err(TemplateError::syntax(format!(
380            "constant '{name}' is missing a value (expected 'name = type := value')"
381        )));
382    }
383
384    // Validate that the default value matches the declared type.
385    if let Some(ref default) = default_value
386        && !var_type.matches(default)
387    {
388        let label = if is_constant { "constant" } else { "param" };
389        return Err(TemplateError::syntax(format!(
390            "{label} '{name}': value has type '{}' but declared type is '{var_type}'",
391            default.type_name()
392        )));
393    }
394
395    // Only params (not consts) benefit from imported-type reuse in codegen.
396    let import_ref = if is_constant {
397        None
398    } else {
399        imported_enum_type_ref(type_str, resolved_imports)
400    };
401
402    Ok(Some((
403        VarDecl {
404            name,
405            var_type,
406            default_value,
407        },
408        import_ref,
409    )))
410}
411
412// Compatibility wrapper for `params:` removed as it is now unused.
413
414/// Strip enclosing compound type delimiter pair `(...)`.
415pub(crate) fn strip_type_brackets(s: &str) -> Option<&str> {
416    if let (Some(inner), true) = (
417        s.strip_prefix(crate::consts::PAREN_OPEN),
418        s.ends_with(crate::consts::PAREN_CLOSE),
419    ) {
420        Some(&inner[..inner.len() - 1])
421    } else {
422        None
423    }
424}
425
426/// Split a string on commas at bracket-depth 0, ignoring commas inside quoted
427/// string literals.
428///
429/// Delimiters (brackets, braces, parens, angle brackets, and the separating
430/// comma) that appear inside a `"..."` or `'...'` string literal are treated as
431/// literal characters. This lets struct/list default values contain quoted
432/// strings with embedded commas or brackets (e.g. `{msg = "a, b", n = 1}`)
433/// without the field separator being misdetected.
434pub(crate) fn split_at_depth_zero(input: &str) -> Vec<&str> {
435    use crate::consts::{
436        ANGLE_CLOSE, ANGLE_OPEN, BRACE_CLOSE, BRACE_OPEN, BRACKET_CLOSE, BRACKET_OPEN, COMMA,
437        PAREN_CLOSE, PAREN_OPEN, QUOTE_DOUBLE, QUOTE_SINGLE,
438    };
439    let mut entries = Vec::new();
440    let mut depth: u32 = 0;
441    let mut start = 0;
442    // When inside a string literal, holds the opening quote char; delimiters are
443    // ignored until the matching closing quote is seen.
444    let mut in_quote: Option<char> = None;
445    // When inside a quote, tracks whether the previous char was an unescaped
446    // backslash (which escapes the current char, e.g. `\"` does not close).
447    let mut escaped = false;
448    for (i, ch) in input.char_indices() {
449        if let Some(q) = in_quote {
450            if escaped {
451                escaped = false;
452            } else if ch == crate::consts::BACKSLASH {
453                escaped = true;
454            } else if ch == q {
455                in_quote = None;
456            }
457            continue;
458        }
459        match ch {
460            QUOTE_DOUBLE | QUOTE_SINGLE => in_quote = Some(ch),
461            ANGLE_OPEN | BRACKET_OPEN | PAREN_OPEN | BRACE_OPEN => depth += 1,
462            ANGLE_CLOSE | BRACKET_CLOSE | PAREN_CLOSE | BRACE_CLOSE => {
463                depth = depth.saturating_sub(1);
464            }
465            COMMA if depth == 0 => {
466                entries.push(&input[start..i]);
467                start = i + 1;
468            }
469            _ => {}
470        }
471    }
472    entries.push(&input[start..]);
473    entries
474}
475
476/// Find the first occurrence of `target` at bracket-depth 0.
477pub(crate) fn find_char_at_depth_zero(input: &str, target: char) -> Option<usize> {
478    use crate::consts::{
479        ANGLE_CLOSE, ANGLE_OPEN, BRACE_CLOSE, BRACE_OPEN, BRACKET_CLOSE, BRACKET_OPEN, PAREN_CLOSE,
480        PAREN_OPEN,
481    };
482    let mut depth: u32 = 0;
483    for (i, ch) in input.char_indices() {
484        match ch {
485            ANGLE_OPEN | BRACKET_OPEN | PAREN_OPEN | BRACE_OPEN => depth += 1,
486            ANGLE_CLOSE | BRACKET_CLOSE | PAREN_CLOSE | BRACE_CLOSE => {
487                depth = depth.saturating_sub(1);
488            }
489            c if c == target && depth == 0 => return Some(i),
490            _ => {}
491        }
492    }
493    None
494}
495
496/// Find the position of `:=` at bracket-depth zero.
497fn find_assign_default_at_depth_zero(input: &str) -> Option<usize> {
498    use crate::consts::{
499        ANGLE_CLOSE_BYTE, ANGLE_OPEN_BYTE, BRACE_CLOSE_BYTE, BRACE_OPEN_BYTE, BRACKET_CLOSE_BYTE,
500        BRACKET_OPEN_BYTE, COLON_BYTE, EQUALS_BYTE, PAREN_CLOSE_BYTE, PAREN_OPEN_BYTE,
501    };
502    let mut depth: u32 = 0;
503    let bytes = input.as_bytes();
504    for (i, &b) in bytes.iter().enumerate() {
505        match b {
506            ANGLE_OPEN_BYTE | BRACKET_OPEN_BYTE | PAREN_OPEN_BYTE | BRACE_OPEN_BYTE => depth += 1,
507            ANGLE_CLOSE_BYTE | BRACKET_CLOSE_BYTE | PAREN_CLOSE_BYTE | BRACE_CLOSE_BYTE => {
508                depth = depth.saturating_sub(1);
509            }
510            COLON_BYTE if depth == 0 && bytes.get(i + 1) == Some(&EQUALS_BYTE) => return Some(i),
511            _ => {}
512        }
513    }
514    None
515}
516
517/// Parse a type annotation string into a [`VarType`].
518///
519/// Supported forms:
520/// - `str` → [`VarType::Str`]
521/// - `bool` → [`VarType::Bool`]
522/// - `int` → [`VarType::Int`]
523/// - `float` → [`VarType::Float`]
524/// - `list(name = str, count = int)` → [`VarType::List`] with field declarations
525/// - `struct(key = str)` → [`VarType::Struct`] with field declarations
526/// - `enum(A, B(field = type))` → [`VarType::Enum`] with variant declarations
527///
528/// # Errors
529///
530/// Returns an error string if the type annotation is malformed or
531/// references an unknown type name.
532fn starts_with_compound_type(s: &str, keyword: &str) -> bool {
533    if let Some(rest) = s.strip_prefix(keyword) {
534        let rest = rest.trim_start();
535        rest.starts_with(crate::consts::PAREN_OPEN)
536    } else {
537        false
538    }
539}
540
541/// Parses a type annotation string into a `VarType`.
542///
543/// # Errors
544/// Returns an error string if the type annotation syntax is invalid or references an unknown type alias.
545pub fn parse_type_annotation(
546    s: &str,
547    type_aliases: &HashMap<String, VarType>,
548    resolved_imports: &HashMap<String, ImportedNamespace>,
549) -> Result<VarType, String> {
550    use crate::consts::{
551        ANGLE_OPEN, BRACKET_OPEN, ERR_COMPOUND_BRACKETS_PROHIBITED, TYPE_BOOL, TYPE_ENUM,
552        TYPE_FLOAT, TYPE_INT, TYPE_LIST, TYPE_OPTION, TYPE_STR, TYPE_STRUCT, TYPE_TMPL,
553    };
554
555    let s = crate::consts::strip_string_literal(s.trim())
556        .unwrap_or(s.trim())
557        .trim();
558
559    for kw in &[TYPE_LIST, TYPE_STRUCT, TYPE_ENUM, TYPE_TMPL, TYPE_OPTION] {
560        if let Some(rest) = s.strip_prefix(kw) {
561            let rest_trimmed = rest.trim_start();
562            if rest_trimmed.starts_with(ANGLE_OPEN) || rest_trimmed.starts_with(BRACKET_OPEN) {
563                return Err(format!(
564                    "compound type '{kw}': {ERR_COMPOUND_BRACKETS_PROHIBITED}"
565                ));
566            }
567        }
568    }
569
570    // Check type aliases first (own or inherited).
571    if let Some(ty) = type_aliases.get(s) {
572        return Ok(ty.clone());
573    }
574
575    // Check dotted import paths: `stem.TypeName`.
576    if let Some(dot_pos) = s.find(crate::consts::PATH_SEP) {
577        let stem = &s[..dot_pos];
578        let type_name = &s[dot_pos + 1..];
579        if let Some(ns) = resolved_imports.get(stem) {
580            if let Some(ty) = ns.type_aliases.get(type_name) {
581                return Ok(ty.clone());
582            }
583            if let Some(ty) = ns.param_types.get(type_name) {
584                return Ok(ty.clone());
585            }
586            return Err(format!("import '{stem}' has no type '{type_name}'"));
587        }
588    }
589
590    if s == TYPE_STR {
591        Ok(VarType::Str)
592    } else if s == TYPE_BOOL {
593        Ok(VarType::Bool)
594    } else if s == TYPE_INT {
595        Ok(VarType::Int)
596    } else if s == TYPE_FLOAT {
597        Ok(VarType::Float)
598    } else if starts_with_compound_type(s, TYPE_LIST) {
599        parse_compound_type_list(s, type_aliases, resolved_imports)
600    } else if starts_with_compound_type(s, TYPE_STRUCT) {
601        parse_compound_type_struct(s, type_aliases, resolved_imports)
602    } else if starts_with_compound_type(s, TYPE_ENUM) {
603        parse_enum_type(s, type_aliases, resolved_imports)
604    } else if starts_with_compound_type(s, TYPE_TMPL) {
605        parse_tmpl_type(s, type_aliases, resolved_imports)
606    } else if starts_with_compound_type(s, TYPE_OPTION) {
607        parse_option_type(s, type_aliases, resolved_imports)
608    } else {
609        Err(format!("unknown type '{s}'"))
610    }
611}
612
613/// Parse an enum type like `enum(Confirmed(evidence = list(text = str)), Inconclusive)`.
614fn parse_enum_type(
615    s: &str,
616    type_aliases: &HashMap<String, VarType>,
617    resolved_imports: &HashMap<String, ImportedNamespace>,
618) -> Result<VarType, String> {
619    use crate::{consts::TYPE_ENUM, types::VariantDecl};
620
621    let rest = s.strip_prefix(TYPE_ENUM).unwrap_or("").trim();
622    let Some(inner) = strip_type_brackets(rest) else {
623        return Err(format!("malformed enum type: '{s}'"));
624    };
625    let entries = split_at_depth_zero(inner);
626    let mut variants = Vec::new();
627    for entry in entries {
628        let entry = entry.trim();
629        if entry.is_empty() {
630            continue;
631        }
632        if let (Some(open_idx), Some(close_idx)) = (
633            entry.find(crate::consts::PAREN_OPEN),
634            entry.rfind(crate::consts::PAREN_CLOSE),
635        ) {
636            let name = entry[..open_idx].trim().to_string();
637            let fields_str = &entry[open_idx + 1..close_idx];
638            let fields = parse_field_declarations(fields_str, type_aliases, resolved_imports)?;
639            if fields.iter().any(|f| f.name.is_empty()) {
640                return Err(
641                    "enum struct variant must use named fields (e.g. Variant(name = str))"
642                        .to_string(),
643                );
644            }
645            variants.push(VariantDecl { name, fields });
646            continue;
647        }
648        variants.push(VariantDecl {
649            name: entry.to_string(),
650            fields: vec![],
651        });
652    }
653    if variants.is_empty() {
654        return Err("enum must have at least one variant".to_string());
655    }
656    // Reject variant names that shadow builtin type keywords.
657    for v in &variants {
658        if crate::consts::RESERVED_NAMES.contains(&v.name.as_str()) {
659            return Err(format!(
660                "enum variant name '{}' shadows a builtin type keyword",
661                v.name
662            ));
663        }
664    }
665    Ok(VarType::Enum(variants))
666}
667
668/// Parse a compound type like `list(name = str, count = int)`.
669fn parse_compound_type_list(
670    s: &str,
671    type_aliases: &HashMap<String, VarType>,
672    resolved_imports: &HashMap<String, ImportedNamespace>,
673) -> Result<VarType, String> {
674    use crate::consts::TYPE_LIST;
675
676    let rest = s.strip_prefix(TYPE_LIST).unwrap_or("").trim();
677    let Some(inner) = strip_type_brackets(rest) else {
678        return Err(format!("malformed list type: '{s}'"));
679    };
680    let fields = parse_field_declarations(inner, type_aliases, resolved_imports)?;
681    if fields.is_empty() {
682        return Err("untyped list() is not allowed; must specify element type or fields (e.g., list(str) or list(name = str))".to_string());
683    }
684    if fields.len() > 1 && fields.iter().any(|f| f.name.is_empty()) {
685        return Err(
686            "list with multiple fields must use named fields (e.g. list(name = str, count = int))"
687                .to_string(),
688        );
689    }
690    // Reject literal raw struct declarations inside list definitions (e.g. list(struct(name = str, count = int))).
691    // Users should write named fields directly (e.g. list(name = str, count = int)) or reference a strong Type alias.
692    let inner_trimmed = inner.trim();
693    if inner_trimmed.starts_with(crate::consts::TYPE_STRUCT_ANGLE_PREFIX)
694        || inner_trimmed.starts_with(crate::consts::TYPE_STRUCT_PREFIX)
695        || inner_trimmed.starts_with(crate::consts::TYPE_STRUCT_BRACKET_PREFIX)
696        || inner_trimmed.starts_with(crate::consts::TYPE_STRUCT_SPACE_PREFIX)
697    {
698        return Err(
699            "list(struct(..)) is redundant; use named fields directly: list(name = str, count = int)"
700                .to_string(),
701        );
702    }
703    // If the inner type resolved to a strong struct alias (e.g. list(MyStruct)),
704    // unwrap the struct fields directly into the list fields.
705    if fields.len() == 1 && fields[0].name.is_empty() {
706        if let VarType::Struct(ref struct_fields) = fields[0].var_type {
707            return Ok(VarType::List(struct_fields.clone()));
708        }
709    }
710    Ok(VarType::List(fields))
711}
712
713/// Parse a compound type like `struct(key = str, value = int)`.
714fn parse_compound_type_struct(
715    s: &str,
716    type_aliases: &HashMap<String, VarType>,
717    resolved_imports: &HashMap<String, ImportedNamespace>,
718) -> Result<VarType, String> {
719    use crate::consts::TYPE_STRUCT;
720
721    let rest = s.strip_prefix(TYPE_STRUCT).unwrap_or("").trim();
722    let Some(inner) = strip_type_brackets(rest) else {
723        return Err(format!("malformed struct type: '{s}'"));
724    };
725    let fields = parse_field_declarations(inner, type_aliases, resolved_imports)?;
726    if fields.is_empty() {
727        return Err(
728            "untyped struct() is not allowed; must specify fields (e.g., struct(name = str))"
729                .to_string(),
730        );
731    }
732    if fields.iter().any(|f| f.name.is_empty()) {
733        return Err(
734            "struct must use named fields (e.g. struct(name = str, count = int))".to_string(),
735        );
736    }
737    Ok(VarType::Struct(fields))
738}
739
740/// Parse a tmpl type like `tmpl(name = str, count = int)`.
741fn parse_tmpl_type(
742    s: &str,
743    type_aliases: &HashMap<String, VarType>,
744    resolved_imports: &HashMap<String, ImportedNamespace>,
745) -> Result<VarType, String> {
746    use crate::consts::TYPE_TMPL;
747
748    let rest = s.strip_prefix(TYPE_TMPL).unwrap_or("").trim();
749    let Some(inner) = strip_type_brackets(rest) else {
750        return Err(format!("malformed tmpl type: '{s}'"));
751    };
752    let fields = parse_field_declarations(inner, type_aliases, resolved_imports)?;
753    if fields.iter().any(|f| f.name.is_empty()) {
754        return Err("tmpl must use named fields (e.g. tmpl(name = str, count = int))".to_string());
755    }
756    Ok(VarType::Tmpl(fields))
757}
758
759/// Parse `option(T)` into [`VarType::Option`].
760fn parse_option_type(
761    s: &str,
762    type_aliases: &HashMap<String, VarType>,
763    resolved_imports: &HashMap<String, ImportedNamespace>,
764) -> Result<VarType, String> {
765    use crate::consts::TYPE_OPTION;
766
767    let rest = s.strip_prefix(TYPE_OPTION).unwrap_or("").trim();
768    let Some(inner) = strip_type_brackets(rest) else {
769        return Err(format!("malformed option type: '{s}'"));
770    };
771    let inner = inner.trim();
772    if inner.is_empty() {
773        return Err("option() requires an inner type (e.g. option(str))".to_string());
774    }
775    let inner_type = parse_type_annotation(inner, type_aliases, resolved_imports)?;
776    Ok(VarType::Option(Box::new(inner_type)))
777}
778
779/// Parse field declarations like `name = str, count = int` into [`VarDecl`]s.
780fn parse_field_declarations(
781    inner: &str,
782    type_aliases: &HashMap<String, VarType>,
783    resolved_imports: &HashMap<String, ImportedNamespace>,
784) -> Result<Vec<VarDecl>, String> {
785    let entries = split_at_depth_zero(inner);
786    let mut decls = Vec::new();
787    for f in &entries {
788        let f = f.trim();
789        if f.is_empty() {
790            continue;
791        }
792        let (name, type_str) =
793            if let Some(eq_pos) = find_char_at_depth_zero(f, crate::consts::EQUALS) {
794                (f[..eq_pos].trim().to_string(), f[eq_pos + 1..].trim())
795            } else {
796                (String::new(), f)
797            };
798        let var_type = parse_type_annotation(type_str, type_aliases, resolved_imports)?;
799        // Reject reserved names (incl. codegen collision guards like __self).
800        if !name.is_empty() && crate::consts::RESERVED_NAMES.contains(&name.as_str()) {
801            return Err(format!("{}: '{name}'", crate::consts::ERR_RESERVED_KEYWORD));
802        }
803        decls.push(VarDecl {
804            name,
805            var_type,
806            default_value: None,
807        });
808    }
809    Ok(decls)
810}
811
812/// Parse the *inner* content of a `{key = value, ...}` struct default into
813/// a [`Value::Struct`].
814///
815/// Uses `=` as the key-value separator (not `:`) and curly braces for
816/// delimiters.
817fn parse_struct_default(
818    inner: &str,
819    fields: &[VarDecl],
820    available_consts: &HashMap<String, Value>,
821    type_aliases: &HashMap<String, VarType>,
822    resolved_imports: &HashMap<String, ImportedNamespace>,
823) -> Value {
824    let entries = split_at_depth_zero(inner);
825    let mut map = HashMap::new();
826    for e in entries {
827        let e = e.trim();
828        if e.is_empty() {
829            continue;
830        }
831        if let Some(eq_pos) = find_char_at_depth_zero(e, crate::consts::EQUALS) {
832            let key = e[..eq_pos].trim();
833            let val_str = e[eq_pos + 1..].trim();
834            let field_type = fields
835                .iter()
836                .find(|d| d.name == key)
837                .map_or(&VarType::Str, |d| &d.var_type);
838            if let Some(v) = parse_default_value_full(
839                val_str,
840                field_type,
841                available_consts,
842                type_aliases,
843                resolved_imports,
844            ) {
845                map.insert(key.to_string(), v);
846            }
847        }
848    }
849    Value::Struct(Arc::new(map))
850}
851
852/// Resolve a const name used as a default value.
853///
854/// Looks up `name` in the available constants map, supporting both local
855/// const names (e.g. `MAX`) and imported const names (e.g. `lib.LIMIT`).
856/// Returns a clone of the const value if found.
857fn resolve_const_default(name: &str, available_consts: &HashMap<String, Value>) -> Option<Value> {
858    let name = name.trim();
859    if name.is_empty() {
860        return None;
861    }
862    available_consts.get(name).cloned()
863}
864
865/// Resolve a function expression default like `kinds(EnumType)` into a [`Value::List`].
866fn resolve_kinds_default(
867    expr: &str,
868    type_aliases: &HashMap<String, VarType>,
869    resolved_imports: &HashMap<String, ImportedNamespace>,
870) -> Option<Value> {
871    let s = expr.trim();
872    let inner = s
873        .strip_prefix(crate::consts::FN_KINDS)?
874        .strip_prefix(crate::consts::PAREN_OPEN)?
875        .strip_suffix(crate::consts::PAREN_CLOSE)?
876        .trim();
877    if inner.is_empty() {
878        return None;
879    }
880    let var_type = if let Some(dot_pos) = inner.find(crate::consts::PATH_SEP) {
881        let ns_name = &inner[..dot_pos];
882        let type_name = &inner[dot_pos + 1..];
883        resolved_imports.get(ns_name)?.type_aliases.get(type_name)
884    } else {
885        type_aliases.get(inner)
886    };
887    if let Some(VarType::Enum(variants)) = var_type {
888        let list: Vec<Value> = variants
889            .iter()
890            .map(|v| Value::Str(v.name.clone()))
891            .collect();
892        Some(Value::List(Arc::new(list)))
893    } else {
894        None
895    }
896}
897
898/// Parse a default value string into a [`Value`].
899///
900/// Supports:
901/// - Inline lists: `[1, 2, 3]` or `['a', 'b']`
902/// - Inline structs: `{key = value, key2 = value2}`
903/// - List of structs: `[{k = v1}, {k = v2}]`
904/// - Quoted strings: `"hello"` or `'hello'`
905/// - Integers, floats, booleans
906///
907/// Lists use `[]` and structs use `{}` with `=` as the key-value separator.
908#[cfg(test)]
909pub(crate) fn parse_default_value_with_type(
910    s: &str,
911    var_type: &VarType,
912    available_consts: &HashMap<String, Value>,
913) -> Option<Value> {
914    let empty_aliases = HashMap::new();
915    let empty_imports = HashMap::new();
916    parse_default_value_full(
917        s,
918        var_type,
919        available_consts,
920        &empty_aliases,
921        &empty_imports,
922    )
923}
924
925pub(crate) fn parse_default_value_full(
926    s: &str,
927    var_type: &VarType,
928    available_consts: &HashMap<String, Value>,
929    type_aliases: &HashMap<String, VarType>,
930    resolved_imports: &HashMap<String, ImportedNamespace>,
931) -> Option<Value> {
932    let s = s.trim();
933    if s.is_empty() {
934        return None;
935    }
936
937    // Handle list defaults: [a, b, c]
938    if s.starts_with(crate::consts::BRACKET_OPEN) && s.ends_with(crate::consts::BRACKET_CLOSE) {
939        let inner = &s[1..s.len() - 1];
940        if inner.trim().is_empty() {
941            return Some(Value::List(Arc::new(Vec::new())));
942        }
943        let entries = split_at_depth_zero(inner);
944        let mut list = Vec::new();
945        let elem_type = match var_type {
946            VarType::List(fields) => {
947                if fields.len() == 1 && fields[0].name.is_empty() {
948                    &fields[0].var_type
949                } else {
950                    var_type
951                }
952            }
953            _ => var_type,
954        };
955        for e in entries {
956            if let Some(v) = parse_default_value_full(
957                e,
958                elem_type,
959                available_consts,
960                type_aliases,
961                resolved_imports,
962            ) {
963                list.push(v);
964            }
965        }
966        return Some(Value::List(Arc::new(list)));
967    }
968
969    // Handle struct defaults: {key = value, ...}
970    if s.starts_with('{') && s.ends_with('}') {
971        let inner = &s[1..s.len() - 1].trim();
972        if inner.is_empty() {
973            return match var_type {
974                VarType::Struct(_) => Some(Value::Struct(Arc::new(HashMap::new()))),
975                _ => None,
976            };
977        }
978
979        let fields = match var_type {
980            VarType::Struct(f) | VarType::List(f) => f.as_slice(),
981            _ => &[],
982        };
983        return Some(parse_struct_default(
984            inner,
985            fields,
986            available_consts,
987            type_aliases,
988            resolved_imports,
989        ));
990    }
991
992    // Quoted string
993    if let Some(inner) = crate::consts::strip_string_literal(s) {
994        return Some(Value::Str(crate::consts::unescape_string_literal(inner)));
995    }
996
997    // Boolean
998    if s == crate::consts::LIT_TRUE {
999        return Some(Value::Bool(true));
1000    }
1001    if s == crate::consts::LIT_FALSE {
1002        return Some(Value::Bool(false));
1003    }
1004
1005    // Integer
1006    if let Ok(n) = s.parse::<i64>() {
1007        return Some(Value::Int(n));
1008    }
1009
1010    // Float
1011    if let Ok(n) = s.parse::<f64>() {
1012        return Some(Value::Float(n));
1013    }
1014
1015    // Handle option(T) defaults: None maps to Value::None, otherwise delegate
1016    // to the inner type.
1017    if let VarType::Option(inner) = var_type {
1018        if s == crate::consts::OPTION_NONE {
1019            return Some(Value::None);
1020        }
1021        return parse_default_value_full(
1022            s,
1023            inner,
1024            available_consts,
1025            type_aliases,
1026            resolved_imports,
1027        );
1028    }
1029
1030    // If the expected type is an Enum, handle variant identifiers.
1031    if let VarType::Enum(variants) = var_type {
1032        return parse_enum_default_value(
1033            s,
1034            variants,
1035            available_consts,
1036            type_aliases,
1037            resolved_imports,
1038        );
1039    }
1040
1041    if let Some(val) = resolve_const_default(s, available_consts) {
1042        return Some(val);
1043    }
1044    if let Some(val) = resolve_kinds_default(s, type_aliases, resolved_imports) {
1045        return Some(val);
1046    }
1047
1048    // Intentional removal of fallback: unquoted strings are no longer allowed
1049    // as default values. All string defaults must be explicitly quoted.
1050    None
1051}
1052
1053/// Return the enum variants for `var_type`, transparently unwrapping
1054/// `option(T)` so `option(Stage)` is treated like `Stage`. Returns `None` for
1055/// non-enum types.
1056fn enum_variants_of(var_type: &VarType) -> Option<&[crate::types::VariantDecl]> {
1057    match var_type {
1058        VarType::Enum(variants) => Some(variants),
1059        VarType::Option(inner) => enum_variants_of(inner),
1060        _ => None,
1061    }
1062}
1063
1064/// If `default` is a qualified `Type.Variant` reference for an enum-typed (or
1065/// `option(enum)`) declaration whose suffix names a real variant, return a
1066/// helpful error message. Qualified references are only valid in expression
1067/// position; defaults must use the bare variant name.
1068///
1069/// Returns `None` when the type is not an enum or the default is not a
1070/// qualified reference to one of its variants, so const/other fallbacks keep
1071/// their generic error.
1072fn qualified_variant_default_error(default: &str, var_type: &VarType) -> Option<String> {
1073    let variants = enum_variants_of(var_type)?;
1074    let (_, suffix) = default.rsplit_once(crate::consts::PATH_SEP)?;
1075    let suffix = suffix.trim();
1076    if variants.iter().any(|v| v.name == suffix) {
1077        Some(alloc::format!(
1078            "invalid enum default '{default}': use the bare variant name '{suffix}' \
1079             (a qualified 'Type.Variant' is only valid in expressions)"
1080        ))
1081    } else {
1082        None
1083    }
1084}
1085
1086/// Parse a default value for an enum variant — either a unit variant name
1087/// (e.g. `Active`) or a struct variant with fields (e.g. `Error(msg = "oops")`).
1088fn parse_enum_default_value(
1089    s: &str,
1090    variants: &[crate::types::VariantDecl],
1091    available_consts: &HashMap<String, Value>,
1092    type_aliases: &HashMap<String, VarType>,
1093    resolved_imports: &HashMap<String, ImportedNamespace>,
1094) -> Option<Value> {
1095    // Check for struct variant default: VariantName(field = value, ...)
1096    // Uses () to match the type declaration syntax and avoid ambiguity
1097    // with <> which is used for struct/list defaults.
1098    if let Some(open_pos) = s.find(crate::consts::PAREN_OPEN) {
1099        if s.ends_with(crate::consts::PAREN_CLOSE) {
1100            let variant_name = s[..open_pos].trim();
1101            let inner = &s[open_pos + 1..s.len() - 1];
1102            // Find the variant declaration.
1103            let variant = variants.iter().find(|v| v.name == variant_name);
1104            match variant {
1105                Some(v) if v.fields.is_empty() => {
1106                    return None; // Unit variant can't have fields
1107                }
1108                Some(v) => {
1109                    // Parse field values and build a tagged dict.
1110                    let entries = split_at_depth_zero(inner);
1111                    let mut map = HashMap::new();
1112                    map.insert(
1113                        crate::consts::ENUM_TAG_KEY.to_string(),
1114                        Value::Str(variant_name.to_string()),
1115                    );
1116                    for e in entries {
1117                        let e = e.trim();
1118                        if e.is_empty() {
1119                            continue;
1120                        }
1121                        if let Some(eq_pos) = find_char_at_depth_zero(e, crate::consts::EQUALS) {
1122                            let key = e[..eq_pos].trim();
1123                            let val_str = e[eq_pos + 1..].trim();
1124                            let field_type = v
1125                                .fields
1126                                .iter()
1127                                .find(|f| f.name == key)
1128                                .map_or(&VarType::Str, |f| &f.var_type);
1129                            if let Some(val) = parse_default_value_full(
1130                                val_str,
1131                                field_type,
1132                                available_consts,
1133                                type_aliases,
1134                                resolved_imports,
1135                            ) {
1136                                map.insert(key.to_string(), val);
1137                            }
1138                        }
1139                    }
1140                    return Some(Value::Struct(Arc::new(map)));
1141                }
1142                None => return None, // Unknown variant
1143            }
1144        }
1145    }
1146
1147    // Bare identifier — must be a known unit variant.
1148    let variant = variants.iter().find(|v| v.name == s);
1149    match variant {
1150        Some(v) if !v.fields.is_empty() => {
1151            // Struct variant without fields — reject.
1152            None
1153        }
1154        Some(_) => Some(Value::Str(s.to_string())),
1155        None => None, // Unknown variant name
1156    }
1157}
1158
1159#[cfg(test)]
1160pub(crate) fn parse_default_value(s: &str) -> Option<Value> {
1161    parse_default_value_with_type(s, &VarType::Str, &HashMap::new())
1162}
1163
1164#[cfg(test)]
1165#[path = "params_tests.rs"]
1166mod tests;