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>)>,
}
const NOT_FI_TERMINATED: &[&str] = &[
"iff", "ifthenelse", "iflanguage", "iftoggle", "ifdef",
"ifcsdef",
"ifundef",
"ifcsundef",
"ifdefmacro",
"ifcsmacro",
"ifdefempty",
"ifcsempty",
"ifdefvoid",
"ifcsvoid",
"ifdefstring",
"ifcsstring",
"ifdefequal",
"ifcsequal",
"ifbool",
"ifboolexpr",
"ifboolexpe",
"ifstrequal",
"ifstrempty",
"ifblank",
"ifnumcomp",
"ifnumequal",
"ifnumgreater",
"ifnumless",
"ifnumodd",
"ifdimcomp",
"ifdimequal",
"ifdimgreater",
"ifdimless",
];
const OPERAND_SKIPS: &[(&str, u8)] = &[
("if", 2),
("ifx", 2),
("ifcat", 2),
("ifdefined", 1),
("newif", 1),
("let", 2),
];
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 pending_skips = 0u8;
let mut in_csname = false;
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;
}
let is_flow = matches!(name.as_str(), "else" | "or" | "fi");
if in_csname {
if name == "endcsname" {
in_csname = false;
continue;
}
if !is_flow {
continue;
}
in_csname = false;
}
if pending_skips > 0 {
if is_flow {
pending_skips = 0;
} else {
pending_skips -= 1;
continue;
}
}
match name.as_str() {
"else" | "or" => {
if let Some(top) = stack.last_mut() {
top.branch += 1;
snapshots.push((start, stack.clone()));
}
}
"fi" => {
if stack.pop().is_some() {
snapshots.push((start, stack.clone()));
}
}
_ if is_conditional_opener(&name) => {
stack.push(Frame {
id: next_id,
branch: 0,
});
next_id += 1;
if name == "ifcsname" {
in_csname = true;
} else if let Some(&(_, n)) = OPERAND_SKIPS.iter().find(|(op, _)| *op == name) {
pending_skips = n;
}
snapshots.push((start, stack.clone()));
}
_ => {
if let Some(&(_, n)) = OPERAND_SKIPS.iter().find(|(op, _)| *op == name) {
pending_skips = n;
}
}
}
}
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 is_conditional_opener(name: &str) -> bool {
name.starts_with("if") && !NOT_FI_TERMINATED.contains(&name)
}
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());
}
}