1mod imports;
21mod params;
22mod type_aliases;
23mod validation;
24
25use alloc::{
26 string::{String, ToString},
27 vec::Vec,
28};
29#[cfg(feature = "std")]
30use std::path::PathBuf;
31
32pub use imports::*;
33pub use params::parse_type_annotation;
34pub(crate) use params::*;
35pub(crate) use type_aliases::*;
36pub(crate) use validation::*;
37
38use crate::{
39 compat::HashMap,
40 consts::{
41 FM_ALLOW_UNUSED_PREFIX, FM_CONSTS_PREFIX, FM_DELIMITER, FM_DELIMITER_NEWLINE,
42 FM_DESC_PREFIX, FM_ENV_PREFIX, FM_IMPORTS_PREFIX, FM_NAME_PREFIX, FM_PARAMS_PREFIX,
43 FM_TYPES_PREFIX,
44 },
45 error::TemplateError,
46 frontmatter::params::parse_declarations,
47 types::{VarDecl, VarType},
48};
49
50#[derive(Debug, Clone)]
52pub struct Import {
53 pub stem: String,
55 #[cfg(feature = "std")]
57 pub path: PathBuf,
58 #[cfg(not(feature = "std"))]
60 pub path: alloc::string::String,
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct ImportedNamespace {
66 pub type_aliases: HashMap<String, VarType>,
68 pub param_types: HashMap<String, VarType>,
70 pub consts: HashMap<String, crate::value::Value>,
72}
73
74#[derive(Debug, Clone, Default)]
76pub struct Frontmatter {
77 pub name: Option<String>,
79 pub description: Option<String>,
81 pub declarations: Vec<VarDecl>,
83 pub params: Vec<String>,
85 pub has_params: bool,
87 pub allow_unused: bool,
92 pub type_aliases: HashMap<String, VarType>,
96 pub imports: Vec<Import>,
98 pub consts: Vec<VarDecl>,
100 pub env: Vec<VarDecl>,
103 pub imported_consts: HashMap<String, crate::value::Value>,
105 pub imported_enum_type_keys: Vec<String>,
109}
110
111pub fn strip_frontmatter(source: &str) -> Result<&str, TemplateError> {
117 parse_frontmatter(source).map(|(_, body)| body)
118}
119
120pub fn parse_frontmatter(source: &str) -> Result<(Frontmatter, &str), TemplateError> {
130 parse_frontmatter_impl(
131 source,
132 #[cfg(feature = "std")]
133 None,
134 None,
135 false,
136 &[],
137 )
138}
139
140pub fn parse_frontmatter_with_env<'a>(
151 source: &'a str,
152 env_values: &[(&str, crate::value::Value)],
153) -> Result<(Frontmatter, &'a str), TemplateError> {
154 parse_frontmatter_impl(
155 source,
156 #[cfg(feature = "std")]
157 None,
158 None,
159 false,
160 env_values,
161 )
162}
163
164#[cfg(feature = "std")]
175pub fn parse_frontmatter_with_base_dir<'a>(
176 source: &'a str,
177 base_dir: &std::path::Path,
178 env_values: &[(&str, crate::value::Value)],
179) -> Result<(Frontmatter, &'a str), TemplateError> {
180 parse_frontmatter_impl(source, Some(base_dir), None, false, env_values)
181}
182
183pub fn parse_frontmatter_with_parent_scope<'a>(
188 source: &'a str,
189 parent_type_aliases: &HashMap<String, VarType>,
190) -> Result<(Frontmatter, &'a str), TemplateError> {
191 parse_frontmatter_impl(
192 source,
193 #[cfg(feature = "std")]
194 None,
195 Some(parent_type_aliases),
196 true,
197 &[],
198 )
199}
200
201fn extract_yaml_logical_lines(
202 source: &str,
203 allow_missing_fm: bool,
204) -> Result<(Vec<String>, &str), TemplateError> {
205 let trimmed = source.trim_start();
206 if !trimmed.starts_with(FM_DELIMITER) {
207 if allow_missing_fm {
208 return Ok((Vec::new(), source));
209 }
210 return Err(TemplateError::syntax(
211 crate::consts::ERR_MISSING_FM.to_string(),
212 ));
213 }
214
215 let after_first = trimmed[FM_DELIMITER.len()..].trim_start_matches(['\r', '\n']);
216 let Some(end) = after_first.find(FM_DELIMITER_NEWLINE) else {
217 return Err(TemplateError::syntax(
218 crate::consts::ERR_UNCLOSED_FM.to_string(),
219 ));
220 };
221
222 let yaml_block = &after_first[..end];
223 let after_close = end + FM_DELIMITER_NEWLINE.len();
224 let body_start = if after_first[after_close..].starts_with('\n') {
225 after_close + 1
226 } else if after_first[after_close..].starts_with("\r\n") {
227 after_close + 2
228 } else {
229 after_close
230 };
231 let body = &after_first[body_start..];
232
233 let mut in_block_list = false;
234 let mut had_blank_line = true;
235 for line in yaml_block.lines() {
236 let trimmed = line.trim();
237 if trimmed.is_empty() {
238 had_blank_line = true;
239 continue;
240 }
241 let starts_with_section = line.starts_with(FM_NAME_PREFIX)
242 || line.starts_with(FM_DESC_PREFIX)
243 || line.starts_with(FM_TYPES_PREFIX)
244 || line.starts_with(FM_IMPORTS_PREFIX)
245 || line.starts_with(FM_PARAMS_PREFIX)
246 || line.starts_with(FM_CONSTS_PREFIX)
247 || line.starts_with(FM_ENV_PREFIX)
248 || line.starts_with(FM_ALLOW_UNUSED_PREFIX);
249
250 if starts_with_section {
251 if in_block_list && !had_blank_line {
252 return Err(TemplateError::syntax(format!(
253 "A blank line is required after a block list before '{trimmed}' so raw markdown renders correctly"
254 )));
255 }
256 in_block_list = false;
257 } else if trimmed.starts_with('-') {
258 in_block_list = true;
259 }
260 had_blank_line = false;
261 }
262
263 Ok((join_continuation_lines(yaml_block), body))
264}
265
266type FmResolutionResult = Result<
267 (
268 HashMap<String, VarType>,
269 HashMap<String, ImportedNamespace>,
270 HashMap<String, crate::value::Value>,
271 ),
272 TemplateError,
273>;
274
275fn validate_env_value(
281 name: &str,
282 value: &crate::value::Value,
283 var_type: &VarType,
284) -> Result<crate::value::Value, TemplateError> {
285 use crate::value::Value;
286 match (value, var_type) {
287 (Value::Str(raw), VarType::Int) => raw
289 .parse::<i64>()
290 .map(Value::Int)
291 .map_err(|_| TemplateError::syntax(format!("env '{name}': expected int, got '{raw}'"))),
292 (Value::Str(raw), VarType::Bool) => match raw.as_str() {
293 crate::consts::LIT_TRUE => Ok(Value::Bool(true)),
294 crate::consts::LIT_FALSE => Ok(Value::Bool(false)),
295 _ => Err(TemplateError::syntax(format!(
296 "env '{name}': expected bool, got '{raw}'"
297 ))),
298 },
299 (Value::Str(raw), VarType::Float) => raw.parse::<f64>().map(Value::Float).map_err(|_| {
300 TemplateError::syntax(format!("env '{name}': expected float, got '{raw}'"))
301 }),
302 _ => Ok(value.clone()),
305 }
306}
307
308fn resolve_fm_consts_and_imports(
309 fm: &mut Frontmatter,
310 consts_raw: Option<&str>,
311 env_raw: Option<&str>,
312 env_values: &[(&str, crate::value::Value)],
313 parent_type_aliases: Option<&HashMap<String, VarType>>,
314 #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
315) -> FmResolutionResult {
316 let mut merged_aliases = if let Some(parent_aliases) = parent_type_aliases {
317 parent_aliases.clone()
318 } else {
319 HashMap::new()
320 };
321 for (k, v) in &fm.type_aliases {
322 merged_aliases.insert(k.clone(), v.clone());
323 }
324
325 let mut prelim_consts = HashMap::new();
326 let empty_imports = HashMap::new();
327 let empty_consts = HashMap::new();
328
329 if let Some(raw) = env_raw {
331 let mut env_decls =
332 parse_declarations(raw, &merged_aliases, &empty_imports, false, &empty_consts)?;
333 for decl in &mut env_decls {
334 if let Some((_, provided_val)) = env_values.iter().find(|(k, _)| *k == decl.name) {
336 let val = validate_env_value(&decl.name, provided_val, &decl.var_type)?;
337 prelim_consts.insert(decl.name.clone(), val.clone());
338 decl.default_value = Some(val);
339 } else if let Some(ref default) = decl.default_value {
340 prelim_consts.insert(decl.name.clone(), default.clone());
341 } else {
342 return Err(TemplateError::syntax(format!(
343 "env '{}': no value provided and no default",
344 decl.name
345 )));
346 }
347 }
348 fm.env = env_decls;
349 }
350
351 if let Some(raw) = consts_raw {
352 if let Ok(decls) =
354 parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
355 {
356 let const_map = build_available_consts(&decls, &HashMap::new());
357 for (k, v) in const_map {
358 prelim_consts.insert(k, v);
359 }
360 }
361 }
362
363 #[cfg(feature = "std")]
364 let resolved_imports = if let Some(dir) = base_dir {
365 if fm.imports.is_empty() {
366 HashMap::new()
367 } else {
368 let mut visited = std::collections::HashSet::new();
369 resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
370 }
371 } else {
372 if !fm.imports.is_empty() {
373 interpolate_imports(&mut fm.imports, &prelim_consts)?;
374 }
375 HashMap::new()
376 };
377
378 #[cfg(not(feature = "std"))]
379 let resolved_imports = {
380 if !fm.imports.is_empty() {
381 interpolate_imports(&mut fm.imports, &prelim_consts)?;
382 }
383 HashMap::new()
384 };
385
386 #[cfg(feature = "std")]
387 inject_imported_consts(fm, &resolved_imports);
388
389 if let Some(raw) = consts_raw {
390 fm.consts = parse_declarations(
391 raw,
392 &merged_aliases,
393 &resolved_imports,
394 true,
395 &prelim_consts,
396 )?;
397 }
398
399 let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
400 for decl in &fm.env {
402 if let Some(val) = prelim_consts.get(&decl.name) {
403 available_consts
404 .entry(decl.name.clone())
405 .or_insert_with(|| val.clone());
406 }
407 }
408 Ok((merged_aliases, resolved_imports, available_consts))
409}
410
411fn parse_frontmatter_impl<'a>(
412 source: &'a str,
413 #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
414 parent_type_aliases: Option<&HashMap<String, VarType>>,
415 allow_missing_fm: bool,
416 env_values: &[(&str, crate::value::Value)],
417) -> Result<(Frontmatter, &'a str), TemplateError> {
418 let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
419 if logical_lines.is_empty()
420 && allow_missing_fm
421 && !source.trim_start().starts_with(FM_DELIMITER)
422 {
423 return Ok((Frontmatter::default(), body));
424 }
425
426 let mut fm = Frontmatter::default();
427 let mut params_raw: Option<String> = None;
428 let mut consts_raw: Option<String> = None;
429 let mut env_raw: Option<String> = None;
430
431 for line in &logical_lines {
432 let line = line.trim();
433 if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
434 fm.name = Some(rest.trim().to_string());
435 } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
436 fm.description = Some(rest.trim().to_string());
437 } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
438 fm.type_aliases = parse_types_value(rest)?;
439 } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
440 fm.imports = parse_imports_value(rest)?;
441 } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
442 params_raw = Some(rest.to_string());
443 } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
444 consts_raw = Some(rest.to_string());
445 } else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
446 env_raw = Some(rest.to_string());
447 } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
448 fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
449 }
450 }
451
452 let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
453 &mut fm,
454 consts_raw.as_deref(),
455 env_raw.as_deref(),
456 env_values,
457 parent_type_aliases,
458 #[cfg(feature = "std")]
459 base_dir,
460 )?;
461
462 if let Some(raw) = params_raw {
463 let decls = parse_declarations(
464 &raw,
465 &merged_aliases,
466 &resolved_imports,
467 false,
468 &available_consts,
469 )?;
470 fm.params = decls.iter().map(|d| d.name.clone()).collect();
471 fm.declarations = decls;
472 fm.has_params = true;
473 }
474
475 validate_collision_rules(&fm)?;
476 add_implicit_param_types(&mut fm);
477
478 Ok((fm, body))
479}
480
481#[cfg(feature = "std")]
487fn inject_imported_consts(
488 fm: &mut Frontmatter,
489 resolved_imports: &HashMap<String, ImportedNamespace>,
490) {
491 for (stem, ns) in resolved_imports {
492 for (name, val) in &ns.consts {
493 fm.imported_consts
494 .insert(format!("{stem}.{name}"), val.clone());
495 }
496 for (type_name, var_type) in &ns.type_aliases {
499 let VarType::Enum(variants) = var_type else {
500 continue;
501 };
502 let key = format!("{stem}.{type_name}");
503 if fm.imported_consts.contains_key(&key) {
505 continue;
506 }
507 let mut variant_map = HashMap::new();
508 let mut variant_names = Vec::with_capacity(variants.len());
509 for variant in variants {
510 variant_names.push(crate::value::Value::Str(variant.name.clone()));
511 if variant.fields.is_empty() {
512 variant_map.insert(
513 variant.name.clone(),
514 crate::value::Value::Str(variant.name.clone()),
515 );
516 } else {
517 let mut partial = HashMap::new();
518 partial.insert(
519 crate::consts::ENUM_TAG_KEY.into(),
520 crate::value::Value::Str(variant.name.clone()),
521 );
522 variant_map.insert(
523 variant.name.clone(),
524 crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
525 );
526 }
527 }
528 variant_map.insert(
529 crate::consts::ENUM_VARIANTS_KEY.into(),
530 crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
531 );
532 fm.imported_consts.insert(
533 key.clone(),
534 crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
535 );
536 fm.imported_enum_type_keys.push(key);
537 }
538 }
539}
540
541fn build_available_consts(
548 consts: &[crate::types::VarDecl],
549 imported_consts: &HashMap<String, crate::value::Value>,
550) -> HashMap<String, crate::value::Value> {
551 let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
552 for d in consts {
554 if let Some(ref v) = d.default_value {
555 available.insert(d.name.clone(), v.clone());
556 }
557 }
558 for (k, v) in imported_consts {
560 available.insert(k.clone(), v.clone());
561 }
562 available
563}
564
565#[cfg(test)]
566mod tests;