use std::ops::Range;
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use super::fence::{RawBlock, lua_block_location, split_section_blocks};
use super::list::{is_all_list_markers, parse_bullet_items};
use super::{Block, ParseErrorKind, Section};
use crate::lua::LuaProgram;
use crate::observe::Observer;
use crate::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
#[serde(deny_unknown_fields)]
#[non_exhaustive]
pub struct Frontmatter {
pub(crate) name: String,
pub(crate) description: String,
#[serde(default)]
pub(crate) promptforge: Option<u32>,
#[serde(default)]
pub(crate) default_return: Option<String>,
#[serde(default)]
pub(crate) max_tool_iterations: MaxToolIterations,
}
pub const MAX_TOOL_ITERATIONS: u32 = 1000;
pub(crate) fn nz_source_line(line: u32) -> Result<std::num::NonZeroU32> {
std::num::NonZeroU32::new(line).ok_or(Error::Internal(
"parser: computed 1-based source line was zero",
))
}
pub(crate) fn line_add(a: u32, b: u32) -> Result<u32> {
a.checked_add(b)
.ok_or(Error::Internal("parser: source line arithmetic overflowed"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum MaxToolIterations {
#[default]
Default,
Limit(std::num::NonZeroU32),
}
impl MaxToolIterations {
#[must_use]
pub fn resolve(self, default: usize) -> usize {
match self {
MaxToolIterations::Default => default,
MaxToolIterations::Limit(limit) => limit.get() as usize,
}
}
#[must_use]
pub fn limit(self) -> Option<std::num::NonZeroU32> {
match self {
MaxToolIterations::Default => None,
MaxToolIterations::Limit(limit) => Some(limit),
}
}
}
impl<'de> serde::Deserialize<'de> for MaxToolIterations {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = i64::deserialize(deserializer)?;
if raw <= 0 {
return Err(serde::de::Error::custom(format!(
"max_tool_iterations must be a positive integer (>= 1), got {raw}"
)));
}
if raw > i64::from(MAX_TOOL_ITERATIONS) {
return Err(serde::de::Error::custom(format!(
"max_tool_iterations must be <= {MAX_TOOL_ITERATIONS}, got {raw}"
)));
}
let value = u32::try_from(raw)
.map_err(|_| serde::de::Error::custom("max_tool_iterations is out of range"))?;
let limit = std::num::NonZeroU32::new(value)
.ok_or_else(|| serde::de::Error::custom("max_tool_iterations must be non-zero"))?;
Ok(MaxToolIterations::Limit(limit))
}
}
impl Frontmatter {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn description(&self) -> &str {
&self.description
}
#[must_use]
pub fn promptforge(&self) -> Option<u32> {
self.promptforge
}
#[must_use]
pub fn default_return(&self) -> Option<&str> {
self.default_return.as_deref()
}
#[must_use]
pub fn max_tool_iterations(&self) -> MaxToolIterations {
self.max_tool_iterations
}
}
#[derive(Debug, Clone)]
pub(crate) struct Heading {
pub(crate) level: u8,
pub(crate) title: String,
pub(crate) content: String,
pub(crate) content_start_line: u32,
pub(crate) source_line: u32,
pub(crate) span: Range<usize>,
}
pub(crate) fn split_frontmatter(input: &str) -> Result<(String, String, u32)> {
let input = input.strip_prefix('\u{feff}').unwrap_or(input); let mut lines = input.lines();
match lines.next() {
Some(l) if l.trim() == "---" => {}
_ => {
return Err(Error::Parse(
"file must begin with a --- frontmatter delimiter".into(),
));
}
}
let mut yaml = String::new();
let mut closed = false;
let mut line_count: u32 = 1; for line in lines.by_ref() {
line_count += 1;
if line.trim() == "---" {
closed = true;
break;
}
yaml.push_str(line);
yaml.push('\n');
}
if !closed {
return Err(Error::Parse("frontmatter was not closed with ---".into()));
}
let body = lines.collect::<Vec<_>>().join("\n");
Ok((yaml, body, line_count))
}
#[must_use]
pub fn promptforge_version(source: &str) -> Option<u32> {
#[derive(serde::Deserialize)]
struct Probe {
#[serde(default)]
promptforge: Option<u32>,
}
let (yaml, _body, _lines) = split_frontmatter(source).ok()?;
let probe: Probe = serde_yaml_ng::from_str(&yaml).ok()?;
probe.promptforge
}
pub(crate) fn newlines_before(text: &str, byte_offset: usize) -> Result<u32> {
u32::try_from(text[..byte_offset].matches('\n').count())
.map_err(|_| Error::Internal("parser: newline count exceeded u32 range"))
}
fn level_num(level: HeadingLevel) -> u8 {
match level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
}
}
pub(crate) fn collect_headings(body: &str) -> Result<Vec<Heading>> {
struct Raw {
level: u8,
title: String,
range: Range<usize>,
}
let mut raws: Vec<Raw> = Vec::new();
let mut current: Option<(u8, Range<usize>, String)> = None;
for (event, range) in Parser::new_ext(body, Options::empty()).into_offset_iter() {
match event {
Event::Start(Tag::Heading { level, .. }) => {
current = Some((level_num(level), range.clone(), String::new()));
}
Event::End(TagEnd::Heading(_)) => {
if let Some((level, range, title)) = current.take() {
raws.push(Raw {
level,
title: title.trim().to_string(),
range,
});
}
}
Event::Text(t) | Event::Code(t) => {
if let Some((_, _, ref mut title)) = current {
title.push_str(&t);
}
}
_ => {}
}
}
let mut headings = Vec::with_capacity(raws.len());
for i in 0..raws.len() {
let start = raws[i].range.end;
let end = raws.get(i + 1).map_or(body.len(), |next| next.range.start);
let content = &body[start..end];
let content_start_line = line_add(newlines_before(body, start)?, 1)?;
let source_line = line_add(newlines_before(body, raws[i].range.start)?, 1)?;
headings.push(Heading {
level: raws[i].level,
title: raws[i].title.clone(),
content: content.to_string(),
content_start_line,
source_line,
span: raws[i].range.clone(),
});
}
Ok(headings)
}
pub(crate) fn build_sections(
headings: &[Heading],
pos: &mut usize,
parent_level: u8,
frontmatter_lines: u32,
execution: &str,
observer: &dyn Observer,
) -> Result<Vec<Section>> {
let mut result = Vec::new();
let mut sibling_lines: Vec<(String, u32)> = Vec::new();
while *pos < headings.len() {
let level = headings[*pos].level;
if level <= parent_level {
break;
}
if level > parent_level + 1 {
return Err(Error::Parse(format!(
"section `{}` is an orphan H{level} heading with no parent H{}",
headings[*pos].title.trim(),
parent_level + 1
)));
}
let h = &headings[*pos];
let name = h.title.clone();
if name.trim().is_empty() {
return Err(Error::Parse(format!(
"an H{level} section heading must not be empty"
)));
}
let content_abs_line = line_add(frontmatter_lines, h.content_start_line)?;
let heading_abs_line = line_add(frontmatter_lines, h.source_line)?;
let heading_span = h.span.clone();
let raw_blocks = split_section_blocks(&h.content, &name)?;
let has_prose = raw_blocks
.iter()
.any(|block| matches!(block, RawBlock::Prose(_)));
let last_prose = raw_blocks
.iter()
.rposition(|block| matches!(block, RawBlock::Prose(_)));
let total = raw_blocks.len();
let mut blocks = Vec::with_capacity(total);
for (index, raw) in raw_blocks.into_iter().enumerate() {
match raw {
RawBlock::Prose(text) => {
let loop_capable = Some(index) == last_prose;
blocks.push(Block::Prose { text, loop_capable });
}
RawBlock::Lua {
source,
line_offset,
} => {
let abs_line = line_add(content_abs_line, line_offset)?;
let location = lua_block_location(&name, index, total, has_prose);
let program = LuaProgram::compile(
&source,
&location,
nz_source_line(abs_line)?,
execution,
observer,
&name,
)?;
blocks.push(Block::Lua(program));
}
}
}
*pos += 1;
let children =
build_sections(headings, pos, level, frontmatter_lines, execution, observer)?;
let has_no_lua = blocks
.iter()
.all(|block| matches!(block, Block::Prose { .. }));
let prose_for_items = blocks.iter().find_map(|block| match block {
Block::Prose { text, .. } => Some(text.as_str()),
Block::Lua(_) => None,
});
let items = if has_no_lua
&& let Some(prose) = prose_for_items
&& is_all_list_markers(prose)
{
parse_bullet_items(prose, &name)?
} else {
Vec::new()
};
if let Some((_, first_line)) = sibling_lines.iter().find(|(n, _)| *n == name) {
return Err(Error::ParseStructured {
kind: ParseErrorKind::Structure,
span: Some((heading_span.start, heading_span.end)),
message: format!(
"duplicate sibling section name `{name}`: first declared at line {first_line}, again at line {heading_abs_line}; sibling section names must be unique"
),
});
}
sibling_lines.push((name.clone(), heading_abs_line));
result.push(Section {
name,
level,
blocks,
children,
items,
});
}
Ok(result)
}