use crate::input::{line_ranges, FormatData, Prepared};
use crate::{AnalysisError, Config};
use pulldown_cmark::{CodeBlockKind, Event, LinkType, Options, Parser, Tag, TagEnd};
use std::ops::Range;
pub const F_QUOTED: u8 = 1;
pub const F_HEADING: u8 = 2;
pub const F_SUBJECT: u8 = 4;
pub const F_BODY: u8 = 8;
pub const F_TRAILER: u8 = 16;
pub const F_FULL_FOLD: u8 = 64;
pub const F_HTML_TEXT: u8 = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RegionKind {
Prose,
CodeBlock,
InlineCode,
Html,
Autolink,
LinkUrl,
Structure,
}
impl RegionKind {
pub fn reason(self) -> &'static str {
match self {
RegionKind::Prose => "prose",
RegionKind::CodeBlock => "code_fence",
RegionKind::InlineCode => "inline_code",
RegionKind::Html => "html",
RegionKind::Autolink => "autolink",
RegionKind::LinkUrl => "link_url",
RegionKind::Structure => "structure",
}
}
}
#[derive(Debug, Clone)]
pub struct Region {
pub range: Range<usize>,
pub kind: RegionKind,
}
#[derive(Debug, Clone)]
pub enum NormOp {
Block,
Text {
range: Range<usize>,
flags: u8,
},
TextOwned {
range: Range<usize>,
content: String,
flags: u8,
},
Break {
range: Range<usize>,
hard: bool,
},
Barrier {
range: Range<usize>,
flags: u8,
},
}
#[derive(Debug, Clone)]
pub struct Heading {
pub level: u32,
pub range: Range<usize>,
pub text: String,
pub text_range: Range<usize>,
}
#[derive(Debug, Clone)]
pub struct Section {
pub title: String,
pub level: u32,
pub range: Range<usize>,
}
#[derive(Debug, Clone)]
pub struct CodeRegion {
pub range: Range<usize>,
pub fenced: bool,
pub info: String,
}
#[derive(Debug, Clone)]
pub struct HtmlComment {
pub range: Range<usize>,
pub content: String,
pub content_range: Range<usize>,
}
#[derive(Debug, Clone, Default)]
pub struct Stats {
pub word_count: u64,
pub paragraph_words: Vec<u64>,
pub bullets: u64,
pub bullets_with_link: u64,
pub task_bullets: u64,
pub bold_label_items: u64,
}
#[derive(Debug, Clone, Default)]
pub struct Doc {
pub ops: Vec<NormOp>,
pub regions: Vec<Region>,
pub prose_regions: Vec<(Range<usize>, u8)>,
pub code_regions: Vec<CodeRegion>,
pub link_url_regions: Vec<Range<usize>>,
pub html_comments: Vec<HtmlComment>,
pub headings: Vec<Heading>,
pub sections: Vec<Section>,
pub emphasis: Vec<(Range<usize>, String)>,
pub stats: Stats,
pub bold_label_ranges: Vec<Range<usize>>,
pub fence_unclosed: Option<Range<usize>>,
pub html_unclosed_comment: Option<Range<usize>>,
pub html_unclosed_script: Option<Range<usize>>,
pub html_unparseable: Option<Range<usize>>,
pub numeric_ref_anomaly: Option<Range<usize>>,
pub html_bytes: usize,
pub link_url_decoded: Vec<(Range<usize>, String)>,
pub unused_refdefs: Vec<(Range<usize>, Option<String>)>,
pub dropped_html_text: Vec<Range<usize>>,
}
pub fn build_doc(prepared: &Prepared, _config: &Config) -> Result<Doc, AnalysisError> {
let doc = match &prepared.format {
FormatData::Markdown => build_markdown(&prepared.text),
FormatData::Text => build_text(&prepared.text),
FormatData::Commit(split) => build_commit(&prepared.text, split),
FormatData::Manifest(info) => build_manifest(&prepared.text, info),
};
Ok(doc)
}
fn count_words(s: &str) -> u64 {
let mut n = 0u64;
let mut in_word = false;
for c in s.chars() {
let w = unicode_ident::is_xid_continue(c);
if w && !in_word {
n += 1;
}
in_word = w;
}
n
}
fn build_markdown(src: &str) -> Doc {
let opts = Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_TASKLISTS
| Options::ENABLE_YAML_STYLE_METADATA_BLOCKS;
let parser = Parser::new_ext(src, opts);
let refdefs: Vec<(String, Range<usize>, String, Option<String>)> = parser
.reference_definitions()
.iter()
.map(|(label, def)| {
(
label.to_string(),
def.span.clone(),
def.dest.to_string(),
def.title.as_ref().map(|t| t.to_string()),
)
})
.collect();
let mut doc = Doc::default();
let mut excluded: Vec<Region> = Vec::new();
let mut code_depth = 0usize;
let mut quote_depth = 0usize;
let mut heading_level: Option<u32> = None;
let mut heading_buf = String::new();
let mut heading_texts: Vec<Range<usize>> = Vec::new();
let mut heading_range = 0..0;
let mut autolink_depth = 0usize;
let mut item_stack: Vec<bool> = Vec::new(); let mut para_words: Option<u64> = None;
let mut item_fresh = false;
let mut pending_bold_label: Option<Range<usize>> = None;
let mut awaiting_colon: Option<Range<usize>> = None;
let mut used_labels: Vec<String> = Vec::new();
let mut fence_stack: Vec<(Range<usize>, char, usize, usize, String)> = Vec::new();
let mut pending_html: Option<Range<usize>> = None;
for (event, range) in parser.into_offset_iter() {
let ignorable_ws = matches!(&event, Event::Text(t)
if range.start == range.end && t.chars().all(char::is_whitespace));
if !matches!(event, Event::Html(_) | Event::InlineHtml(_)) && !ignorable_ws {
if let Some(joined) = pending_html.take() {
collect_html_payload(src, &joined, &mut doc);
}
}
match event {
Event::Start(tag) => {
match &tag {
Tag::Paragraph
| Tag::Item
| Tag::TableCell
| Tag::FootnoteDefinition(_)
| Tag::MetadataBlock(_) => {
doc.ops.push(NormOp::Block);
}
Tag::Heading { .. } | Tag::BlockQuote(_) => {
doc.ops.push(NormOp::Block);
}
_ => {}
}
match tag {
Tag::Table(_) => {
let bend = src[range.start..]
.chars()
.next()
.map(|c| range.start + c.len_utf8())
.unwrap_or(range.start);
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
doc.ops.push(NormOp::Barrier {
range: range.start..bend,
flags,
});
}
Tag::CodeBlock(kind) => {
code_depth += 1;
let (fenced, info) = match kind {
CodeBlockKind::Fenced(i) => (true, i.to_string()),
CodeBlockKind::Indented => (false, String::new()),
};
if fenced {
let block = &src[range.clone()];
let opening = block.trim_start_matches([' ', '\t', '>']);
let ch = opening.chars().next().unwrap_or('`');
let open_len = opening.chars().take_while(|&c| c == ch).count();
let fence_pos = range.start + (block.len() - opening.len());
let line_start =
src[..fence_pos].rfind('\n').map(|p| p + 1).unwrap_or(0);
let open_col = width_cols(&src[line_start..fence_pos]);
fence_stack.push((range.clone(), ch, open_len, open_col, info.clone()));
}
doc.code_regions.push(CodeRegion {
range: range.clone(),
fenced,
info,
});
excluded.push(Region {
range: range.clone(),
kind: RegionKind::CodeBlock,
});
item_fresh = false;
}
Tag::BlockQuote(_) => {
quote_depth += 1;
}
Tag::Heading { level, .. } => {
heading_level = Some(level as u32);
heading_buf.clear();
heading_texts.clear();
heading_range = range.clone();
}
Tag::Paragraph => {
para_words = Some(0);
}
Tag::Item => {
doc.stats.bullets += 1;
item_stack.push(false);
item_fresh = true;
pending_bold_label = None;
awaiting_colon = None;
}
Tag::Link {
link_type,
dest_url,
ref id,
..
} => {
if !id.is_empty() {
used_labels.push(id.to_lowercase());
}
if let Some(top) = item_stack.last_mut() {
*top = true;
}
match link_type {
LinkType::Autolink | LinkType::Email => {
autolink_depth += 1;
let inner = range.start + 1..range.end.saturating_sub(1);
if inner.start < inner.end {
excluded.push(Region {
range: inner.clone(),
kind: RegionKind::Autolink,
});
doc.link_url_regions.push(inner.clone());
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
if heading_level.is_some() {
flags |= F_HEADING;
}
doc.ops.push(NormOp::Barrier {
range: inner,
flags,
});
}
}
LinkType::Inline => {
if let Some(rel) = inline_dest_range(&src[range.clone()], &dest_url)
{
let url_range = range.start + rel.start..range.start + rel.end;
excluded.push(Region {
range: url_range.clone(),
kind: RegionKind::LinkUrl,
});
if !url_range.is_empty()
&& src[url_range.clone()] != *dest_url
&& !dest_url.is_empty()
{
doc.link_url_decoded
.push((url_range.clone(), dest_url.to_string()));
}
doc.link_url_regions.push(url_range);
}
}
LinkType::Reference
| LinkType::ReferenceUnknown
| LinkType::Collapsed
| LinkType::CollapsedUnknown
| LinkType::Shortcut
| LinkType::ShortcutUnknown => {}
_ => {}
}
}
Tag::Image {
link_type,
dest_url,
ref id,
..
} => {
if !id.is_empty() {
used_labels.push(id.to_lowercase());
}
if link_type == LinkType::Inline {
if let Some(rel) = inline_dest_range(&src[range.clone()], &dest_url) {
let url_range = range.start + rel.start..range.start + rel.end;
excluded.push(Region {
range: url_range.clone(),
kind: RegionKind::LinkUrl,
});
if !url_range.is_empty()
&& src[url_range.clone()] != *dest_url
&& !dest_url.is_empty()
{
doc.link_url_decoded
.push((url_range.clone(), dest_url.to_string()));
}
doc.link_url_regions.push(url_range);
}
}
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
if heading_level.is_some() {
flags |= F_HEADING;
}
doc.ops.push(NormOp::Barrier {
range: range.start..(range.start + 2).min(range.end),
flags,
});
}
Tag::Emphasis | Tag::Strong => {
let inner = src[range.clone()].trim_matches(['*', '_']);
doc.emphasis.push((range.clone(), inner.to_string()));
if item_fresh && matches!(tag, Tag::Strong) {
pending_bold_label = Some(range.clone());
}
}
_ => {}
}
}
Event::End(tag_end) => match tag_end {
TagEnd::CodeBlock => {
code_depth = code_depth.saturating_sub(1);
if let Some((frange, ch, open_len, open_col, _info)) = fence_stack.pop() {
if is_unclosed_fence(src, &frange, ch, open_len, open_col) {
doc.fence_unclosed = Some(frange);
}
}
}
TagEnd::BlockQuote(_) => {
quote_depth = quote_depth.saturating_sub(1);
}
TagEnd::Heading(_) => {
let level = heading_level.take().unwrap_or(1);
let text_range = match (heading_texts.first(), heading_texts.last()) {
(Some(f), Some(l)) => f.start..l.end,
_ => heading_range.start..heading_range.start,
};
doc.headings.push(Heading {
level,
range: heading_range.clone(),
text: heading_buf.trim().to_string(),
text_range,
});
}
TagEnd::Paragraph => {
if let Some(w) = para_words.take() {
doc.stats.paragraph_words.push(w);
}
}
TagEnd::Item => {
if let Some(had_link) = item_stack.pop() {
if had_link {
doc.stats.bullets_with_link += 1;
}
}
item_fresh = false;
}
TagEnd::Strong => {
if let Some(label_range) = pending_bold_label.take() {
if src[label_range.clone()]
.trim_end_matches(['*', '_'])
.trim_end()
.ends_with(':')
{
doc.stats.bold_label_items += 1;
doc.bold_label_ranges.push(label_range);
} else {
awaiting_colon = Some(label_range);
}
}
}
TagEnd::Link => {
autolink_depth = autolink_depth.saturating_sub(1);
}
TagEnd::TableCell => {
let bend = src[range.end..]
.chars()
.next()
.map(|c| range.end + c.len_utf8())
.unwrap_or(range.end);
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
doc.ops.push(NormOp::Barrier {
range: range.end..bend,
flags,
});
}
TagEnd::Image => {
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
if heading_level.is_some() {
flags |= F_HEADING;
}
doc.ops.push(NormOp::Barrier {
range: range.end.saturating_sub(1)..range.end,
flags,
});
}
_ => {}
},
Event::Text(_) => {
if code_depth > 0 {
} else if autolink_depth > 0 {
} else {
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
if heading_level.is_some() {
flags |= F_HEADING;
heading_buf.push_str(&src[range.clone()]);
heading_texts.push(range.clone());
}
let w = count_words(&src[range.clone()]);
doc.stats.word_count += w;
if let Some(pw) = para_words.as_mut() {
*pw += w;
}
if let Some(label_range) = awaiting_colon.take() {
if src[range.clone()].trim_start().starts_with(':') {
doc.stats.bold_label_items += 1;
doc.bold_label_ranges.push(label_range);
}
}
doc.ops.push(NormOp::Text {
range: range.clone(),
flags,
});
if !src[range.clone()].trim().is_empty() {
item_fresh = false;
}
}
}
Event::Code(_) => {
if code_depth == 0 {
excluded.push(Region {
range: range.clone(),
kind: RegionKind::InlineCode,
});
doc.code_regions.push(CodeRegion {
range: range.clone(),
fenced: false,
info: String::new(),
});
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
if heading_level.is_some() {
flags |= F_HEADING;
}
doc.ops.push(NormOp::Barrier {
range: range.clone(),
flags,
});
}
item_fresh = false;
awaiting_colon = None;
}
Event::Html(_) | Event::InlineHtml(_) => {
doc.html_bytes += range.len();
excluded.push(Region {
range: range.clone(),
kind: RegionKind::Html,
});
if matches!(&event, Event::InlineHtml(_)) {
let frag = &src[range.clone()];
if !frag.starts_with("</")
&& BARRIER_VOID_ELEMENTS.contains(&tag_name(frag).as_str())
{
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
if heading_level.is_some() {
flags |= F_HEADING;
}
doc.ops.push(NormOp::Barrier {
range: range.clone(),
flags,
});
}
}
match pending_html.take() {
Some(p)
if p.end == range.start || is_wrapped_html_gap(src, p.end..range.start) =>
{
pending_html = Some(p.start..range.end);
}
Some(p) => {
collect_html_payload(src, &p, &mut doc);
pending_html = Some(range.clone());
}
None => pending_html = Some(range.clone()),
}
item_fresh = false;
}
Event::SoftBreak => {
if code_depth == 0 {
doc.ops.push(NormOp::Break {
range: range.clone(),
hard: false,
});
}
}
Event::HardBreak => {
if code_depth == 0 {
doc.ops.push(NormOp::Break {
range: range.clone(),
hard: true,
});
}
}
Event::TaskListMarker(_) => {
doc.stats.task_bullets += 1;
}
Event::FootnoteReference(_) => {
let mut flags = 0u8;
if quote_depth > 0 {
flags |= F_QUOTED;
}
if heading_level.is_some() {
flags |= F_HEADING;
}
doc.ops.push(NormOp::Barrier {
range: range.clone(),
flags,
});
}
Event::Rule => {
item_fresh = false;
}
_ => {}
}
}
if let Some(joined) = pending_html.take() {
collect_html_payload(src, &joined, &mut doc);
}
for (label, span, dest, title) in refdefs {
let dest_range = refdef_dest_range(src, &span);
if !dest_range.is_empty() && src[dest_range.clone()] != dest && !dest.is_empty() {
doc.link_url_decoded.push((dest_range.clone(), dest));
}
doc.link_url_regions.push(dest_range);
excluded.push(Region {
range: span.clone(),
kind: RegionKind::LinkUrl,
});
if !used_labels.iter().any(|u| u == &label.to_lowercase()) {
doc.unused_refdefs.push((span, title));
}
}
finish(src, &mut doc, excluded);
doc
}
const BARRIER_VOID_ELEMENTS: &[&str] = &["br", "embed", "hr", "img", "input"];
fn inline_dest_range(slice: &str, dest: &str) -> Option<Range<usize>> {
let delim = inline_delim(slice)?;
if !dest.is_empty() {
if let Some(p) = slice[delim..].find(dest) {
return Some(delim + p..delim + p + dest.len());
}
}
inline_dest_syntactic(slice.as_bytes(), delim)
}
fn inline_delim(slice: &str) -> Option<usize> {
let mut at = 0usize;
while let Some(o) = slice[at..].find("](") {
let pos = at + o;
let backslashes = slice[..pos]
.bytes()
.rev()
.take_while(|&b| b == b'\\')
.count();
if backslashes % 2 == 0 {
return Some(pos + 2);
}
at = pos + 1;
}
None
}
fn inline_dest_syntactic(bytes: &[u8], delim: usize) -> Option<Range<usize>> {
let mut d = delim;
while d < bytes.len() && bytes[d].is_ascii_whitespace() {
d += 1;
}
if d >= bytes.len() {
return None;
}
if bytes[d] == b'<' {
let inner = d + 1;
let mut j = inner;
loop {
match bytes.get(j) {
Some(b'\\') => j += 2,
Some(b'>') => return Some(inner..j),
Some(b'\n') | None => return None,
Some(_) => j += 1,
}
}
}
let mut j = d;
let mut depth = 0usize;
while j < bytes.len() {
match bytes[j] {
b'\\' => j += 2,
b'(' => {
depth += 1;
j += 1;
}
b')' => {
if depth == 0 {
break;
}
depth -= 1;
j += 1;
}
b if b.is_ascii_whitespace() => break,
_ => j += 1,
}
}
(d < j).then_some(d..j)
}
fn refdef_dest_range(src: &str, span: &Range<usize>) -> Range<usize> {
let slice = &src[span.clone()];
let bytes = slice.as_bytes();
let Some(open) = slice.find('[') else {
return span.clone();
};
let mut i = open + 1;
loop {
match bytes.get(i) {
Some(b'\\') => i += 2,
Some(b']') => break,
Some(_) => i += 1,
None => return span.clone(),
}
}
if bytes.get(i + 1) != Some(&b':') {
return span.clone();
}
let mut d = i + 2;
while d < bytes.len() && bytes[d].is_ascii_whitespace() {
d += 1;
}
if d >= bytes.len() {
return span.clone();
}
if bytes[d] == b'<' {
let inner = d + 1;
let mut j = inner;
loop {
match bytes.get(j) {
Some(b'\\') => j += 2,
Some(b'>') => return span.start + inner..span.start + j,
Some(b'\n') | None => return span.clone(),
Some(_) => j += 1,
}
}
}
let mut j = d;
while j < bytes.len() && !bytes[j].is_ascii_whitespace() {
j += 1;
}
span.start + d..span.start + j
}
fn width_cols(prefix: &str) -> usize {
prefix.chars().fold(
0,
|col, c| if c == '\t' { col / 4 * 4 + 4 } else { col + 1 },
)
}
fn is_unclosed_fence(
src: &str,
range: &Range<usize>,
ch: char,
open_len: usize,
open_col: usize,
) -> bool {
if range.end < src.len().saturating_sub(1) {
return false;
}
let body = &src[range.clone()];
let mut lines: Vec<&str> = body.lines().collect();
while let Some(last) = lines.last() {
if last.trim().is_empty() {
lines.pop();
} else {
break;
}
}
if lines.len() < 2 {
return true;
}
let line = lines.last().unwrap();
let tail = line.trim_start_matches([' ', '\t', '>']);
let indent = width_cols(&line[..line.len() - tail.len()]).saturating_sub(open_col);
let run = tail.chars().take_while(|&c| c == ch).count();
let closes = indent <= 3 && run >= open_len.max(3) && tail[run..].trim().is_empty();
!closes
}
fn is_wrapped_html_gap(src: &str, gap: Range<usize>) -> bool {
if gap.start >= gap.end {
return false;
}
let s = &src[gap];
!s.contains('\n') && s.chars().all(|c| matches!(c, '>' | ' ' | '\t' | '\r'))
}
fn collect_html_payload(src: &str, range: &Range<usize>, doc: &mut Doc) {
let slice = &src[range.clone()];
let mut at = 0usize;
while let Some(open) = slice[at..].find("<!--") {
let cstart = at + open;
let Some(close) = slice[cstart + 4..].find("-->") else {
if doc.html_unclosed_comment.is_none() {
doc.html_unclosed_comment = Some(range.start + cstart..range.end);
}
return;
};
let content_start = cstart + 4;
let content_end = cstart + 4 + close;
let content = slice[content_start..content_end].trim().to_string();
doc.html_comments.push(HtmlComment {
range: range.start + cstart..range.start + content_end + 3,
content,
content_range: range.start + content_start..range.start + content_end,
});
at = content_end + 3;
}
let mut anomaly = None;
let pieces = html_visible_pieces(slice, range.start, &mut anomaly);
if doc.html_unparseable.is_none() {
doc.html_unparseable = anomaly;
}
if pieces.iter().any(|p| matches!(p, HtmlPiece::Text(_))) {
doc.ops.push(NormOp::Block);
for piece in pieces {
match piece {
HtmlPiece::Text(r) => {
doc.html_bytes = doc.html_bytes.saturating_sub(r.len());
doc.ops.push(NormOp::Text {
range: r,
flags: F_HTML_TEXT,
});
}
HtmlPiece::Space(r) => {
doc.html_bytes = doc.html_bytes.saturating_sub(r.len());
doc.ops.push(NormOp::TextOwned {
range: r,
content: " ".to_string(),
flags: F_HTML_TEXT,
});
}
HtmlPiece::Block => doc.ops.push(NormOp::Block),
}
}
}
collect_script_style_bodies(slice, range.start, doc);
}
enum HtmlPiece {
Text(Range<usize>),
Space(Range<usize>),
Block,
}
const INLINE_ELEMENTS: &[&str] = &[
"a", "abbr", "b", "bdi", "bdo", "cite", "data", "dfn", "em", "i", "mark", "q", "rp", "rt",
"ruby", "s", "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr", "big", "tt",
"ins", "del", "font", "nobr", "label", "img",
];
const SKIP_BODY_ELEMENTS: &[&str] = &["pre", "code", "kbd", "samp", "script", "style"];
fn tag_end(slice: &str, lt: usize) -> Option<usize> {
let bytes = slice.as_bytes();
let mut i = lt + 1;
let mut quote: Option<u8> = None;
while i < bytes.len() {
let b = bytes[i];
match quote {
Some(q) => {
if b == q {
quote = None;
}
}
None => match b {
b'"' | b'\'' => quote = Some(b),
b'>' => return Some(i + 1),
_ => {}
},
}
i += 1;
}
None
}
fn tag_name(tag: &str) -> String {
tag.trim_start_matches('<')
.trim_start_matches('/')
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect::<String>()
.to_ascii_lowercase()
}
fn tag_is_self_closing(tag: &str) -> bool {
let Some(head) = tag.strip_suffix("/>") else {
return false;
};
let token_start = head
.rfind(|c: char| c.is_ascii_whitespace() || c == '"' || c == '\'')
.map(|p| p + 1)
.unwrap_or(0);
!head[token_start..].contains('=')
}
fn find_close_tag(hay: &str, name: &str) -> Option<usize> {
find_tag_ci(hay, &format!("</{name}"))
}
fn find_open_tag_ci(hay: &str, name: &str) -> Option<usize> {
find_tag_ci(hay, &format!("<{name}"))
}
fn find_tag_ci(hay: &str, needle: &str) -> Option<usize> {
let mut at = 0usize;
while let Some(o) = find_ci_ascii(&hay[at..], needle.as_bytes()) {
let pos = at + o;
match hay.as_bytes().get(pos + needle.len()) {
None => return Some(pos),
Some(&c) if c == b'>' || c == b'/' || c.is_ascii_whitespace() => return Some(pos),
_ => at = pos + 1,
}
}
None
}
fn starts_with_ci(s: &str, prefix: &[u8]) -> bool {
s.len() >= prefix.len() && s.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix)
}
fn skipped_body_anomaly(body: &str, base: usize) -> Option<Range<usize>> {
let mut first: Option<usize> = None;
let mut consider = |p: Option<usize>| {
if let Some(p) = p {
first = Some(first.map_or(p, |f| f.min(p)));
}
};
consider(find_ci_ascii(body, b"<![cdata["));
consider(body.find("--!>"));
consider(find_open_tag_ci(body, "template"));
for name in SKIP_BODY_ELEMENTS {
let mut at = 0usize;
while let Some(o) = find_open_tag_ci(&body[at..], name) {
let pos = at + o;
let Some(te) = tag_end(body, pos) else { break };
if tag_is_self_closing(&body[pos..te]) {
consider(Some(pos));
break;
}
at = te;
}
}
first.map(|p| base + p..base + body.len())
}
fn is_tag_start(bytes: &[u8], i: usize) -> bool {
matches!(bytes.get(i + 1), Some(&c) if c.is_ascii_alphabetic() || c == b'/' || c == b'!' || c == b'?')
}
fn html_visible_pieces(
slice: &str,
base: usize,
anomaly: &mut Option<Range<usize>>,
) -> Vec<HtmlPiece> {
let bytes = slice.as_bytes();
let n = slice.len();
let mut pieces: Vec<HtmlPiece> = Vec::new();
let mut run_start: Option<usize> = None;
let mut pending_ws: Option<Range<usize>> = None;
let mut text_in_block = false;
let mut i = 0usize;
let flush_run = |pieces: &mut Vec<HtmlPiece>, run_start: &mut Option<usize>, end: usize| {
if let Some(s) = run_start.take() {
if s < end {
pieces.push(HtmlPiece::Text(base + s..base + end));
}
}
};
while i < n {
if slice[i..].starts_with("<!--") {
flush_run(&mut pieces, &mut run_start, i);
match slice[i + 4..].find("-->") {
Some(end) => {
if anomaly.is_none() && slice[i + 4..i + 4 + end].contains("--!>") {
*anomaly = Some(base + i..base + i + 4 + end + 3);
}
i = i + 4 + end + 3;
}
None => break, }
continue;
}
if starts_with_ci(&slice[i..], b"<![CDATA[") {
if anomaly.is_none() {
*anomaly = Some(base + i..base + n);
}
break;
}
if bytes[i] == b'<' && is_tag_start(bytes, i) {
flush_run(&mut pieces, &mut run_start, i);
let Some(te) = tag_end(slice, i) else {
break; };
let tag = &slice[i..te];
let name = tag_name(tag);
let is_end = tag.starts_with("</");
let self_closing = tag_is_self_closing(tag);
if !is_end && name == "template" {
if anomaly.is_none() {
*anomaly = Some(base + i..base + n);
}
match find_close_tag(&slice[te..], "template") {
Some(c) => i = tag_end(slice, te + c).unwrap_or(n),
None => break,
}
if text_in_block {
pieces.push(HtmlPiece::Block);
text_in_block = false;
}
pending_ws = None;
continue;
}
if !is_end && self_closing && SKIP_BODY_ELEMENTS.contains(&name.as_str()) {
if anomaly.is_none() {
*anomaly = Some(base + i..base + n);
}
}
if !is_end && !self_closing && SKIP_BODY_ELEMENTS.contains(&name.as_str()) {
let body_end = match find_close_tag(&slice[te..], &name) {
Some(c) => te + c,
None => n,
};
if anomaly.is_none() {
*anomaly = skipped_body_anomaly(&slice[te..body_end], base + te);
}
if body_end == n {
break;
}
i = tag_end(slice, body_end).unwrap_or(n);
if text_in_block {
pieces.push(HtmlPiece::Block);
text_in_block = false;
}
pending_ws = None;
continue;
}
if !INLINE_ELEMENTS.contains(&name.as_str()) {
if text_in_block {
pieces.push(HtmlPiece::Block);
text_in_block = false;
}
pending_ws = None;
}
i = te;
continue;
}
let ch = slice[i..].chars().next().unwrap();
if ch.is_whitespace() {
flush_run(&mut pieces, &mut run_start, i);
pending_ws = Some(match pending_ws.take() {
Some(r) => r.start..base + i + ch.len_utf8(),
None => base + i..base + i + ch.len_utf8(),
});
i += ch.len_utf8();
continue;
}
if run_start.is_none() {
if text_in_block {
if let Some(ws) = pending_ws.take() {
pieces.push(HtmlPiece::Space(ws));
}
}
pending_ws = None;
run_start = Some(i);
text_in_block = true;
}
i += ch.len_utf8();
}
flush_run(&mut pieces, &mut run_start, n);
pieces
}
fn collect_script_style_bodies(slice: &str, base: usize, doc: &mut Doc) {
let mut at = 0usize;
while at < slice.len() {
let next = [
("script", b"<script".as_slice()),
("style", b"<style".as_slice()),
]
.iter()
.filter_map(|(name, needle)| find_ci_ascii(&slice[at..], needle).map(|o| (at + o, *name)))
.min_by_key(|&(o, _)| o);
let Some((o, name)) = next else { break };
let after_name = o + 1 + name.len();
let boundary = slice
.as_bytes()
.get(after_name)
.map(|&c| c == b'>' || c == b'/' || c.is_ascii_whitespace())
.unwrap_or(false);
if !boundary {
at = o + 1;
continue;
}
let Some(te) = tag_end(slice, o) else {
if doc.html_unclosed_script.is_none() {
doc.html_unclosed_script = Some(base + o..base + slice.len());
}
break;
};
match find_close_tag(&slice[te..], name) {
Some(c) => {
let body = te..te + c;
if !slice[body.clone()].trim().is_empty() {
doc.dropped_html_text
.push(base + body.start..base + body.end);
}
let close_lt = te + c;
at = tag_end(slice, close_lt).unwrap_or(slice.len());
}
None => {
if doc.html_unclosed_script.is_none() {
doc.html_unclosed_script = Some(base + o..base + slice.len());
}
break;
}
}
}
}
fn find_ci_ascii(hay: &str, needle: &[u8]) -> Option<usize> {
let hb = hay.as_bytes();
let m = needle.len();
if m == 0 || hb.len() < m {
return None;
}
(0..=hb.len() - m).find(|&j| hb[j..j + m].eq_ignore_ascii_case(needle))
}
fn build_text(src: &str) -> Doc {
let mut doc = Doc::default();
let lines = line_ranges(src);
let mut prev_blank = true;
let mut para_words = 0u64;
for lr in &lines {
let line = &src[lr.clone()];
let blank = line.trim().is_empty();
if blank {
if para_words > 0 {
doc.stats.paragraph_words.push(para_words);
para_words = 0;
}
prev_blank = true;
continue;
}
doc.ops.push(NormOp::Block);
doc.ops.push(NormOp::Text {
range: lr.clone(),
flags: 0,
});
let trimmed = line.trim_start();
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
doc.stats.bullets += 1;
if trimmed.contains("](") || trimmed.contains("http") {
doc.stats.bullets_with_link += 1;
}
}
let w = count_words(line);
doc.stats.word_count += w;
para_words += w;
prev_blank = false;
}
let _ = prev_blank;
if para_words > 0 {
doc.stats.paragraph_words.push(para_words);
}
let excluded = Vec::new();
finish(src, &mut doc, excluded);
doc
}
fn build_commit(src: &str, split: &crate::input::CommitSplit) -> Doc {
let mut doc = Doc::default();
let lines = line_ranges(src);
for lr in &lines {
let line = &src[lr.clone()];
if line.trim().is_empty() {
continue;
}
let flags = if lr.start >= split.trailers.start && split.trailers.start < split.trailers.end
{
F_TRAILER
} else if lr.start >= split.body.start && lr.start < split.body.end {
F_BODY
} else if lr.start >= split.subject.start && lr.end <= split.subject.end {
F_SUBJECT
} else {
F_BODY
};
doc.ops.push(NormOp::Block);
doc.ops.push(NormOp::Text {
range: lr.clone(),
flags,
});
doc.stats.word_count += count_words(line);
}
finish(src, &mut doc, Vec::new());
doc
}
fn build_manifest(src: &str, info: &crate::input::ManifestInfo) -> Doc {
let mut doc = Doc::default();
if let Some(inner) = &info.description_inner {
doc.ops.push(NormOp::Block);
doc.ops.push(NormOp::Text {
range: inner.clone(),
flags: 0,
});
doc.stats.word_count += count_words(&src[inner.clone()]);
} else if let (Some(span), Some(value)) = (&info.description_span, &info.description) {
doc.ops.push(NormOp::Block);
doc.stats.word_count += count_words(value);
doc.ops.push(NormOp::TextOwned {
range: span.clone(),
content: value.clone(),
flags: 0,
});
}
finish(src, &mut doc, Vec::new());
doc
}
fn html_numeric_ref_len(s: &str, amp: usize) -> Option<usize> {
let body = s[amp..].strip_prefix("&#")?;
let (digits, prefix, hex) = match body.strip_prefix(['x', 'X']) {
Some(h) => (h, 3usize, true),
None => (body, 2usize, false),
};
let run = digits
.bytes()
.take_while(|b| {
if hex {
b.is_ascii_hexdigit()
} else {
b.is_ascii_digit()
}
})
.count();
if run == 0 {
return None;
}
let semi = digits.as_bytes().get(run) == Some(&b';');
Some(prefix + run + usize::from(semi))
}
fn scan_numeric_ref_anomalies(src: &str, doc: &mut Doc) {
let mut anomaly: Option<Range<usize>> = None;
'ops: for op in &doc.ops {
let NormOp::Text { range, flags } = op else {
continue;
};
let html = flags & F_HTML_TEXT != 0;
let slice = &src[range.clone()];
let bytes = slice.as_bytes();
let mut i = 0usize;
while i < slice.len() {
if bytes[i] != b'&' {
i += 1;
continue;
}
if let Some((elen, _)) = crate::views::entity_at(slice, i) {
i += elen;
continue;
}
use crate::views::NumRef;
let (len, bad) = match crate::views::classify_numeric_ref(slice, i) {
NumRef::Decode(len, _) => (len, false),
NumRef::Suppress(len) => (len, true),
NumRef::Overlong(len) => (len, html),
NumRef::Literal => match html_numeric_ref_len(slice, i) {
Some(len) => (len, html),
None => (1, false),
},
};
if bad {
anomaly = Some(range.start + i..range.start + i + len);
break 'ops; }
i += len;
}
}
doc.numeric_ref_anomaly = anomaly;
}
fn finish(src: &str, doc: &mut Doc, excluded: Vec<Region>) {
scan_numeric_ref_anomalies(src, doc);
let mut prose: Vec<(Range<usize>, u8)> = Vec::new();
for op in &doc.ops {
if let NormOp::Text { range, flags } | NormOp::TextOwned { range, flags, .. } = op {
if range.start < range.end {
match prose.last_mut() {
Some((last, f)) if last.end == range.start && *f == *flags => {
last.end = range.end;
}
_ => prose.push((range.clone(), *flags)),
}
}
}
}
doc.prose_regions = prose;
let mut all: Vec<Region> = Vec::new();
for (r, _) in &doc.prose_regions {
all.push(Region {
range: r.clone(),
kind: RegionKind::Prose,
});
}
all.extend(excluded);
all.sort_by_key(|r| (r.range.start, r.range.end));
let mut resolved: Vec<Region> = Vec::new();
for mut r in all {
if let Some(prev) = resolved.last() {
if r.range.start < prev.range.end {
r.range.start = prev.range.end;
}
}
if r.range.start < r.range.end {
resolved.push(r);
}
}
let mut full: Vec<Region> = Vec::new();
let mut at = 0usize;
for r in resolved {
if r.range.start > at {
full.push(Region {
range: at..r.range.start,
kind: RegionKind::Structure,
});
}
at = at.max(r.range.end);
full.push(r);
}
if at < src.len() {
full.push(Region {
range: at..src.len(),
kind: RegionKind::Structure,
});
}
doc.regions = full;
let mut sections = Vec::new();
for (i, h) in doc.headings.iter().enumerate() {
let end = doc
.headings
.iter()
.skip(i + 1)
.find(|h2| h2.level <= h.level)
.map(|h2| h2.range.start)
.unwrap_or(src.len());
sections.push(Section {
title: h.text.clone(),
level: h.level,
range: h.range.start..end,
});
}
doc.sections = sections;
}