use std::ops::Range;
use mant_ast::{Block, Diagnostic, DiagnosticLevel, LayoutHint, SourceSpan};
pub(super) struct MarkdownSource<'a> {
text: &'a str,
line_starts: Vec<usize>,
}
impl<'a> MarkdownSource<'a> {
pub(super) fn new(text: &'a str) -> Self {
let mut line_starts = vec![0];
line_starts.extend(
text.match_indices('\n')
.map(|(offset, _)| offset.saturating_add(1)),
);
Self { text, line_starts }
}
pub(super) fn raw(&self, range: &Range<usize>) -> &'a str {
let start = range.start.min(self.text.len());
let end = range.end.clamp(start, self.text.len());
self.text.get(start..end).unwrap_or_default()
}
pub(super) fn span(&self, range: &Range<usize>) -> SourceSpan {
let start = self.position(range.start);
let end = self.position(range.end);
SourceSpan {
line: start.0,
column: start.1,
end_line: Some(end.0),
end_column: Some(end.1),
}
}
pub(super) fn has_blank_line_between(&self, previous: SourceSpan, current: SourceSpan) -> bool {
let previous_end = self.offset(
previous.end_line.unwrap_or(previous.line),
previous.end_column.unwrap_or(previous.column),
);
let current_start = self.offset(current.line, current.column);
let mut boundary_start = previous_end.saturating_sub(2);
while boundary_start > 0 && !self.text.is_char_boundary(boundary_start) {
boundary_start -= 1;
}
let boundary_end = current_start.max(previous_end).min(self.text.len());
self.text
.get(boundary_start..boundary_end)
.unwrap_or_default()
.replace("\r\n", "\n")
.replace('\r', "\n")
.contains("\n\n")
}
pub(super) fn unsupported_block(
&self,
name: &str,
range: Range<usize>,
diagnostics: &mut Vec<Diagnostic>,
) -> Block {
self.report_unsupported(name, range.clone(), diagnostics);
Block::Unsupported {
name: Some(name.to_owned()),
text: self.raw(&range).to_owned(),
layout: LayoutHint::default(),
source: Some(self.span(&range)),
}
}
pub(super) fn unsupported_inline(
&self,
name: &str,
range: Range<usize>,
diagnostics: &mut Vec<Diagnostic>,
) -> String {
self.report_unsupported(name, range.clone(), diagnostics);
self.raw(&range).to_owned()
}
fn report_unsupported(
&self,
name: &str,
range: Range<usize>,
diagnostics: &mut Vec<Diagnostic>,
) {
diagnostics.push(Diagnostic {
level: DiagnosticLevel::Unsupported,
code: Some("markdown.unsupported".to_owned()),
message: format!("preserved unsupported Markdown {name} as source text"),
source: Some(self.span(&range)),
});
}
fn position(&self, offset: usize) -> (u32, u32) {
let offset = offset.min(self.text.len());
let line_index = self.line_starts.partition_point(|start| *start <= offset);
let line_index = line_index.saturating_sub(1);
let line_start = self.line_starts[line_index];
(
u32::try_from(line_index.saturating_add(1)).unwrap_or(u32::MAX),
u32::try_from(offset.saturating_sub(line_start).saturating_add(1)).unwrap_or(u32::MAX),
)
}
fn offset(&self, line: u32, column: u32) -> usize {
let line_index = usize::try_from(line.saturating_sub(1))
.unwrap_or(usize::MAX)
.min(self.line_starts.len().saturating_sub(1));
let line_start = self.line_starts[line_index];
let next_line = self
.line_starts
.get(line_index.saturating_add(1))
.copied()
.unwrap_or(self.text.len());
line_start
.saturating_add(usize::try_from(column.saturating_sub(1)).unwrap_or(usize::MAX))
.min(next_line)
}
}
#[cfg(test)]
mod tests {
use mant_ast::SourceSpan;
use super::MarkdownSource;
fn span(line: u32, column: u32, end_line: u32, end_column: u32) -> SourceSpan {
SourceSpan {
line,
column,
end_line: Some(end_line),
end_column: Some(end_column),
}
}
#[test]
fn detects_a_blank_line_after_a_multibyte_character() {
let source = MarkdownSource::new("# 中\n\ntext\n");
let previous = span(1, 1, 1, 4);
let current = span(3, 1, 3, 5);
assert!(
source.has_blank_line_between(previous, current),
"a blank line after a multi-byte character must be detected"
);
}
#[test]
fn reports_no_blank_line_for_directly_adjacent_lines() {
let source = MarkdownSource::new("# 中\ntext\n");
let previous = span(1, 1, 1, 4);
let current = span(2, 1, 2, 5);
assert!(!source.has_blank_line_between(previous, current));
}
}