#![forbid(unsafe_code)]
#[cfg(doctest)]
#[doc = include_str!("../../../README.md")]
struct ReadmeDoctests;
mod ast_splice;
mod code_block_mask;
pub mod diagnostics;
pub mod html;
pub mod ir;
mod sentinel_stream;
mod source_line_anchors;
pub mod sentinels {
pub const INLINE: char = aozora::INLINE_SENTINEL;
pub const BLOCK_LEAF: char = aozora::BLOCK_LEAF_SENTINEL;
pub const BLOCK_OPEN: char = aozora::BLOCK_OPEN_SENTINEL;
pub const BLOCK_CLOSE: char = aozora::BLOCK_CLOSE_SENTINEL;
}
#[doc(inline)]
pub use diagnostics::{Diagnostic, DiagnosticSource, Severity, Span};
use core::mem;
use aozora::pipeline::lexer::sanitize;
use aozora::render::serialize as aozora_serialize;
use aozora::syntax::borrowed::Arena;
use comrak::nodes::AstNode;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Options {
comrak: comrak::Options<'static>,
aozora_enabled: bool,
source_line_anchors: bool,
}
impl Default for Options {
fn default() -> Self {
let mut comrak = comrak::Options::default();
comrak.extension.strikethrough = true;
comrak.extension.table = true;
comrak.extension.autolink = true;
comrak.extension.tasklist = true;
comrak.render.hardbreaks = true;
Self {
comrak,
aozora_enabled: true,
source_line_anchors: false,
}
}
}
impl Options {
#[doc(hidden)]
#[must_use]
pub fn commonmark_only() -> Self {
let mut comrak = comrak::Options::default();
comrak.render.r#unsafe = true;
Self {
comrak,
aozora_enabled: false,
source_line_anchors: false,
}
}
#[doc(hidden)]
#[must_use]
pub fn gfm_only() -> Self {
let mut comrak = comrak::Options::default();
comrak.extension.strikethrough = true;
comrak.extension.table = true;
comrak.extension.autolink = true;
comrak.extension.tasklist = true;
comrak.extension.tagfilter = true;
comrak.render.r#unsafe = true;
Self {
comrak,
aozora_enabled: false,
source_line_anchors: false,
}
}
#[must_use]
pub fn with_source_line_anchors(mut self, on: bool) -> Self {
self.source_line_anchors = on;
self
}
#[must_use]
pub fn with_aozora_enabled(mut self, on: bool) -> Self {
self.aozora_enabled = on;
self
}
#[must_use]
pub fn aozora_enabled(&self) -> bool {
self.aozora_enabled
}
#[must_use]
pub fn source_line_anchors(&self) -> bool {
self.source_line_anchors
}
#[must_use]
pub fn comrak(&self) -> &comrak::Options<'static> {
&self.comrak
}
pub fn comrak_mut(&mut self) -> &mut comrak::Options<'static> {
&mut self.comrak
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct Rendered {
pub html: String,
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug)]
#[non_exhaustive]
pub struct RenderedIr {
pub ir: ir::IrDocument,
pub html: String,
pub diagnostics: Vec<Diagnostic>,
}
const MAX_SOURCE_BYTES: usize = u32::MAX as usize;
const fn len_within_span_budget(len: usize) -> bool {
len <= MAX_SOURCE_BYTES
}
const fn source_within_span_budget(input: &str) -> bool {
len_within_span_budget(input.len())
}
#[must_use]
pub fn render(input: &str, options: &Options) -> Rendered {
if !source_within_span_budget(input) {
return Rendered {
html: String::new(),
diagnostics: vec![Diagnostic::source_too_large(input.len())],
};
}
let (html, diagnostics, ()) = drive_pipeline(input, options, |_root, _lex_out, _source| ());
Rendered { html, diagnostics }
}
#[must_use]
pub fn render_to_ir(input: &str, options: &Options) -> RenderedIr {
if !source_within_span_budget(input) {
return RenderedIr {
ir: ir::IrDocument::default(),
html: String::new(),
diagnostics: vec![Diagnostic::source_too_large(input.len())],
};
}
let (html, diagnostics, ir) = drive_pipeline(input, options, ir::build_ir);
RenderedIr {
ir,
html,
diagnostics,
}
}
fn drive_pipeline<F, T>(input: &str, options: &Options, project: F) -> (String, Vec<Diagnostic>, T)
where
F: for<'a> FnOnce(&'a AstNode<'a>, Option<&aozora::BorrowedLexOutput<'a>>, &str) -> T,
{
if !options.aozora_enabled {
let comrak_arena = comrak::Arena::new();
let root = comrak::parse_document(&comrak_arena, input, &options.comrak);
let extra = project(root, None, input);
let html = format_root(root, options, None);
return (html, Vec::new(), extra);
}
let (masked_source, mask_originals) = code_block_mask::mask_code_block_triggers(input);
let sanitized = sanitize(&masked_source);
let arena = Arena::new();
let lex_out = aozora::lex_into_arena(&masked_source, &arena);
let comrak_arena = comrak::Arena::new();
let root = comrak::parse_document(&comrak_arena, lex_out.normalized, &options.comrak);
let extra = project(root, Some(&lex_out), &sanitized.text);
ast_splice::splice_into_ast(root, &comrak_arena, &lex_out, &sanitized.text);
let html = format_root(root, options, Some(mask_originals.as_slice()));
let diagnostics = lex_out.diagnostics.iter().map(Diagnostic::from).collect();
(html, diagnostics, extra)
}
fn format_root<'a>(
root: &'a AstNode<'a>,
options: &Options,
mask_originals: Option<&[char]>,
) -> String {
let html = if options.source_line_anchors {
source_line_anchors::format_root_with_anchors(root, &options.comrak)
} else {
let mut html = String::new();
comrak::format_html(root, &options.comrak, &mut html)
.expect("formatting to a String never fails");
html
};
if let Some(originals) = mask_originals {
code_block_mask::unmask_html(&html, originals).into_owned()
} else {
html
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct RenderedBlock {
pub ir: Vec<ir::IrBlock>,
pub html: String,
pub source_line: u32,
}
#[must_use]
pub fn render_blocks_to_ir(
input: &str,
options: &Options,
) -> (Vec<RenderedBlock>, Vec<Diagnostic>) {
if !source_within_span_budget(input) {
return (Vec::new(), vec![Diagnostic::source_too_large(input.len())]);
}
if !options.aozora_enabled {
let comrak_arena = comrak::Arena::new();
let root = comrak::parse_document(&comrak_arena, input, &options.comrak);
let blocks = collect_rendered_blocks(root, options, Vec::new());
return (blocks, Vec::new());
}
let (masked_source, _mask_originals) = code_block_mask::mask_code_block_triggers(input);
let sanitized = sanitize(&masked_source);
let arena = Arena::new();
let lex_out = aozora::lex_into_arena(&masked_source, &arena);
let comrak_arena = comrak::Arena::new();
let root = comrak::parse_document(&comrak_arena, lex_out.normalized, &options.comrak);
let blocks_ir: Vec<Vec<ir::IrBlock>> = {
let mut builder = ir::StreamingIrBuilder::new(Some(&lex_out), &sanitized.text);
root.children()
.map(|child| builder.walk_block(child))
.collect()
};
ast_splice::splice_into_ast(root, &comrak_arena, &lex_out, &sanitized.text);
let blocks = collect_rendered_blocks(root, options, blocks_ir);
let diagnostics = lex_out.diagnostics.iter().map(Diagnostic::from).collect();
(blocks, diagnostics)
}
fn collect_rendered_blocks<'a>(
root: &'a AstNode<'a>,
options: &Options,
mut blocks_ir: Vec<Vec<ir::IrBlock>>,
) -> Vec<RenderedBlock> {
let mut blocks = Vec::new();
for (idx, child) in root.children().enumerate() {
let data = child.data.borrow();
let line = sentinel_stream::saturating_u32(data.sourcepos.start.line).max(1);
drop(data);
let mut block_html = String::new();
comrak::format_html(child, &options.comrak, &mut block_html)
.expect("formatting a String never fails");
let ir_blocks = if idx < blocks_ir.len() {
mem::take(&mut blocks_ir[idx])
} else {
Vec::new()
};
blocks.push(RenderedBlock {
ir: ir_blocks,
html: block_html,
source_line: line,
});
}
blocks
}
#[must_use]
pub fn serialize(input: &str) -> String {
if !source_within_span_budget(input) {
return String::new();
}
let arena = Arena::new();
let lex_out = aozora::lex_into_arena(input, &arena);
aozora_serialize::serialize(&lex_out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_round_trips_through_html() {
let r = render("hello, world", &Options::default());
assert!(r.html.contains("hello, world"), "html: {}", r.html);
assert!(r.diagnostics.is_empty());
}
#[test]
fn plain_text_serialize_returns_input_unchanged() {
assert_eq!(serialize("plain text"), "plain text");
}
#[test]
fn ruby_renders_as_html_ruby_element() {
let r = render("|青梅《おうめ》へ", &Options::default());
assert!(r.html.contains("<ruby>"), "html: {}", r.html);
assert!(r.html.contains("青梅"));
assert!(r.html.contains("おうめ"));
assert!(!r.html.contains("[#"));
}
#[test]
fn page_break_promotes_and_does_not_leak_brackets() {
let r = render("前[#改ページ]後", &Options::default());
assert!(!r.html.contains("[#"), "html: {}", r.html);
}
#[test]
fn unknown_annotation_keeps_brackets_inside_wrapper() {
let r = render("前[#ほげふが]後", &Options::default());
assert!(
!contains_bare_bracket(&r.html),
"bare bracket leaked in: {}",
r.html
);
}
#[test]
fn commonmark_passes_through_with_heading_intact() {
let r = render("# Hello\n\nworld", &Options::default());
assert!(r.html.contains("<h1>Hello</h1>"), "html: {}", r.html);
assert!(r.html.contains("world"));
}
#[test]
fn gfm_only_options_have_aozora_disabled_and_gfm_extensions_enabled() {
let opts = Options::gfm_only();
assert!(!opts.aozora_enabled, "gfm_only must skip the aozora pass");
assert!(opts.comrak.extension.strikethrough);
assert!(opts.comrak.extension.table);
assert!(opts.comrak.extension.autolink);
assert!(opts.comrak.extension.tasklist);
assert!(opts.comrak.extension.tagfilter);
assert!(opts.comrak.render.r#unsafe);
}
#[test]
fn options_builders_and_getters_round_trip() {
let opts = Options::default()
.with_aozora_enabled(false)
.with_source_line_anchors(true);
assert!(!opts.aozora_enabled());
assert!(opts.source_line_anchors());
assert!(opts.comrak().extension.table);
}
#[test]
fn gfm_only_renders_strikethrough_and_does_not_recognise_ruby() {
let opts = Options::gfm_only();
let html = render("~~strike~~ |青梅《おうめ》", &opts).html;
assert!(html.contains("<del>strike</del>"), "html: {html}");
assert!(
html.contains("|青梅"),
"ruby trigger must survive raw: {html}"
);
assert!(
!html.contains("<ruby>"),
"ruby must NOT render in gfm-only: {html}"
);
}
#[test]
fn contains_bare_bracket_helper_detects_leaked_marker() {
assert!(contains_bare_bracket("plain [# leak"));
assert!(!contains_bare_bracket(
"<span class=\"aozora-md-annotation\" hidden>[#</span>"
));
assert!(!contains_bare_bracket("no marker at all"));
}
#[test]
fn commonmark_only_enables_raw_html_and_disables_aozora() {
let opts = Options::commonmark_only();
assert!(
opts.comrak.render.r#unsafe,
"commonmark_only must enable raw-HTML passthrough for the spec runner"
);
assert!(
!opts.aozora_enabled,
"commonmark_only must skip the aozora pass"
);
}
#[test]
fn default_does_not_enable_raw_html() {
let opts = Options::default();
assert!(
!opts.comrak.render.r#unsafe,
"default must leave raw HTML escaped (no render.unsafe)"
);
assert!(opts.aozora_enabled, "default must run the aozora pass");
}
#[test]
fn len_budget_boundary_is_exactly_u32_max() {
assert!(len_within_span_budget(0));
assert!(len_within_span_budget(1024));
assert!(
len_within_span_budget(MAX_SOURCE_BYTES),
"exactly u32::MAX bytes is still addressable"
);
if let Some(over) = MAX_SOURCE_BYTES.checked_add(1) {
assert!(
!len_within_span_budget(over),
"one byte past u32::MAX must be rejected"
);
}
}
#[test]
fn in_budget_input_still_renders_normally() {
let r = render("# hi\n\nbody", &Options::default());
assert!(r.html.contains("<h1>hi</h1>"), "html: {}", r.html);
let ir = render_to_ir("para", &Options::default());
assert!(!ir.ir.blocks.is_empty());
let (blocks, _) = render_blocks_to_ir("a\n\nb", &Options::default());
assert_eq!(blocks.len(), 2);
assert_eq!(serialize("plain"), "plain");
}
fn contains_bare_bracket(html: &str) -> bool {
let needle = "[#";
let wrapper_open = "aozora-md-annotation";
let mut pos = 0;
while let Some(idx) = html[pos..].find(needle) {
let abs = pos + idx;
let prefix = &html[..abs];
let last_open = prefix.rfind('<').unwrap_or(0);
let last_close = prefix.rfind('>').unwrap_or(0);
let inside_tag = last_open > last_close;
let in_wrapper = prefix.contains(wrapper_open);
if !inside_tag && !in_wrapper {
return true;
}
pos = abs + needle.len();
}
false
}
}