use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag};
use super::Block;
use super::build::{line_add, newlines_before, nz_source_line};
use crate::lua::LuaProgram;
use crate::observe::Observer;
use crate::{Error, Result};
pub(super) enum RawBlock {
Lua {
source: String,
line_offset: u32,
},
Prose(String),
}
pub(super) fn split_h1(
content: &str,
title: &str,
content_abs_line: u32,
execution: &str,
observer: &dyn Observer,
) -> Result<(Option<LuaProgram>, Vec<Block>, String)> {
let leading = trim_leading_blank_lines(content);
if leading.lines().next() == Some("```lua prompt") {
return Err(Error::Parse(
"the `lua prompt` fence form was removed; use `lua` for a live H1 block or `lua shared` for the shared library".into(),
));
}
let shared_opening = exact_shared_openings(content).into_iter().next();
let mut h1_content = content.as_bytes().to_vec();
let replay = if let Some(opening) = shared_opening {
let after_open = strip_exact_shared_opening(&content[opening..]).ok_or_else(|| {
Error::Parse("internal shared fence classification mismatch".to_owned())
})?;
let (source, rest) = extract_exact_fence(after_open, "prompt `lua shared`")?;
let fence_end = content.len() - rest.len();
for byte in &mut h1_content[opening..fence_end] {
if !matches!(*byte, b'\r' | b'\n') {
*byte = b' ';
}
}
Some(LuaProgram::compile(
&source,
"prompt shared library",
nz_source_line(line_add(
line_add(content_abs_line, newlines_before(content, opening)?)?,
1,
)?)?,
execution,
observer,
title,
)?)
} else {
None
};
let h1_content = String::from_utf8(h1_content)
.map_err(|_| Error::Parse("internal H1 source masking failed".to_owned()))?;
let raw_blocks = split_section_blocks(&h1_content, title)?;
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) => blocks.push(Block::Prose {
text,
loop_capable: Some(index) == last_prose,
}),
RawBlock::Lua {
source,
line_offset,
} => {
let location = format!("H1 `{title}` lua");
blocks.push(Block::Lua(LuaProgram::compile(
&source,
&location,
nz_source_line(line_add(content_abs_line, line_offset)?)?,
execution,
observer,
title,
)?));
}
}
}
if matches!(
blocks.as_slice(),
[Block::Prose {
text,
loop_capable: _
}] if text.is_empty()
) {
blocks.clear();
}
let description_text = blocks
.iter()
.filter_map(|block| match block {
Block::Prose { text, .. } if !text.is_empty() => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("\n\n");
Ok((replay, blocks, description_text))
}
fn trim_leading_blank_lines(content: &str) -> &str {
let mut offset = 0;
for line in content.split_inclusive('\n') {
if line.trim().is_empty() {
offset += line.len();
} else {
break;
}
}
&content[offset..]
}
fn extract_exact_fence<'a>(content: &'a str, label: &str) -> Result<(String, &'a str)> {
let mut offset = 0;
for line in content.split_inclusive('\n') {
let text = line.strip_suffix('\n').unwrap_or(line);
let text = text.strip_suffix('\r').unwrap_or(text);
if text == "```" {
let source = content[..offset].trim_end_matches(['\r', '\n']).to_string();
return Ok((source, &content[offset + line.len()..]));
}
offset += line.len();
}
Err(Error::Parse(format!("{label} fence is not closed")))
}
fn strip_exact_lua_opening(content: &str) -> Option<&str> {
content
.strip_prefix("```lua\r\n")
.or_else(|| content.strip_prefix("```lua\n"))
.or_else(|| (content == "```lua").then_some(""))
}
fn strip_exact_shared_opening(content: &str) -> Option<&str> {
content
.strip_prefix("```lua shared\r\n")
.or_else(|| content.strip_prefix("```lua shared\n"))
.or_else(|| (content == "```lua shared").then_some(""))
}
fn exact_fence_openings(content: &str, marker: &str) -> Vec<usize> {
Parser::new_ext(content, Options::empty())
.into_offset_iter()
.filter_map(|(event, range)| {
if !matches!(
event,
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(_)))
) {
return None;
}
let line_start = content[..range.start]
.rfind('\n')
.map_or(0, |newline| newline + 1);
(content[line_start..].lines().next() == Some(marker)).then_some(line_start)
})
.collect()
}
fn exact_lua_openings(content: &str) -> Vec<usize> {
exact_fence_openings(content, "```lua")
}
pub(super) fn exact_shared_openings(content: &str) -> Vec<usize> {
exact_fence_openings(content, "```lua shared")
}
fn leading_content_start(content: &str) -> usize {
content.len() - trim_leading_blank_lines(content).len()
}
pub(super) fn split_section_blocks(content: &str, section: &str) -> Result<Vec<RawBlock>> {
let openings = exact_lua_openings(content);
if openings.is_empty() {
return Ok(vec![RawBlock::Prose(content.trim().to_string())]);
}
let leading_start = leading_content_start(content);
let mut blocks = Vec::new();
let mut pos = 0usize;
for (index, &opening) in openings.iter().enumerate() {
let Some(after_open) = strip_exact_lua_opening(&content[opening..]) else {
return Err(Error::Parse(
"internal section fence classification mismatch".to_owned(),
));
};
let label = if index == 0 && opening == leading_start {
format!("section `{section}` prologue `lua`")
} else if index + 1 == openings.len() {
format!("section `{section}` epilog `lua`")
} else {
format!("section `{section}` `lua`")
};
let (source, rest) = extract_exact_fence(after_open, &label)?;
let fence_end = content.len() - rest.len();
if !(pos == 0 && opening == leading_start) {
blocks.push(RawBlock::Prose(content[pos..opening].trim().to_string()));
}
let line_offset = line_add(newlines_before(content, opening)?, 1)?;
blocks.push(RawBlock::Lua {
source,
line_offset,
});
pos = fence_end;
}
let trailing = content[pos..].trim();
if !trailing.is_empty() {
blocks.push(RawBlock::Prose(trailing.to_string()));
}
Ok(blocks)
}
pub(super) fn lua_block_location(
section: &str,
index: usize,
total: usize,
has_prose: bool,
) -> String {
let is_first = index == 0;
let is_last = index + 1 == total;
if is_first {
return format!("section `{section}` prologue");
}
if is_last && has_prose {
return format!("section `{section}` epilog");
}
format!("section `{section}` lua")
}