use core::fmt;
use core::mem;
use std::borrow::Cow;
use aozora::pipeline::{BorrowedLexOutput, INLINE_SENTINEL};
use aozora::render::render_node;
use aozora::syntax::Container;
use aozora::syntax::ContainerKind;
use aozora::syntax::borrowed::{AozoraNode, HeadingHint, NodeRef};
use comrak::Arena;
use comrak::nodes::{AstNode, NodeHeading, NodeValue};
use crate::sentinel_stream::{
BlockSentinelKind, ParaScan, SentinelCursor, is_sentinel_char, paragraph_sole_block_sentinel,
};
pub(crate) fn splice_into_ast<'a, 'src>(
root: &'a AstNode<'a>,
arena: &'a Arena<'a>,
lex_out: &BorrowedLexOutput<'src>,
sanitized: &str,
) {
let mut splicer = AstSplicer::<'a, 'src> {
cursor: SentinelCursor::from_lex_out_with_source(Some(lex_out), sanitized),
container_stack: Vec::new(),
in_heading_depth: 0,
arena,
};
splicer.walk(root);
splicer.drain_unclosed_containers(root);
}
struct AstSplicer<'a, 'src> {
cursor: SentinelCursor<'src>,
container_stack: Vec<ContainerKind>,
in_heading_depth: u32,
arena: &'a Arena<'a>,
}
impl<'a, 'src> AstSplicer<'a, 'src> {
fn walk(&mut self, root: &'a AstNode<'a>) {
let mut stack: Vec<Work<'a>> = Vec::new();
push_children_rev(&mut stack, root);
while let Some(work) = stack.pop() {
let node = match work {
Work::ExitHeading => {
self.in_heading_depth -= 1;
continue;
}
Work::ProcessLinkFields(node) => {
self.process_link_fields(node);
continue;
}
Work::Visit(node) => node,
};
let (action, is_heading) = {
let data = node.data.borrow();
(
classify(&data.value),
matches!(&data.value, NodeValue::Heading(_)),
)
};
match action {
DispatchAction::Skip => {}
DispatchAction::TextWith(text) => self.split_text_node(node, &text),
DispatchAction::CodeWith(literal) => self.splice_code_literal(node, &literal),
DispatchAction::Paragraph => self.dispatch_paragraph(node, &mut stack),
DispatchAction::RecurseLink => {
stack.push(Work::ProcessLinkFields(node));
push_children_rev(&mut stack, node);
}
DispatchAction::Recurse => {
if is_heading {
self.in_heading_depth += 1;
stack.push(Work::ExitHeading);
}
push_children_rev(&mut stack, node);
}
}
}
}
fn dispatch_paragraph(&mut self, paragraph: &'a AstNode<'a>, stack: &mut Vec<Work<'a>>) {
if let Some(kind) = paragraph_sole_block_sentinel(paragraph) {
self.handle_block_sentinel(paragraph, kind);
return;
}
let scan = ParaScan::run(paragraph, &self.cursor);
if let Some(hint) = scan.first_heading_hint {
self.handle_heading_hint(paragraph, hint, scan.total_sentinels);
return;
}
push_children_rev(stack, paragraph);
}
fn handle_block_sentinel(&mut self, paragraph: &'a AstNode<'a>, kind: BlockSentinelKind) {
let Some(node_ref) = self.cursor.next() else {
paragraph.detach();
return;
};
match (kind, node_ref) {
(BlockSentinelKind::Leaf, NodeRef::BlockLeaf(node)) => {
self.replace_with_block_html(paragraph, render_aozora_html(node, true));
}
(BlockSentinelKind::Open, NodeRef::BlockOpen(ck)) => {
self.container_stack.push(ck);
self.replace_with_block_html(
paragraph,
render_aozora_html(AozoraNode::Container(Container { kind: ck }), true),
);
}
(BlockSentinelKind::Close, NodeRef::BlockClose(ck))
if self.container_stack.pop().is_some() =>
{
self.replace_with_block_html(
paragraph,
render_aozora_html(AozoraNode::Container(Container { kind: ck }), false),
);
}
_ => {
paragraph.detach();
}
}
}
fn handle_heading_hint(
&mut self,
paragraph: &'a AstNode<'a>,
hint: &'src HeadingHint<'src>,
sentinels_to_consume: usize,
) {
self.cursor.advance(sentinels_to_consume);
let level = hint.level.clamp(1, 6);
let mut escaped = String::with_capacity(hint.target.as_str().len());
push_html_escaped(&mut escaped, hint.target.as_str());
let children: Vec<&'a AstNode<'a>> = paragraph.children().collect();
for child in children {
child.detach();
}
paragraph.data.borrow_mut().value = NodeValue::Heading(NodeHeading {
level,
setext: false,
closed: true,
});
paragraph.append(self.new_raw_node(escaped));
}
fn split_text_node(&mut self, node: &'a AstNode<'a>, text: &str) {
let mut segments: Vec<&'a AstNode<'a>> = Vec::new();
let mut current = String::new();
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if is_sentinel_char(ch) {
self.flush_text(&mut current, &mut segments);
let Some(node_ref) = self.cursor.next() else {
continue;
};
if ch == INLINE_SENTINEL
&& let NodeRef::Inline(aozora) = node_ref
{
let in_heading = self.in_heading_depth > 0;
let is_annotation = matches!(aozora, AozoraNode::Annotation(_));
if !(in_heading && is_annotation) {
let html = render_aozora_html(aozora, true);
segments.push(self.new_raw_node(html));
}
}
} else if ch == '[' && chars.peek() == Some(&'#') {
chars.next(); if self.in_heading_depth > 0 {
for b in chars.by_ref() {
if b == ']' {
break;
}
}
continue;
}
let mut bracket_body = String::from("[#");
for b in chars.by_ref() {
bracket_body.push(b);
if b == ']' {
break;
}
}
self.flush_text(&mut current, &mut segments);
let mut html = String::with_capacity(bracket_body.len() + 64);
html.push_str("<span class=\"aozora-md-annotation\" hidden>");
push_html_escaped(&mut html, &bracket_body);
html.push_str("</span>");
segments.push(self.new_raw_node(html));
} else {
current.push(ch);
}
}
self.flush_text(&mut current, &mut segments);
if segments.is_empty() {
return;
}
let mut anchor: &'a AstNode<'a> = node;
for seg in segments {
anchor.insert_after(seg);
anchor = seg;
}
node.detach();
}
fn rewrite_literal_context(&mut self, s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
if is_sentinel_char(ch) {
if let Some(literal) = self.cursor.next_literal() {
out.push_str(literal);
}
} else {
out.push(ch);
}
}
out
}
fn splice_code_literal(&mut self, node: &'a AstNode<'a>, literal: &str) {
let rewritten = self.rewrite_literal_context(literal);
let mut data = node.data.borrow_mut();
if let NodeValue::Code(code) = &mut data.value {
code.literal = rewritten;
}
}
fn process_link_fields(&mut self, node: &'a AstNode<'a>) {
let (url, title) = {
let data = node.data.borrow();
match &data.value {
NodeValue::Link(link) | NodeValue::Image(link) => {
let has = link.url.chars().any(is_sentinel_char)
|| link.title.chars().any(is_sentinel_char);
if !has {
return;
}
(link.url.clone(), link.title.clone())
}
_ => return,
}
};
let new_url = self.rewrite_literal_context(&url);
let new_title = self.rewrite_literal_context(&title);
let mut data = node.data.borrow_mut();
if let NodeValue::Link(link) | NodeValue::Image(link) = &mut data.value {
link.url = new_url;
link.title = new_title;
}
}
fn flush_text(&self, current: &mut String, segments: &mut Vec<&'a AstNode<'a>>) {
if !current.is_empty() {
segments.push(self.new_text_node(mem::take(current)));
}
}
fn replace_with_block_html(&self, paragraph: &'a AstNode<'a>, html: String) {
let raw = self.new_raw_node(html);
paragraph.insert_before(raw);
paragraph.detach();
}
fn drain_unclosed_containers(&mut self, root: &'a AstNode<'a>) {
while let Some(ck) = self.container_stack.pop() {
let html = render_aozora_html(AozoraNode::Container(Container { kind: ck }), false);
root.append(self.new_raw_node(html));
}
}
fn new_text_node(&self, text: String) -> &'a AstNode<'a> {
self.arena
.alloc(AstNode::from(NodeValue::Text(Cow::Owned(text))))
}
fn new_raw_node(&self, html: String) -> &'a AstNode<'a> {
self.arena.alloc(AstNode::from(NodeValue::Raw(html)))
}
}
enum Work<'a> {
Visit(&'a AstNode<'a>),
ExitHeading,
ProcessLinkFields(&'a AstNode<'a>),
}
fn push_children_rev<'a>(stack: &mut Vec<Work<'a>>, parent: &'a AstNode<'a>) {
let start = stack.len();
stack.extend(parent.children().map(Work::Visit));
stack[start..].reverse();
}
#[derive(Debug)]
enum DispatchAction {
Paragraph,
TextWith(String),
CodeWith(String),
RecurseLink,
Recurse,
Skip,
}
fn classify(value: &NodeValue) -> DispatchAction {
match value {
NodeValue::Paragraph => DispatchAction::Paragraph,
NodeValue::Text(s) => {
if s.chars().any(is_sentinel_char) || s.contains("[#") {
DispatchAction::TextWith(s.clone().into_owned())
} else {
DispatchAction::Skip
}
}
NodeValue::Code(c) => {
if c.literal.chars().any(is_sentinel_char) {
DispatchAction::CodeWith(c.literal.clone())
} else {
DispatchAction::Skip
}
}
NodeValue::Link(_) | NodeValue::Image(_) => DispatchAction::RecurseLink,
NodeValue::CodeBlock(_)
| NodeValue::HtmlBlock(_)
| NodeValue::HtmlInline(_)
| NodeValue::Raw(_) => DispatchAction::Skip,
_ => DispatchAction::Recurse,
}
}
fn render_aozora_html(node: AozoraNode<'_>, entering: bool) -> String {
let mut out = String::new();
render_node::render(node, entering, &mut StringSink(&mut out))
.expect("writing AozoraNode HTML to a String cannot fail");
if out.contains("aozora-") {
out = out.replace("aozora-", "aozora-md-");
}
out
}
fn push_html_escaped(out: &mut String, s: &str) {
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
}
struct StringSink<'s>(&'s mut String);
impl fmt::Write for StringSink<'_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.0.write_str(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aozora::pipeline::{BLOCK_LEAF_SENTINEL, lex_into_arena};
use aozora::syntax::borrowed::Arena as AozoraArena;
use crate::code_block_mask;
fn render_via_ast_splice(input: &str) -> String {
use aozora::pipeline::lexer::sanitize;
let (masked, originals) = code_block_mask::mask_code_block_triggers(input);
let sanitized = sanitize(&masked);
let aozora_arena = AozoraArena::new();
let lex_out = lex_into_arena(&masked, &aozora_arena);
let comrak_arena: Arena<'_> = Arena::new();
let opts = comrak::Options::default();
let root = comrak::parse_document(&comrak_arena, lex_out.normalized, &opts);
splice_into_ast(root, &comrak_arena, &lex_out, &sanitized.text);
let mut html = String::new();
comrak::format_html(root, &opts, &mut html).expect("formatting to a String never fails");
code_block_mask::unmask_html(&html, &originals).into_owned()
}
#[test]
fn plain_text_passes_through() {
let html = render_via_ast_splice("hello");
assert!(html.contains("hello"), "html: {html}");
}
#[test]
fn ruby_inline_sentinel_is_replaced() {
let html = render_via_ast_splice("|青梅《おうめ》");
assert!(html.contains("<ruby>"), "html: {html}");
assert!(html.contains("青梅"), "html: {html}");
assert!(html.contains("おうめ"), "html: {html}");
assert!(!html.contains(INLINE_SENTINEL), "sentinel leaked: {html}");
}
#[test]
fn page_break_block_leaf_replaces_paragraph() {
let html = render_via_ast_splice("前\n\n[#改ページ]\n\n後");
assert!(
!html.contains(BLOCK_LEAF_SENTINEL),
"sentinel leaked: {html}"
);
assert!(
!html.contains("<p>\u{E002}</p>"),
"block-sentinel paragraph survived: {html}"
);
}
#[test]
fn heading_hint_promotes_paragraph_to_heading() {
let html = render_via_ast_splice("第一篇[#「第一篇」は大見出し]");
assert!(
html.contains("<h1>第一篇</h1>"),
"expected <h1>第一篇</h1>, got {html}"
);
}
#[test]
fn orphan_close_does_not_emit_div() {
let html = render_via_ast_splice("[#ここで字下げ終わり]");
let opens = html.matches("<div").count();
let closes = html.matches("</div>").count();
assert_eq!(opens, closes, "tag-balance broken: {html}");
}
#[test]
fn block_sentinel_inside_code_block_does_not_promote() {
let html = render_via_ast_splice("```\n[#改ページ]\n```");
assert!(
!html.contains(BLOCK_LEAF_SENTINEL),
"sentinel leaked: {html}"
);
}
#[test]
fn heading_hint_target_html_special_chars_are_escaped() {
let html = render_via_ast_splice("<&\"'><&\"'>[#「<&\"'>」は大見出し]");
assert!(html.contains("<"), "missing < escape: {html}");
assert!(html.contains(">"), "missing > escape: {html}");
assert!(html.contains("&"), "missing & escape: {html}");
assert!(html.contains("""), "missing \" escape: {html}");
assert!(html.contains("'"), "missing ' escape: {html}");
}
#[test]
fn atx_heading_with_orphan_bracket_drops_wrapper() {
let html = render_via_ast_splice("# header[#orphan]tail");
assert!(
!html.contains("aozora-md-annotation"),
"aozora-md-annotation leaked into heading: {html}"
);
}
#[test]
fn setext_heading_with_orphan_bracket_drops_wrapper() {
let html = render_via_ast_splice("text[#orphan]more\n===");
assert!(
!html.contains("aozora-md-annotation"),
"aozora-md-annotation leaked into setext heading: {html}"
);
}
#[test]
fn dispatch_skip_covers_inline_html_and_code() {
let _html = render_via_ast_splice("<div>raw</div>\n\n```\ncode\n```\n\n`x`");
}
#[test]
fn orphan_bracket_wrap_respects_text_node_boundary() {
let html = render_via_ast_splice("[#\n※");
assert!(
html.contains("<span class=\"aozora-md-annotation\" hidden>[#</span>"),
"wrapped run did not honour Text-node boundary: {html}"
);
assert!(
!html.contains("<span class=\"aozora-md-annotation\" hidden>[#\n※"),
"wrap leaked across SoftBreak: {html}"
);
}
}