use rowan::{GreenToken, NodeOrToken, TextRange, TextSize};
use crate::parser::lexer::lex_with;
use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
use super::leaf::{Context, context_admits, shifted_errors, text_reads_are_inert};
use super::{Edit, ReparseBase, ReparseTier, Reparsed, finish};
const MAX_PROBE_BYTES: usize = 1024;
pub(super) fn reparse_token(
base: &ReparseBase<'_>,
edit: &Edit,
new_text: &str,
) -> Option<Reparsed> {
if edit.insert.contains(['\n', '\r']) || base.text[edit.range.clone()].contains(['\n', '\r']) {
return None;
}
let root = base.syntax();
let range = TextRange::new(
TextSize::try_from(edit.range.start).ok()?,
TextSize::try_from(edit.range.end).ok()?,
);
candidates(&root, range)
.into_iter()
.find_map(|leaf| try_leaf(base, edit, new_text, &leaf, range))
}
pub(super) fn candidates(root: &SyntaxNode, range: TextRange) -> Vec<SyntaxToken> {
if range.is_empty() {
root.token_at_offset(range.start()).collect()
} else {
match root.covering_element(range) {
NodeOrToken::Token(t) => vec![t],
NodeOrToken::Node(_) => Vec::new(),
}
}
}
fn try_leaf(
base: &ReparseBase<'_>,
edit: &Edit,
new_text: &str,
leaf: &SyntaxToken,
range: TextRange,
) -> Option<Reparsed> {
if !leaf.text_range().contains_range(range) {
return None;
}
if !matches!(
leaf.kind(),
SyntaxKind::WORD | SyntaxKind::WHITESPACE | SyntaxKind::COMMENT
) {
return None;
}
let ctx = context_admits(leaf, leaf)?;
let leaf_start = usize::from(leaf.text_range().start());
let old = leaf.text();
let cut = edit.range.start.checked_sub(leaf_start)?..edit.range.end.checked_sub(leaf_start)?;
let mut new_leaf = String::with_capacity(old.len() + edit.insert.len());
new_leaf.push_str(old.get(..cut.start)?);
new_leaf.push_str(&edit.insert);
new_leaf.push_str(old.get(cut.end..)?);
if new_leaf.is_empty() {
return None;
}
if leaf.kind() == SyntaxKind::WORD && word_needs_math_proof(leaf, ctx) {
if !text_reads_are_inert(leaf.kind(), old, &new_leaf, Context { in_math: false })
|| !math_word_relexes(base, leaf, &new_leaf)
|| !math_partition_is_stable(leaf, &new_leaf)
{
return None;
}
} else {
if !text_reads_are_inert(leaf.kind(), old, &new_leaf, ctx) {
return None;
}
let relexed = lex_with(&new_leaf, base.ctx, base.config);
if relexed.len() != 1 || relexed[0].kind != leaf.kind() {
return None;
}
if !joins(base, leaf.prev_token().as_ref(), &new_leaf, Side::Before)
|| !joins(base, leaf.next_token().as_ref(), &new_leaf, Side::After)
{
return None;
}
}
let errors = shifted_errors(base.errors, leaf.text_range(), edit)?;
let green = leaf.replace_with(GreenToken::new(leaf.kind().into(), &new_leaf));
finish(green, errors, ReparseTier::Token, base, new_text)
}
fn word_needs_math_proof(leaf: &SyntaxToken, ctx: Context) -> bool {
ctx.in_math
|| leaf.parent().is_some_and(|parent| {
matches!(
parent.kind(),
SyntaxKind::SCRIPTED | SyntaxKind::SUBSCRIPT | SyntaxKind::SUPERSCRIPT
)
})
|| leaf.prev_token().is_some_and(|previous| {
previous.kind() == SyntaxKind::WORD
&& previous.text_range().end() == leaf.text_range().start()
})
|| leaf.next_token().is_some_and(|next| {
next.kind() == SyntaxKind::WORD && leaf.text_range().end() == next.text_range().start()
})
}
fn math_word_relexes(base: &ReparseBase<'_>, leaf: &SyntaxToken, new_leaf: &str) -> bool {
let mut first = leaf.clone();
while let Some(previous) = first.prev_token().filter(|token| {
token.kind() == SyntaxKind::WORD && token.text_range().end() == first.text_range().start()
}) {
first = previous;
}
let mut last = leaf.clone();
while let Some(next) = last.next_token().filter(|token| {
token.kind() == SyntaxKind::WORD && last.text_range().end() == token.text_range().start()
}) {
last = next;
}
let start = usize::from(first.text_range().start());
let end = usize::from(last.text_range().end());
if end
.checked_sub(start)
.is_none_or(|len| len > MAX_PROBE_BYTES)
{
return false;
}
let mut combined = String::with_capacity(end - start + new_leaf.len());
let mut cursor = first.clone();
loop {
if cursor == *leaf {
combined.push_str(new_leaf);
} else {
combined.push_str(cursor.text());
}
if cursor == last {
break;
}
let Some(next) = cursor.next_token() else {
return false;
};
cursor = next;
}
let relexed = lex_with(&combined, base.ctx, base.config);
if relexed.len() != 1 || relexed[0].kind != SyntaxKind::WORD || relexed[0].text != combined {
return false;
}
joins(base, first.prev_token().as_ref(), &combined, Side::Before)
&& joins(base, last.next_token().as_ref(), &combined, Side::After)
}
fn math_partition_is_stable(leaf: &SyntaxToken, new_leaf: &str) -> bool {
let one_atom = leaf.parent().is_some_and(|parent| {
matches!(
parent.kind(),
SyntaxKind::SCRIPTED | SyntaxKind::SUBSCRIPT | SyntaxKind::SUPERSCRIPT
)
});
!one_atom || (leaf.text().chars().count() == 1 && new_leaf.chars().count() == 1)
}
#[derive(Clone, Copy)]
enum Side {
Before,
After,
}
fn joins(
base: &ReparseBase<'_>,
neighbour: Option<&SyntaxToken>,
leaf_text: &str,
side: Side,
) -> bool {
let Some(neighbour) = neighbour else {
return true;
};
let n = neighbour.text();
if n.len() > MAX_PROBE_BYTES {
return false;
}
let (first, second) = match side {
Side::Before => (n, leaf_text),
Side::After => (leaf_text, n),
};
let mut probe = String::with_capacity(first.len() + second.len());
probe.push_str(first);
probe.push_str(second);
let toks = lex_with(&probe, base.ctx, base.config);
if toks.len() != 2 || toks[0].text != first || toks[1].text != second {
return false;
}
match side {
Side::Before => toks[0].kind == neighbour.kind(),
Side::After => toks[1].kind == neighbour.kind(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::declarations::ResolvedDeclarations;
use crate::parser::core::parse_with_declarations_resolved;
use crate::parser::lexer::{LatexFlavor, LexConfig, lex_with};
use crate::parser::reparse::{ReparseBase, reparse};
fn with_base<R>(text: &str, f: impl FnOnce(&ReparseBase<'_>) -> R) -> R {
let declared = ResolvedDeclarations::default();
let (parse, ctx) = parse_with_declarations_resolved(text, LatexFlavor::Document, &declared);
f(&ReparseBase::from_parts(
text,
&parse.green,
&parse.errors,
&ctx,
LatexFlavor::Document.into(),
&declared,
))
}
fn with_dtx_base<R>(text: &str, f: impl FnOnce(&ReparseBase<'_>) -> R) -> R {
let declared = ResolvedDeclarations::default();
let config = LexConfig {
flavor: LatexFlavor::Document,
dtx: true,
};
let (parse, ctx) = parse_with_declarations_resolved(text, config, &declared);
f(&ReparseBase::from_parts(
text,
&parse.green,
&parse.errors,
&ctx,
config,
&declared,
))
}
fn edit(range: std::ops::Range<usize>, insert: &str) -> Edit {
Edit {
range,
insert: insert.to_string(),
}
}
fn edit_at(text: &str, needle: &str, offset: usize, insert: &str) -> Edit {
let start = text.find(needle).expect("fixture") + offset;
edit(start..start, insert)
}
fn replace_needle(text: &str, needle: &str, insert: &str) -> Edit {
let start = text.find(needle).expect("fixture");
edit(start..start + needle.len(), insert)
}
fn as_text_range(range: &std::ops::Range<usize>) -> TextRange {
TextRange::new(
TextSize::try_from(range.start).expect("range start"),
TextSize::try_from(range.end).expect("range end"),
)
}
fn candidate_leaf(base: &ReparseBase<'_>, e: &Edit) -> SyntaxToken {
let range = as_text_range(&e.range);
candidates(&base.syntax(), range)
.into_iter()
.find(|leaf| leaf.text_range().contains_range(range))
.expect("expected a covering token candidate")
}
fn try_leaf_without_dtx_bail(base: &ReparseBase<'_>, e: &Edit, leaf: &SyntaxToken) -> bool {
let next = e.apply(base.text);
let range = as_text_range(&e.range);
try_leaf(base, e, &next, leaf, range).is_some()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DtxLeafRefusal {
LeafKindAllowlist,
RelexNotSingleOrSameKind,
}
fn classify_dtx_leaf_refusal(
base: &ReparseBase<'_>,
e: &Edit,
leaf: &SyntaxToken,
) -> DtxLeafRefusal {
if !matches!(
leaf.kind(),
SyntaxKind::WORD | SyntaxKind::WHITESPACE | SyntaxKind::COMMENT
) {
return DtxLeafRefusal::LeafKindAllowlist;
}
let leaf_start = usize::from(leaf.text_range().start());
let old = leaf.text();
let cut = e.range.start - leaf_start..e.range.end - leaf_start;
let mut new_leaf = String::with_capacity(old.len() + e.insert.len());
new_leaf.push_str(&old[..cut.start]);
new_leaf.push_str(&e.insert);
new_leaf.push_str(&old[cut.end..]);
let relexed = lex_with(&new_leaf, base.ctx, base.config);
if relexed.len() != 1 || relexed[0].kind != leaf.kind() {
return DtxLeafRefusal::RelexNotSingleOrSameKind;
}
panic!("fixture no longer trips the expected guard: {e:?}");
}
#[track_caller]
fn assert_splices(text: &str, e: Edit) {
with_base(text, |base| {
let out = reparse(base, &e, &e.apply(text));
let out = out.unwrap_or_else(|| panic!("expected a token-tier splice for {e:?}"));
assert_eq!(out.tier, ReparseTier::Token);
});
}
#[track_caller]
fn assert_refuses(text: &str, e: Edit) {
with_base(text, |base| {
assert!(
reparse(base, &e, &e.apply(text)).is_none(),
"expected a refusal for {e:?}",
);
});
}
#[test]
fn splices_a_letter_typed_into_a_prose_word() {
assert_splices("Some ordinary prose.\n", edit(5..5, "x"));
assert_splices("Some ordinary prose.\n", edit(5..8, "sensible"));
}
#[test]
fn splices_inside_a_comment_and_inside_whitespace() {
assert_splices("text % a trailing note\nmore\n", edit(10..10, "z"));
assert_splices("a b\n", edit(2..2, " "));
}
#[test]
fn splices_a_hyphenated_word_outside_math() {
assert_splices("a well-known result\n", edit(6..6, "l"));
}
#[test]
fn refuses_an_edit_that_carries_a_newline() {
assert_refuses("Some ordinary prose.\n", edit(5..5, "\n"));
assert_refuses("Some ordinary prose.\n", edit(5..5, "\r\n"));
}
#[test]
fn refuses_an_environment_name() {
assert_refuses(
"\\begin{itemize}\n\\item x\n\\end{itemize}\n",
edit(8..8, "z"),
);
assert_refuses("{\\begin{itemize}\\item x}\n", edit(9..9, "z"));
}
#[test]
fn refuses_a_definition_body_and_a_document_class() {
assert_refuses("\\newcommand{\\bea}{\\begin{align}}\n", edit(26..26, "z"));
assert_refuses("\\documentclass{ltxdoc}\n", edit(16..16, "z"));
}
#[test]
fn splices_partition_preserving_math_words() {
assert_splices("$ab$\n", edit(2..2, "c"));
assert_splices("$a b$\n", edit(3..3, "+"));
assert_splices("\\begin{align}\n a b\n\\end{align}\n", edit(17..17, "+"));
assert_splices("$abc_i$\n", edit(2..2, "z"));
assert_splices("$abc_i$\n", edit(3..4, "α"));
assert_splices("$x^23_i$\n", edit(3..4, "α"));
let text = "\\frac{abc_i}{n}\n";
assert_splices(text, edit_at(text, "abc", 1, "z"));
}
#[test]
fn token_tier_refuses_a_changed_math_partition() {
let text = "$x^23_i$\n";
with_base(text, |base| {
let e = edit(4..4, "z");
assert!(reparse_token(base, &e, &e.apply(text)).is_none());
let out = reparse(base, &e, &e.apply(text)).expect("math tier should splice");
assert_eq!(out.tier, ReparseTier::Math);
});
let text = "\\frac{x^23_i}{n}\n";
assert_refuses(text, edit_at(text, "3", 1, "z"));
let text = "\\frac{abc}{n}\n";
assert_refuses(text, edit_at(text, "b", 1, "_"));
}
#[test]
fn refuses_a_word_that_gains_a_statement_terminator() {
let text = "\\begin{tikzpicture}\n \\draw (0,0) -- (1,1);\n\\end{tikzpicture}\n";
let at = text.find("(0,0)").expect("fixture") + 5;
assert_refuses(text, edit(at..at, ";"));
}
#[test]
fn refuses_an_edit_that_would_merge_with_the_previous_token() {
assert_refuses("\\foo1ab\n", edit(4..5, "a"));
}
#[test]
fn dtx_state_bit_survey_is_complete_for_the_token_tier() {
use DtxLeafRefusal::{LeafKindAllowlist, RelexNotSingleOrSameKind};
struct Case {
state_bit: &'static str,
text: &'static str,
edit: Edit,
expected: DtxLeafRefusal,
}
let cases = [
Case {
state_bit: "at_line_start",
text: "% alpha\n",
edit: edit_at("% alpha\n", "alpha", 0, "%"),
expected: RelexNotSingleOrSameKind,
},
Case {
state_bit: "in_doc_line",
text: "% alpha\n",
edit: edit_at("% alpha\n", "alpha", 0, "^^A"),
expected: RelexNotSingleOrSameKind,
},
Case {
state_bit: "at_letter",
text: "% \\begin{macrocode}\n\\foo@bar\n% \\end{macrocode}\n",
edit: edit_at(
"% \\begin{macrocode}\n\\foo@bar\n% \\end{macrocode}\n",
"foo@bar",
4,
"z",
),
expected: LeafKindAllowlist,
},
Case {
state_bit: "expl_syntax",
text: "% \\begin{macrocode}\n\\ExplSyntaxOn\n\\foo_bar:n\n% \\end{macrocode}\n",
edit: edit_at(
"% \\begin{macrocode}\n\\ExplSyntaxOn\n\\foo_bar:n\n% \\end{macrocode}\n",
"foo_bar:n",
3,
"z",
),
expected: LeafKindAllowlist,
},
Case {
state_bit: "macrocode",
text: "% \\begin{macrocode}\n% comment\n% \\end{macrocode}\n",
edit: edit_at(
"% \\begin{macrocode}\n% comment\n% \\end{macrocode}\n",
"comment",
3,
"x",
),
expected: RelexNotSingleOrSameKind,
},
Case {
state_bit: "implicit_expl",
text: "%<@@=demo>\n% \\begin{macrocode}\n\\foo_bar:n\n% \\end{macrocode}\n",
edit: edit_at(
"%<@@=demo>\n% \\begin{macrocode}\n\\foo_bar:n\n% \\end{macrocode}\n",
"foo_bar:n",
3,
"z",
),
expected: LeafKindAllowlist,
},
Case {
state_bit: "short_verbs",
text: "% alpha\n",
edit: replace_needle("% alpha\n", "alpha", "|a|"),
expected: RelexNotSingleOrSameKind,
},
];
assert_eq!(cases.len(), 7, "enumerate every dtx state bit exactly once");
for case in cases {
with_dtx_base(case.text, |base| {
let leaf = candidate_leaf(base, &case.edit);
assert!(
!try_leaf_without_dtx_bail(base, &case.edit, &leaf),
"fixture for `{}` unexpectedly spliced",
case.state_bit
);
let got = classify_dtx_leaf_refusal(base, &case.edit, &leaf);
assert_eq!(got, case.expected, "state bit `{}`", case.state_bit);
});
}
}
#[test]
fn splices_a_doc_line_word_in_a_dtx_parse() {
let text = "% alpha beta\n";
with_dtx_base(text, |base| {
let e = edit_at(text, "alpha", 3, "z");
let out = reparse(base, &e, &e.apply(text)).expect("expected dtx splice");
assert_eq!(out.tier, ReparseTier::Token);
});
}
#[test]
fn refuses_an_edit_that_empties_the_leaf() {
assert_refuses("a bb c\n", edit(2..4, ""));
}
#[test]
fn shifts_errors_after_the_leaf_and_refuses_ones_that_touch_it() {
let text = "word\n\n\\begin{itemize}\n";
with_base(text, |base| {
assert!(
!base.errors.is_empty(),
"this fixture exists to carry an error"
);
let e = edit(2..2, "z");
let out = reparse(base, &e, &e.apply(text)).expect("a splice before the error");
assert_eq!(out.errors.len(), base.errors.len());
assert_eq!(out.errors[0].start, base.errors[0].start + 1);
});
}
#[test]
fn refuses_a_leaf_beside_an_oversized_neighbour() {
let long = "a".repeat(MAX_PROBE_BYTES + 10);
assert_refuses(&format!("{long} b\n"), edit(long.len()..long.len(), " "));
let short = "a".repeat(8);
assert_splices(&format!("{short} b\n"), edit(short.len()..short.len(), " "));
}
}