use crate::engine::{ParsedRmdFileDoc, ParsedTexFileDoc};
use crate::report::{Severity, Violation};
use crate::tex::{self, position::LineIndex};
use regex::{Captures, Regex};
use std::sync::LazyLock;
const PARSE_RULE_ID: &str = "JSS-PARSE-000";
static FENCE_OPEN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^\s*```\s*(?:\{[^}]*\}|[A-Za-z0-9_.+-]+)?\s*$").unwrap());
static FENCE_CLOSE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*```\s*$").unwrap());
static HEADING: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^#{1,6}\s+.+$").unwrap());
static INLINE_R: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"`r\s[^`]*`").unwrap());
static BOLD_SPAN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*\n]+)\*\*").unwrap());
static HTML_COMMENT: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?s)<!--.*?-->").unwrap());
static AUTOLINK_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"<https?://[^>\s]+>").unwrap());
static LINK_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\]\([^)\s]+\)").unwrap());
static BARE_URL: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"https?://[^\s]+").unwrap());
static KNITR_R_TAG: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(:\s*)!r(\s+)").unwrap());
fn is_word_char(c: char) -> bool {
c.is_alphanumeric() || c == '_'
}
fn blank_preserving_newlines(s: &str) -> String {
s.chars()
.map(|c| if c == '\n' { '\n' } else { ' ' })
.collect()
}
fn strip_inline_code(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut mask = vec![false; n];
let mut i = 0;
while i < n {
if chars[i] == '`' && !(i > 0 && chars[i - 1] == '`') {
let mut j = i + 1;
while j < n && chars[j] != '`' && chars[j] != '\n' {
j += 1;
}
if j < n && chars[j] == '`' && j > i + 1 && !(j + 1 < n && chars[j + 1] == '`') {
for slot in mask.iter_mut().take(j + 1).skip(i) {
*slot = true;
}
i = j + 1;
continue;
}
}
i += 1;
}
chars
.iter()
.zip(mask.iter())
.map(|(&c, &m)| if m && c != '\n' { ' ' } else { c })
.collect()
}
fn strip_italic_spans(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let n = chars.len();
let mut mask = vec![false; n];
let mut i = 0;
while i < n {
if chars[i] == '*' {
let prev_ok = i == 0 || !(chars[i - 1] == '*' || is_word_char(chars[i - 1]));
if prev_ok {
let mut j = i + 1;
while j < n && chars[j] != '*' && chars[j] != '\n' {
j += 1;
}
if j < n && chars[j] == '*' && j > i + 1 {
let next_ok = j + 1 >= n || !is_word_char(chars[j + 1]);
if next_ok {
for slot in mask.iter_mut().take(j + 1).skip(i) {
*slot = true;
}
i = j + 1;
continue;
}
}
}
}
i += 1;
}
chars
.iter()
.zip(mask.iter())
.map(|(&c, &m)| if m && c != '\n' { ' ' } else { c })
.collect()
}
fn regex_blank(re: &Regex, text: &str) -> String {
re.replace_all(text, |caps: &Captures| -> String {
blank_preserving_newlines(caps.get(0).unwrap().as_str())
})
.into_owned()
}
fn scrub_prose(text: &str) -> String {
let text = regex_blank(&HTML_COMMENT, text);
let text = regex_blank(&AUTOLINK_URL, &text);
let text = regex_blank(&LINK_URL, &text);
let text = regex_blank(&BARE_URL, &text);
let text = regex_blank(&INLINE_R, &text);
let text = strip_inline_code(&text);
let text = regex_blank(&BOLD_SPAN, &text);
strip_italic_spans(&text)
}
fn parse_violation(path: &str, line: usize, severity: Severity, message: String) -> Violation {
Violation {
file: path.to_string(),
line: line as u32,
column: None,
rule_id: PARSE_RULE_ID.to_string(),
severity,
message,
suggestion: None,
fix: None,
}
}
fn validate_yaml_frontmatter(text: &str) -> Result<(), String> {
if text.trim().is_empty() {
return Ok(());
}
let sanitized = KNITR_R_TAG.replace_all(text, "$1$2");
match serde_yaml::from_str::<serde_yaml::Value>(&sanitized) {
Ok(_) => Ok(()),
Err(e) => Err(e.to_string()),
}
}
struct ProseBlock<'a> {
text: &'a str,
line: usize,
}
fn is_line_boundary(c: char) -> bool {
matches!(
c,
'\n' | '\r'
| '\u{0b}'
| '\u{0c}'
| '\u{1c}'
| '\u{1d}'
| '\u{1e}'
| '\u{85}'
| '\u{2028}'
| '\u{2029}'
)
}
fn python_splitlines(src: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut start = 0usize;
let mut iter = src.char_indices().peekable();
while let Some((pos, c)) = iter.next() {
if is_line_boundary(c) {
out.push(&src[start..pos]);
let mut end = pos + c.len_utf8();
if c == '\r' {
if let Some(&(npos, '\n')) = iter.peek() {
end = npos + 1;
iter.next();
}
}
start = end;
}
}
if start < src.len() {
out.push(&src[start..]);
}
out
}
pub fn parse_rmd_source(path: &str, src: &str) -> ParsedRmdFileDoc {
let lines: Vec<&str> = python_splitlines(src);
let n_lines = lines.len();
let mut violations: Vec<Violation> = Vec::new();
let mut prose_owned: Vec<(String, usize)> = Vec::new();
#[derive(PartialEq, Eq)]
enum State {
Start,
Frontmatter,
Body,
Fence,
}
let mut state = State::Start;
let mut fm_start_line = 0usize;
let mut fm_lines: Vec<&str> = Vec::new();
let mut fence_open_line = 0usize;
let mut prose_buffer: Vec<&str> = Vec::new();
let mut prose_start_line = 0usize;
fn flush_prose(buffer: &mut Vec<&str>, start_line: usize, out: &mut Vec<(String, usize)>) {
if !buffer.is_empty() {
out.push((buffer.join("\n"), start_line));
buffer.clear();
}
}
let mut i = 0usize;
while i < n_lines {
let line = lines[i];
let line_no = i + 1;
match state {
State::Start => {
if line.trim() == "---" {
state = State::Frontmatter;
fm_lines.clear();
fm_start_line = line_no;
} else {
state = State::Body;
continue; }
}
State::Frontmatter => {
if line.trim() == "---" {
let text = fm_lines.join("\n");
if let Err(msg) = validate_yaml_frontmatter(&text) {
violations.push(parse_violation(
path,
fm_start_line,
Severity::Warning,
format!("Malformed YAML frontmatter (ignored): {msg}"),
));
}
state = State::Body;
} else {
fm_lines.push(line);
}
}
State::Body => {
if FENCE_OPEN.is_match(line) {
flush_prose(&mut prose_buffer, prose_start_line, &mut prose_owned);
fence_open_line = line_no;
state = State::Fence;
} else if HEADING.is_match(line) || line.trim().is_empty() {
flush_prose(&mut prose_buffer, prose_start_line, &mut prose_owned);
} else {
if prose_buffer.is_empty() {
prose_start_line = line_no;
}
prose_buffer.push(line);
}
}
State::Fence => {
if FENCE_CLOSE.is_match(line) {
state = State::Body;
}
}
}
i += 1;
}
match state {
State::Frontmatter => {
violations.push(parse_violation(
path,
fm_start_line,
Severity::Error,
"Unterminated YAML frontmatter.".to_string(),
));
}
State::Fence => {
violations.push(parse_violation(
path,
fence_open_line,
Severity::Error,
"Unterminated fenced code block.".to_string(),
));
}
_ => {
flush_prose(&mut prose_buffer, prose_start_line, &mut prose_owned);
}
}
let scrubbed: Vec<ProseBlock> = prose_owned
.iter()
.map(|(text, line)| ProseBlock {
text: text.as_str(),
line: *line,
})
.collect();
let mut latex_fragments: Vec<ParsedTexFileDoc> = Vec::new();
for block in &scrubbed {
let scrubbed_text = scrub_prose(block.text);
if scrubbed_text.trim().is_empty() {
continue;
}
let mut parsed = tex::parse_tex_source(&scrubbed_text);
parsed.line_offset = (block.line - 1) as u32;
let line_index = LineIndex::with_offset(&parsed.chars, parsed.line_offset);
latex_fragments.push(ParsedTexFileDoc {
path: path.to_string(),
parsed,
line_index,
violations: Vec::new(),
});
}
ParsedRmdFileDoc {
path: path.to_string(),
latex_fragments,
violations,
}
}