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 "true" => Ok(Value::Bool(true)),
294 "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) =
353 parse_declarations(raw, &merged_aliases, &empty_imports, true, &prelim_consts)
354 {
355 let const_map = build_available_consts(&decls, &HashMap::new());
356 for (k, v) in const_map {
357 prelim_consts.insert(k, v);
358 }
359 }
360 }
361
362 #[cfg(feature = "std")]
363 let resolved_imports = if let Some(dir) = base_dir {
364 if fm.imports.is_empty() {
365 HashMap::new()
366 } else {
367 let mut visited = std::collections::HashSet::new();
368 resolve_imports_with_consts(&mut fm.imports, dir, &mut visited, &prelim_consts)?
369 }
370 } else {
371 if !fm.imports.is_empty() {
372 interpolate_imports(&mut fm.imports, &prelim_consts)?;
373 }
374 HashMap::new()
375 };
376
377 #[cfg(not(feature = "std"))]
378 let resolved_imports = {
379 if !fm.imports.is_empty() {
380 interpolate_imports(&mut fm.imports, &prelim_consts)?;
381 }
382 HashMap::new()
383 };
384
385 #[cfg(feature = "std")]
386 inject_imported_consts(fm, &resolved_imports);
387
388 if let Some(raw) = consts_raw {
389 fm.consts = parse_declarations(
390 raw,
391 &merged_aliases,
392 &resolved_imports,
393 true,
394 &prelim_consts,
395 )?;
396 }
397
398 let mut available_consts = build_available_consts(&fm.consts, &fm.imported_consts);
399 for decl in &fm.env {
401 if let Some(val) = prelim_consts.get(&decl.name) {
402 available_consts
403 .entry(decl.name.clone())
404 .or_insert_with(|| val.clone());
405 }
406 }
407 Ok((merged_aliases, resolved_imports, available_consts))
408}
409
410fn parse_frontmatter_impl<'a>(
411 source: &'a str,
412 #[cfg(feature = "std")] base_dir: Option<&std::path::Path>,
413 parent_type_aliases: Option<&HashMap<String, VarType>>,
414 allow_missing_fm: bool,
415 env_values: &[(&str, crate::value::Value)],
416) -> Result<(Frontmatter, &'a str), TemplateError> {
417 let (logical_lines, body) = extract_yaml_logical_lines(source, allow_missing_fm)?;
418 if logical_lines.is_empty()
419 && allow_missing_fm
420 && !source.trim_start().starts_with(FM_DELIMITER)
421 {
422 return Ok((Frontmatter::default(), body));
423 }
424
425 let mut fm = Frontmatter::default();
426 let mut params_raw: Option<String> = None;
427 let mut consts_raw: Option<String> = None;
428 let mut env_raw: Option<String> = None;
429
430 for line in &logical_lines {
431 let line = line.trim();
432 if let Some(rest) = line.strip_prefix(FM_NAME_PREFIX) {
433 fm.name = Some(rest.trim().to_string());
434 } else if let Some(rest) = line.strip_prefix(FM_DESC_PREFIX) {
435 fm.description = Some(rest.trim().to_string());
436 } else if let Some(rest) = line.strip_prefix(FM_TYPES_PREFIX) {
437 fm.type_aliases = parse_types_value(rest)?;
438 } else if let Some(rest) = line.strip_prefix(FM_IMPORTS_PREFIX) {
439 fm.imports = parse_imports_value(rest)?;
440 } else if let Some(rest) = line.strip_prefix(FM_PARAMS_PREFIX) {
441 params_raw = Some(rest.to_string());
442 } else if let Some(rest) = line.strip_prefix(FM_CONSTS_PREFIX) {
443 consts_raw = Some(rest.to_string());
444 } else if let Some(rest) = line.strip_prefix(FM_ENV_PREFIX) {
445 env_raw = Some(rest.to_string());
446 } else if let Some(rest) = line.strip_prefix(FM_ALLOW_UNUSED_PREFIX) {
447 fm.allow_unused = rest.trim() == crate::consts::LIT_TRUE;
448 }
449 }
450
451 let (merged_aliases, resolved_imports, available_consts) = resolve_fm_consts_and_imports(
452 &mut fm,
453 consts_raw.as_deref(),
454 env_raw.as_deref(),
455 env_values,
456 parent_type_aliases,
457 #[cfg(feature = "std")]
458 base_dir,
459 )?;
460
461 if let Some(raw) = params_raw {
462 let decls = parse_declarations(
463 &raw,
464 &merged_aliases,
465 &resolved_imports,
466 false,
467 &available_consts,
468 )?;
469 fm.params = decls.iter().map(|d| d.name.clone()).collect();
470 fm.declarations = decls;
471 fm.has_params = true;
472 }
473
474 validate_collision_rules(&fm)?;
475 add_implicit_param_types(&mut fm);
476
477 Ok((fm, body))
478}
479
480#[cfg(feature = "std")]
486fn inject_imported_consts(
487 fm: &mut Frontmatter,
488 resolved_imports: &HashMap<String, ImportedNamespace>,
489) {
490 for (stem, ns) in resolved_imports {
491 for (name, val) in &ns.consts {
492 fm.imported_consts
493 .insert(format!("{stem}.{name}"), val.clone());
494 }
495 for (type_name, var_type) in &ns.type_aliases {
498 let VarType::Enum(variants) = var_type else {
499 continue;
500 };
501 let key = format!("{stem}.{type_name}");
502 if fm.imported_consts.contains_key(&key) {
504 continue;
505 }
506 let mut variant_map = HashMap::new();
507 let mut variant_names = Vec::with_capacity(variants.len());
508 for variant in variants {
509 variant_names.push(crate::value::Value::Str(variant.name.clone()));
510 if variant.fields.is_empty() {
511 variant_map.insert(
512 variant.name.clone(),
513 crate::value::Value::Str(variant.name.clone()),
514 );
515 } else {
516 let mut partial = HashMap::new();
517 partial.insert(
518 crate::consts::ENUM_TAG_KEY.into(),
519 crate::value::Value::Str(variant.name.clone()),
520 );
521 variant_map.insert(
522 variant.name.clone(),
523 crate::value::Value::Struct(alloc::sync::Arc::new(partial)),
524 );
525 }
526 }
527 variant_map.insert(
528 crate::consts::ENUM_VARIANTS_KEY.into(),
529 crate::value::Value::List(alloc::sync::Arc::new(variant_names)),
530 );
531 fm.imported_consts.insert(
532 key.clone(),
533 crate::value::Value::Struct(alloc::sync::Arc::new(variant_map)),
534 );
535 fm.imported_enum_type_keys.push(key);
536 }
537 }
538}
539
540fn build_available_consts(
547 consts: &[crate::types::VarDecl],
548 imported_consts: &HashMap<String, crate::value::Value>,
549) -> HashMap<String, crate::value::Value> {
550 let mut available = HashMap::with_capacity(consts.len() + imported_consts.len());
551 for d in consts {
553 if let Some(ref v) = d.default_value {
554 available.insert(d.name.clone(), v.clone());
555 }
556 }
557 for (k, v) in imported_consts {
559 available.insert(k.clone(), v.clone());
560 }
561 available
562}
563
564#[cfg(test)]
565mod tests;