use badness_parser::parser::conditional::{FlowWord, OpenerScan, Word};
use rowan::TextSize;
use crate::ast::command_name;
use crate::semantic::define::is_definition_command;
use crate::syntax::{SyntaxKind, SyntaxNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Frame {
id: u32,
branch: u32,
}
pub(crate) struct ConditionalIndex {
snapshots: Vec<(TextSize, Vec<Frame>)>,
}
pub(crate) fn mutually_exclusive(a: &[Frame], b: &[Frame]) -> bool {
a.iter()
.zip(b)
.any(|(x, y)| x.id == y.id && x.branch != y.branch)
}
impl ConditionalIndex {
pub(crate) fn compute(root: &SyntaxNode) -> Self {
let mut stack: Vec<Frame> = Vec::new();
let mut next_id = 0u32;
let mut snapshots: Vec<(TextSize, Vec<Frame>)> = Vec::new();
let mut scan = OpenerScan::new();
let mut suppress_until = TextSize::from(0);
for node in root.descendants() {
if node.kind() != SyntaxKind::COMMAND {
continue;
}
let start = node.text_range().start();
if start < suppress_until {
continue;
}
let Some(name) = command_name(&node) else {
continue;
};
if is_definition_command(&name) {
suppress_until = suppress_until.max(definition_span_end(&node));
continue;
}
match scan.visit(&name) {
Word::Flow(FlowWord::Else | FlowWord::Or) => {
if let Some(top) = stack.last_mut() {
top.branch += 1;
snapshots.push((start, stack.clone()));
}
}
Word::Flow(FlowWord::Fi) => {
if stack.pop().is_some() {
snapshots.push((start, stack.clone()));
}
}
Word::Opens => {
stack.push(Frame {
id: next_id,
branch: 0,
});
next_id += 1;
snapshots.push((start, stack.clone()));
}
Word::Inert => {}
}
}
Self { snapshots }
}
pub(crate) fn path_at(&self, offset: usize) -> &[Frame] {
let offset = TextSize::from(offset as u32);
let i = self.snapshots.partition_point(|(s, _)| *s <= offset);
if i == 0 {
&[]
} else {
&self.snapshots[i - 1].1
}
}
}
fn definition_span_end(command: &SyntaxNode) -> TextSize {
let own = command.text_range().end();
if command.children().any(|c| c.kind() == SyntaxKind::GROUP) {
return own;
}
match adjacent_sibling_command(command) {
Some(sibling) => own.max(sibling.text_range().end()),
None => own,
}
}
fn adjacent_sibling_command(command: &SyntaxNode) -> Option<SyntaxNode> {
let mut next = command.next_sibling_or_token();
while let Some(element) = next {
match element {
rowan::NodeOrToken::Token(token) if is_trivia(token.kind()) => {
next = token.next_sibling_or_token();
}
rowan::NodeOrToken::Node(node) if node.kind() == SyntaxKind::COMMAND => {
return Some(node);
}
_ => return None,
}
}
None
}
fn is_trivia(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse;
fn index(src: &str) -> ConditionalIndex {
let root = SyntaxNode::new_root(parse(src).green);
ConditionalIndex::compute(&root)
}
fn offset(src: &str, needle: &str, n: usize) -> usize {
src.match_indices(needle)
.nth(n)
.map(|(i, _)| i)
.unwrap_or_else(|| panic!("occurrence {n} of {needle:?} in {src:?}"))
}
fn paths_at_loads<'a>(idx: &'a ConditionalIndex, src: &str) -> (&'a [Frame], &'a [Frame]) {
(
idx.path_at(offset(src, "\\usepackage", 0)),
idx.path_at(offset(src, "\\usepackage", 1)),
)
}
#[test]
fn if_else_branches_are_mutually_exclusive() {
let src = "\\iftrue\\usepackage{a}\\else\\usepackage{a}\\fi\n";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(mutually_exclusive(a, b));
}
#[test]
fn same_branch_is_not_exclusive() {
let src = "\\iftrue\\usepackage{a}\\usepackage{a}\\else x\\fi\n";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(!mutually_exclusive(a, b));
}
#[test]
fn ifcase_or_branches_are_pairwise_exclusive() {
let src = "\\ifcase 0 \\usepackage{a}\\or\\usepackage{a}\\or\\usepackage{a}\\fi\n";
let idx = index(src);
let a = idx.path_at(offset(src, "\\usepackage", 0));
let b = idx.path_at(offset(src, "\\usepackage", 1));
let c = idx.path_at(offset(src, "\\usepackage", 2));
assert!(mutually_exclusive(a, b));
assert!(mutually_exclusive(b, c));
assert!(mutually_exclusive(a, c));
}
#[test]
fn unconditional_site_is_never_exclusive() {
let src = "\\iftrue\\usepackage{a}\\fi\n\\usepackage{a}\n";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(b.is_empty());
assert!(!mutually_exclusive(a, b));
assert!(!mutually_exclusive(b, b));
}
#[test]
fn nested_conditionals_compare_by_shared_frame() {
let src = "\\iftrue\\usepackage{a}\\else\\ifodd 1 \\usepackage{a}\\fi\\fi\n";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(mutually_exclusive(a, b));
}
#[test]
fn unknown_conditional_is_paired_and_trusted() {
let src = "\\ifmyflag\\usepackage{a}\\else\\usepackage{a}\\fi\n";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(mutually_exclusive(a, b));
}
#[test]
fn unknown_conditional_nested_in_known_resyncs() {
let src = "\\iftrue\\ifmyflag x\\fi\\usepackage{a}\\else\\usepackage{a}\\fi\n";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(mutually_exclusive(a, b));
}
#[test]
fn unknown_conditionals_else_bumps_its_own_frame() {
let src = "\\iftrue\\usepackage{a}\\ifmyflag\\else\\usepackage{a}\\fi\\fi\n";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(!mutually_exclusive(a, b));
}
#[test]
fn ifx_operands_open_no_frames() {
let src = "\\ifx\\ifabc\\ifxyz x\\fi\n done";
let idx = index(src);
assert_eq!(idx.path_at(offset(src, "x\\fi", 0)).len(), 1);
assert!(idx.path_at(offset(src, "done", 0)).is_empty());
}
#[test]
fn ifdefined_operand_opens_no_frame() {
let src = "\\ifdefined\\iffalse x\\fi\n done";
let idx = index(src);
assert!(idx.path_at(offset(src, "done", 0)).is_empty());
}
#[test]
fn textual_operands_do_not_eat_the_else() {
let src = "\\if ab\\usepackage{a}\\else\\usepackage{a}\\fi\n";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(mutually_exclusive(a, b));
}
#[test]
fn newif_declaration_opens_no_frame() {
let src = "\\newif\\ifmyflag\n done";
let idx = index(src);
assert!(idx.path_at(offset(src, "done", 0)).is_empty());
}
#[test]
fn let_aliasing_opens_no_frame() {
let src = "\\let\\ifabc\\iftrue\n done";
let idx = index(src);
assert!(idx.path_at(offset(src, "done", 0)).is_empty());
}
#[test]
fn ifcsname_material_is_skipped_and_pairs() {
let src = "\\ifcsname iftex\\endcsname\\usepackage{a}\\else\\usepackage{a}\\fi\n done";
let idx = index(src);
let (a, b) = paths_at_loads(&idx, src);
assert!(mutually_exclusive(a, b));
assert!(idx.path_at(offset(src, "done", 0)).is_empty());
}
#[test]
fn definition_bodies_change_no_state() {
let src = "\\iftrue x\\newcommand{\\x}{\\else}\\def\\stopit{\\fi} y\\fi\n done";
let idx = index(src);
assert_eq!(idx.path_at(offset(src, " y", 0)).len(), 1);
assert!(idx.path_at(offset(src, "done", 0)).is_empty());
}
#[test]
fn denylisted_macros_open_no_frames() {
let src = "\\ifthenelse{\\boolean{x}}{a}{b} $a \\iff b$\n done";
let idx = index(src);
assert!(idx.path_at(offset(src, "done", 0)).is_empty());
assert!(idx.snapshots.is_empty());
}
#[test]
fn stray_flow_words_are_no_ops() {
let src = "\\else\\or\\fi\n done";
let idx = index(src);
assert!(idx.snapshots.is_empty());
assert!(idx.path_at(offset(src, "done", 0)).is_empty());
}
}