Skip to main content

badness_parser/semantic/
define.rs

1//! Scan a document for **user definitions** — `\newcommand`/`\newenvironment` and
2//! the xparse `\NewDocument…` family — and extract their argument *signatures* into
3//! a per-document [`SignatureDb`]. Signatures only: we read the declared argument
4//! shape, never the replacement text, and never execute anything (AGENTS.md
5//! non-goals and decision #1).
6//!
7//! A single whole-tree walk (mirror of [`super::builder::build`]) collects every
8//! definition; the result overlays the built-in DB via [`Signatures`] (scanned
9//! first). The greedy parser attaches definitions like any other command, so they
10//! surface as plain `COMMAND` descendants — those inside a comment or a verbatim
11//! body never parse to a `COMMAND`, so they are skipped for free.
12//!
13//! [`Signatures`]: super::signature::Signatures
14//!
15//! ## Both name forms
16//!
17//! For command definitions we extract **both** name forms: the braced
18//! `\newcommand{\foo}…` and the unbraced `\newcommand\foo…`. The unbraced form
19//! parses awkwardly under greedy attachment — `\foo` becomes a *sibling* `COMMAND`
20//! and the `[n]`/replacement group attaches to it, not to `\newcommand` — so
21//! `\newcommand` itself has no name group. We recover it with a scanner-side sibling
22//! heuristic ([`resolve_command_def`]): when a definition command has no attached
23//! group, the name and argument shape are read off the immediately-following sibling
24//! `COMMAND`. This stays in the scanner — no parser change — so the parser remains
25//! meaning-free (decision #2). Environment names are brace-delimited *text*, never a
26//! bare control word, so they have no unbraced form to recover.
27
28use std::collections::{HashMap, HashSet};
29
30use crate::ast::{
31    AstNode, Command, Optional, child, children, command_name, control_word_range,
32    group_command_name, group_inner_source, nth_group, nth_group_inner, nth_group_text,
33};
34use crate::semantic::signature::{
35    ArgKind, ArgSpec, CommandSig, ContentKind, EnvironmentSig, SignatureDb, builtin,
36};
37use crate::semantic::xparse;
38use crate::syntax::{SyntaxKind, SyntaxNode, is_collapsible_trivia};
39use rowan::{NodeOrToken, TextRange, TextSize};
40use smol_str::SmolStr;
41
42/// Scan `root` for user command/environment definitions and return their extracted
43/// signatures. Names already defined earlier in the document are overwritten, so a
44/// later `\renewcommand` wins — TeX's last-definition-wins, modulo execution order
45/// we do not track.
46pub fn scan_definitions(root: &SyntaxNode) -> SignatureDb {
47    let mut db = SignatureDb::default();
48    // Replacement-body facts collected alongside each command signature, keyed by
49    // name (last definition wins, mirroring `db`). Consumed after the walk to flag
50    // catcode-othering verbatim-argument commands (`apply_verbatim_flags`).
51    let mut bodies: HashMap<SmolStr, DefBody> = HashMap::new();
52    // The same for environment *begin-code*, kept in a separate map because
53    // environment names live in a different namespace from commands (and so a name
54    // collision must not let one shadow the other during chain resolution). The
55    // begin-code's *called* helpers are resolved against the command `bodies` map.
56    let mut env_bodies: HashMap<SmolStr, DefBody> = HashMap::new();
57    // Environment-alias candidates: a zero-arity definition whose body is exactly
58    // `\begin{X}` or `\end{X}`. Collected raw during the walk (last definition
59    // wins, like `db`) and filtered by [`apply_env_aliases`] afterwards, since
60    // admitting one requires seeing the *other* half of the pair.
61    let mut alias_candidates: HashMap<SmolStr, EnvAliasCandidate> = HashMap::new();
62
63    for command in root
64        .descendants()
65        .filter(|node| node.kind() == SyntaxKind::COMMAND)
66    {
67        let Some(name) = command_name(&command) else {
68            continue;
69        };
70        match DefKind::of(&name) {
71            Some(DefKind::Command) => {
72                scan_newcommand(&command, &mut db, &mut bodies, &mut alias_candidates)
73            }
74            Some(DefKind::Def) => scan_def(&command, &mut db, &mut bodies, &mut alias_candidates),
75            Some(DefKind::Environment) => scan_newenvironment(&command, &mut db, &mut env_bodies),
76            Some(DefKind::XparseCommand) => {
77                scan_xparse_command(&command, &mut db, &mut bodies, &mut alias_candidates)
78            }
79            Some(DefKind::XparseEnvironment) => {
80                scan_xparse_environment(&command, &mut db, &mut env_bodies)
81            }
82            Some(DefKind::VerbatimEnvironment) => {
83                scan_verbatim_environment(&name, &command, &mut db)
84            }
85            None => {}
86        }
87    }
88
89    apply_verbatim_flags(&mut db, &bodies);
90    apply_verbatim_env_flags(&mut db, &env_bodies, &bodies);
91    apply_env_aliases(&mut db, &alias_candidates);
92    db
93}
94
95/// Which delimiter of an environment a definition body stands in for.
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
97enum AliasSide {
98    Begin,
99    End,
100}
101
102/// One unfiltered environment-alias candidate: the side its body spells and the
103/// environment it names. Admission is decided later by [`apply_env_aliases`],
104/// which needs the whole file's candidates to check that both halves exist.
105#[derive(Debug, Clone)]
106struct EnvAliasCandidate {
107    side: AliasSide,
108    target: SmolStr,
109}
110
111/// Read a definition's replacement `body` group as an environment delimiter: the
112/// body must be *exactly* `\begin{X}` or `\end{X}` and nothing else.
113///
114/// Reads the **CST**, not `group_inner_source`: the formatter may re-space inside
115/// the body, and `{\begin{eqnarray}}` and `{ \begin{eqnarray} }` must detect
116/// identically or the alias table would not survive a reformat (the pass-1/pass-2
117/// fixed point). Whitespace, newlines, and comments are skipped for the same
118/// reason; anything else present makes this not a delimiter definition.
119///
120/// Inside a definition body `\begin` is a plain `COMMAND` (the grammar sets
121/// `in_def_body`), so this never has to look through an `ENVIRONMENT` node.
122fn env_alias_body(body: &SyntaxNode) -> Option<EnvAliasCandidate> {
123    let mut sole: Option<SyntaxNode> = None;
124    for child in body.children_with_tokens() {
125        match child {
126            NodeOrToken::Token(t) => match t.kind() {
127                // The group's own delimiters, plus the trivia a reformat may move.
128                SyntaxKind::L_BRACE | SyntaxKind::R_BRACE | SyntaxKind::COMMENT => {}
129                k if is_collapsible_trivia(k) => {}
130                _ => return None,
131            },
132            // A second node means the body does more than open the environment.
133            NodeOrToken::Node(n) => {
134                if sole.replace(n).is_some() {
135                    return None;
136                }
137            }
138        }
139    }
140    let cmd = sole?;
141    if cmd.kind() != SyntaxKind::COMMAND {
142        return None;
143    }
144    // Exactly one attached child, the name group — so `\begin{tabular}{cc}` (two
145    // groups) is not a bare delimiter and does not read as one.
146    if cmd.children().count() != 1 {
147        return None;
148    }
149    let side = match command_name(&cmd)?.as_str() {
150        "begin" => AliasSide::Begin,
151        "end" => AliasSide::End,
152        _ => return None,
153    };
154    let target = nth_group_text(&cmd, 0)?;
155    let target = target.trim();
156    if target.is_empty() {
157        return None;
158    }
159    Some(EnvAliasCandidate {
160        side,
161        target: SmolStr::new(target),
162    })
163}
164
165/// Record an alias candidate for a zero-arity definition. Non-zero arity is
166/// rejected here: an alias head consumes no arguments (the grammar never calls
167/// `attach_arguments` on one), so a parameterized delimiter would silently drop
168/// its arguments into the body.
169fn record_env_alias(
170    candidates: &mut HashMap<SmolStr, EnvAliasCandidate>,
171    name: &str,
172    arity: usize,
173    body: Option<&SyntaxNode>,
174) {
175    // A later definition of the same name wins, mirroring `db`; but it must also
176    // be able to *retract* an earlier alias, or `\renewcommand{\bea}{\textbf}`
177    // would leave the stale entry standing.
178    candidates.remove(name);
179    if arity != 0 {
180        return;
181    }
182    if let Some(candidate) = body.and_then(env_alias_body) {
183        candidates.insert(SmolStr::new(name), candidate);
184    }
185}
186
187/// Promote the alias candidates that survive every admission rule into `db`.
188///
189/// The rules are narrow on purpose — an alias makes the *parser* pair two bare
190/// control words into an `ENVIRONMENT`, and a wrong pairing rewrites layout:
191///
192/// - **The target must be a curated built-in environment.** An alias declares a
193///   *spelling*, never a *semantic*; every behavior flag still comes from curated
194///   data, exactly as `is_math_environment` requires (AGENTS.md decision #1).
195/// - **The target must not be verbatim.** `\newcommand{\bv}{\begin{verbatim}}`
196///   genuinely does not work in TeX — `verbatim` others catcodes, and the body is
197///   already tokenized by the time the macro expands — so pairing it would model a
198///   construct that does not exist.
199/// - **The target must take no arguments.** The alias head consumes none, so an
200///   `array` alias would drop its column spec into the body and the formatter
201///   would grid it with no alignments.
202/// - **Both halves must be defined in the file.** A lone opener can never pair
203///   anyway (no closer to locate), so recording it is pure risk and pure cost —
204///   it would make every such file pay a second parse for nothing.
205fn apply_env_aliases(db: &mut SignatureDb, candidates: &HashMap<SmolStr, EnvAliasCandidate>) {
206    let admissible = |target: &str| {
207        builtin()
208            .environment(target)
209            .is_some_and(|sig| !sig.verbatim_body && sig.args.is_empty())
210    };
211    let has_side = |side: AliasSide, target: &str| {
212        candidates
213            .values()
214            .any(|c| c.side == side && c.target == target)
215    };
216    for (name, candidate) in candidates {
217        let target = candidate.target.as_str();
218        if !admissible(target) {
219            continue;
220        }
221        match candidate.side {
222            AliasSide::Begin if has_side(AliasSide::End, target) => {
223                db.insert_env_begin_alias(name.clone(), candidate.target.clone());
224            }
225            AliasSide::End if has_side(AliasSide::Begin, target) => {
226                db.insert_env_end_alias(name.clone(), candidate.target.clone());
227            }
228            _ => {}
229        }
230    }
231}
232
233/// Which namespace a scanned definition site names. Commands and environments live
234/// in disjoint TeX namespaces, so a name match is only meaningful within a kind.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum DefSiteKind {
237    Command,
238    Environment,
239}
240
241/// One user definition's *location* — the range-bearing sibling of the signature
242/// facts [`scan_definitions`] extracts. Signatures stay range-free so the
243/// `document_signatures` salsa query backdates on pure-offset edits; definition
244/// sites feed LSP navigation (goto-definition, references, rename), which needs
245/// byte ranges and recomputes them per request off the memoized tree.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct DefSite {
248    /// The defined name (no leading `\` for commands).
249    pub name: SmolStr,
250    pub kind: DefSiteKind,
251    /// The defined name's own span. For a command this is the `\name` control-word
252    /// token, backslash included, so it compares equal to the same token found by an
253    /// occurrence walk; for an environment it is the name text between the braces of
254    /// `\newenvironment{name}` (the [`environment_name_range`] convention).
255    ///
256    /// [`environment_name_range`]: crate::ast::environment_name_range
257    pub name_range: TextRange,
258    /// The whole definition's span, through the sibling name `COMMAND` in the
259    /// unbraced `\newcommand\foo…`/`\def\foo…` forms.
260    pub range: TextRange,
261}
262
263/// Scan `root` for user command/environment definitions and return their *sites*, in
264/// document order. Same recognizer set and name resolution as [`scan_definitions`]
265/// (the [`DefKind`] dispatch and [`resolve_command_def`] sibling heuristic), but
266/// keeping every definition — no last-wins collapsing, since a `\renewcommand` of an
267/// earlier definition is still a definition site the user may navigate to or rename.
268pub fn scan_definition_sites(root: &SyntaxNode) -> Vec<DefSite> {
269    let mut sites = Vec::new();
270    for command in root
271        .descendants()
272        .filter(|node| node.kind() == SyntaxKind::COMMAND)
273    {
274        let Some(name) = command_name(&command) else {
275            continue;
276        };
277        let site = match DefKind::of(&name) {
278            Some(DefKind::Command | DefKind::XparseCommand) => command_def_site(&command),
279            Some(DefKind::Def) => def_def_site(&command),
280            Some(
281                DefKind::Environment | DefKind::XparseEnvironment | DefKind::VerbatimEnvironment,
282            ) => environment_def_site(&command),
283            None => None,
284        };
285        sites.extend(site);
286    }
287    sites
288}
289
290/// The [`DefSite`] of a `\newcommand`/xparse command definition, resolving the same
291/// two name forms as [`resolve_command_def`]: braced `{\name}` (the control word
292/// inside the name group) and unbraced `\newcommand\name` (the sibling `COMMAND`
293/// hosting the signature groups).
294fn command_def_site(command: &SyntaxNode) -> Option<DefSite> {
295    let def = resolve_command_def(command)?;
296    let name_range = if def.first_arg_group == 1 {
297        let group = nth_group(command, 0)?;
298        child::<Command>(&group)?.control_word_range()?
299    } else {
300        control_word_range(&def.host)?
301    };
302    Some(DefSite {
303        name: SmolStr::new(&def.name),
304        kind: DefSiteKind::Command,
305        name_range,
306        range: TextRange::new(
307            command.text_range().start(),
308            command.text_range().end().max(def.host.text_range().end()),
309        ),
310    })
311}
312
313/// The [`DefSite`] of a `\def`-family definition — the name is always the
314/// immediately-following sibling `COMMAND` (TeX has no braced `\def{\name}` form).
315fn def_def_site(command: &SyntaxNode) -> Option<DefSite> {
316    let name_node = adjacent_sibling_command(command)?;
317    let name = command_name(&name_node)?;
318    let name_range = control_word_range(&name_node)?;
319    Some(DefSite {
320        name: SmolStr::new(&name),
321        kind: DefSiteKind::Command,
322        name_range,
323        range: TextRange::new(command.text_range().start(), name_node.text_range().end()),
324    })
325}
326
327/// The [`DefSite`] of a `\newenvironment`/xparse environment definition. The name is
328/// brace-delimited *text* in group 0; the recorded span is the trimmed name within
329/// the group's inner range, mirroring the `.trim()` in [`scan_newenvironment`].
330fn environment_def_site(command: &SyntaxNode) -> Option<DefSite> {
331    let (inner_range, text) = nth_group_inner(command, 0)?;
332    let trimmed = text.trim();
333    if trimmed.is_empty() {
334        return None;
335    }
336    let leading = text.len() - text.trim_start().len();
337    let name_range = TextRange::at(
338        inner_range.start() + TextSize::new(leading as u32),
339        TextSize::new(trimmed.len() as u32),
340    );
341    Some(DefSite {
342        name: SmolStr::new(trimmed),
343        kind: DefSiteKind::Environment,
344        name_range,
345        range: command.text_range(),
346    })
347}
348
349/// Replacement-body facts for one scanned command definition, used to detect
350/// verbatim-argument commands without executing anything. We read only *static*
351/// surface text of the body — no macro expansion (AGENTS.md decision #1).
352struct DefBody {
353    /// A catcode-othering signal appears directly in this command's own body
354    /// (`\@makeother`, `\catcode…12`, `\dospecials`, …) — see [`catcode_signal`].
355    signal: bool,
356    /// Control words the body invokes, so chained helpers can be followed to find a
357    /// catcode signal one or more hops away (jss's `\code`→helper idiom).
358    called: Vec<SmolStr>,
359}
360
361/// Flag user commands whose argument is verbatim. A command is verbatim when it
362/// **takes at least one argument** (so it grabs the user's `{…}` itself) **and** a
363/// catcode-othering signal is reachable from its body — present directly, or in the
364/// body of a scanned macro it transitively calls. Conservative by construction
365/// (AGENTS.md): a wrong flag *suppresses* real diagnostics inside the body, so we
366/// flag only on a clear catcode signal and otherwise leave the body ordinary.
367///
368/// On a match we adopt the built-in convention: only the *leading* (non-verbatim)
369/// arguments stay in `args`; the final argument becomes the implicit verbatim one, so
370/// we drop the last `ArgSpec` and set `verbatim = true`. This keeps the lexer's
371/// `lex_verbatim_command` path uniform between built-in and user commands.
372fn apply_verbatim_flags(db: &mut SignatureDb, bodies: &HashMap<SmolStr, DefBody>) {
373    let verbatim: Vec<SmolStr> = bodies
374        .keys()
375        .filter(|name| {
376            // Needs an argument of its own to capture, and a reachable signal.
377            db.command(name).is_some_and(|sig| !sig.args.is_empty())
378                && reaches_signal(name, bodies, &mut HashSet::new())
379        })
380        .cloned()
381        .collect();
382
383    for name in verbatim {
384        if let Some(mut sig) = db.command(&name).cloned() {
385            sig.args.to_mut().pop(); // the final argument is the implicit verbatim one
386            sig.verbatim = true;
387            db.insert_command(name, sig);
388        }
389    }
390}
391
392/// Flag user environments whose body is verbatim — the environment analog of
393/// [`apply_verbatim_flags`]. An environment is verbatim when a catcode-othering signal
394/// is reachable from its **begin-code** (the first definition body), directly or via a
395/// chained helper command. Unlike commands, no argument is dropped: an environment's
396/// declared args are all leading and its body follows the `\begin{…}…` arguments, so
397/// we only flip `verbatim_body` (and the derived `reflow`). The begin-code's called
398/// helpers are resolved against the *command* `bodies` map (`\newcommand`/`\def`
399/// helpers live there). Conservative by construction, like the command case.
400fn apply_verbatim_env_flags(
401    db: &mut SignatureDb,
402    env_bodies: &HashMap<SmolStr, DefBody>,
403    bodies: &HashMap<SmolStr, DefBody>,
404) {
405    let verbatim: Vec<SmolStr> = env_bodies
406        .iter()
407        .filter(|(name, body)| {
408            db.environment(name).is_some() && reaches_signal_body(body, bodies, &mut HashSet::new())
409        })
410        .map(|(name, _)| name.clone())
411        .collect();
412
413    for name in verbatim {
414        if let Some(mut sig) = db.environment(&name).cloned() {
415            sig.verbatim_body = true;
416            sig.reflow = false; // a verbatim body is never reflowed
417            db.insert_environment(name, sig);
418        }
419    }
420}
421
422/// Whether a catcode-othering signal is reachable from `name`'s body, following
423/// chained helper macros within the scanned definition set. A `visited` set breaks
424/// definition cycles (mutually recursive helpers terminate). A helper defined via
425/// `\def` (not scanned) is absent from `bodies`, so the chain breaks there and we do
426/// not flag — the conservative false-negative.
427fn reaches_signal(
428    name: &str,
429    bodies: &HashMap<SmolStr, DefBody>,
430    visited: &mut HashSet<SmolStr>,
431) -> bool {
432    if !visited.insert(SmolStr::new(name)) {
433        return false;
434    }
435    let Some(body) = bodies.get(name) else {
436        return false;
437    };
438    reaches_signal_body(body, bodies, visited)
439}
440
441/// Whether a catcode-othering signal is reachable from a definition `body` —
442/// present directly, or in the body of a scanned command it transitively calls. The
443/// body-level entry point used both by [`reaches_signal`] (after a name lookup) and by
444/// [`apply_verbatim_env_flags`] (for an environment's begin-code, which has no command
445/// name to look up).
446fn reaches_signal_body(
447    body: &DefBody,
448    bodies: &HashMap<SmolStr, DefBody>,
449    visited: &mut HashSet<SmolStr>,
450) -> bool {
451    body.signal
452        || body
453            .called
454            .iter()
455            .any(|callee| reaches_signal(callee, bodies, visited))
456}
457
458/// Whether `body` text reassigns a special char's catcode to "other" — the static
459/// fingerprint of a verbatim-argument command's setup. Strict, to avoid false
460/// positives (which would silence real diagnostics): each pattern is verbatim-setup
461/// specific. We match surface text only; no catcode arithmetic is evaluated.
462fn catcode_signal(body: &str) -> bool {
463    body.contains("\\@makeother")
464        || body.contains("\\@sanitize")
465        || body.contains("\\dospecials")
466        // `\catcode`<char>`=12` others a char; the literal `12` is the "other"
467        // category. Require both tokens so an unrelated `\catcode…=11` does not match.
468        || (body.contains("\\catcode") && body.contains("12"))
469}
470
471/// The control-word names (leading `\` stripped) the body invokes, for chained-helper
472/// resolution. `@` is treated as a name char so `\@makeother`/`\@codex`-style helpers
473/// are captured; control symbols (`\$`, `\\`) yield no name and are skipped. Reads
474/// surface text only.
475fn called_macros(body: &str) -> Vec<SmolStr> {
476    body.match_indices('\\')
477        .filter_map(|(pos, _)| {
478            let after = &body[pos + 1..];
479            let len: usize = after
480                .chars()
481                .take_while(|c| c.is_ascii_alphabetic() || *c == '@')
482                .map(char::len_utf8)
483                .sum();
484            (len > 0).then(|| SmolStr::new(&after[..len]))
485        })
486        .collect()
487}
488
489/// Kernel sectioning primitives that themselves scan a `(*/[toc]/{title})` argument
490/// the static scanner cannot see from a redefinition body. A `\renewcommand{\cs}{…}`
491/// whose body is `\secdef …`/`\@startsection …` carries no `#` parameter and no `[n]`,
492/// so [`newcommand_arity`] reads it as arity 0 — but `\cs` really does consume a prose
493/// title at expansion time (jss's `\renewcommand{\section}{\secdef …}` is the canonical
494/// case). Curated and deliberately narrow: a *missed* name falls back to the safe status
495/// quo (the redefinition wins and the argument is left un-reflowed), while a *false*
496/// match is the only way to over-trust a built-in, so we keep the set tight and match
497/// only these kernel primitives.
498const DELEGATING_PRIMITIVES: &[&str] = &["secdef", "@startsection", "@dblarg", "@sect", "@ssect"];
499
500/// The **trust gate** for a `\newcommand`/`\def` the static scanner reads as taking no
501/// arguments. When the body *delegates* to a token-consuming kernel primitive
502/// ([`DELEGATING_PRIMITIVES`]), the arity-0 reading is provably unreliable, so it must
503/// not overwrite a curated built-in with a strictly less informative 0-arg signature
504/// (which would drop, e.g., a sectioning command's `prose` title and its reflow — the
505/// jss-class bug). The caller keeps the built-in showing through the overlay instead.
506///
507/// Narrow by construction (AGENTS.md conservatism): fires only when arity is 0, the body
508/// delegates, *and* a built-in exists to preserve. A genuine 0-arg redefinition has a
509/// self-contained body (no delegation) and is left to win, so it correctly loses prose.
510fn keeps_builtin_over_arity0(name: &str, arity: usize, body: &DefBody) -> bool {
511    arity == 0
512        && body
513            .called
514            .iter()
515            .any(|callee| DELEGATING_PRIMITIVES.contains(&callee.as_str()))
516        && crate::semantic::signature::builtin()
517            .command(name)
518            .is_some()
519}
520
521/// Whether `name` is a definition command the scanner recognizes
522/// (`\newcommand`/`\def`/xparse families; see [`DefKind`]). Exposed so consumers
523/// that must treat a definition's arguments as *code carried, not executed* (the
524/// linter's `missing-required-argument` rule skips partial applications like
525/// `\newcommand{\bold}{\textbf}`) share the scanner's one name list instead of
526/// duplicating it.
527pub fn is_definition_command(name: &str) -> bool {
528    DefKind::of(name).is_some()
529}
530
531/// Which definition family a control word names, if any.
532enum DefKind {
533    Command,
534    Def,
535    Environment,
536    XparseCommand,
537    XparseEnvironment,
538    /// A package command whose defined environment has a *verbatim* body, a static
539    /// fact of the *defining command's identity* (not of any catcode signal in its
540    /// begin-code, which lives inside the package's own machinery): `listings`'
541    /// `\lstnewenvironment` and `fancyvrb`'s `\DefineVerbatimEnvironment`.
542    VerbatimEnvironment,
543}
544
545impl DefKind {
546    fn of(name: &str) -> Option<Self> {
547        Some(match name {
548            "newcommand" | "renewcommand" | "providecommand" | "DeclareRobustCommand" => {
549                DefKind::Command
550            }
551            // Plain TeX `\def` and its global/expanded variants. `\let` is excluded: it
552            // aliases an existing meaning rather than carrying a replacement body to scan.
553            "def" | "edef" | "gdef" | "xdef" => DefKind::Def,
554            "newenvironment" | "renewenvironment" => DefKind::Environment,
555            "NewDocumentCommand"
556            | "RenewDocumentCommand"
557            | "ProvideDocumentCommand"
558            | "DeclareDocumentCommand" => DefKind::XparseCommand,
559            "NewDocumentEnvironment"
560            | "RenewDocumentEnvironment"
561            | "ProvideDocumentEnvironment"
562            | "DeclareDocumentEnvironment" => DefKind::XparseEnvironment,
563            // `listings`/`fancyvrb` verbatim-environment definitions: the body is raw
564            // text, a fact of the defining command, not of any scannable catcode signal.
565            "lstnewenvironment" | "DefineVerbatimEnvironment" => DefKind::VerbatimEnvironment,
566            _ => return None,
567        })
568    }
569}
570
571/// `\newcommand{\name}[n][default]{body}` → a [`CommandSig`]. The name is the
572/// control word in the first group; `[n]` (if present) is the arg count, and a
573/// second optional `[default]` makes the first argument optional `[…]` while the
574/// rest are mandatory `{…}` — LaTeX2e's `\newcommand` shape. The unbraced
575/// `\newcommand\name[n]…` form is recovered the same way via [`resolve_command_def`].
576fn scan_newcommand(
577    command: &SyntaxNode,
578    db: &mut SignatureDb,
579    bodies: &mut HashMap<SmolStr, DefBody>,
580    aliases: &mut HashMap<SmolStr, EnvAliasCandidate>,
581) {
582    let Some(def) = resolve_command_def(command) else {
583        return;
584    };
585    let (arity, first_optional) = newcommand_arity(&def.host);
586    // The replacement body is the group right after the name: index `first_arg_group`
587    // on the host (group 1 for the braced form, group 0 for the unbraced sibling).
588    let body = nth_group(&def.host, def.first_arg_group);
589    record_body(bodies, &def.name, body.as_ref());
590    record_env_alias(aliases, &def.name, arity, body.as_ref());
591    // Trust gate: a `\secdef`/`\@startsection`-style body reads as arity 0 but really
592    // consumes a title, so don't let it downgrade a curated built-in (keep the overlay
593    // falling through to the built-in). See [`keeps_builtin_over_arity0`].
594    if bodies
595        .get(def.name.as_str())
596        .is_some_and(|body| keeps_builtin_over_arity0(&def.name, arity, body))
597    {
598        return;
599    }
600    db.insert_command(
601        def.name,
602        CommandSig {
603            args: latex2e_args(arity, first_optional).into(),
604            sectioning: None,
605            verbatim: false,
606            verbatim_delimited: false,
607            rule: false,
608            inline: false,
609            // Never inferred for a scanned definition: block-ness is
610            // undecidable without meaning, so scanned commands stay with the
611            // formatter's residual authored-break rule.
612            block: false,
613        },
614    );
615}
616
617/// `\def\name<param text>{body}` (and the `\edef`/`\gdef`/`\xdef` variants) → a
618/// [`CommandSig`]. `\def` has only the unbraced name form (TeX has no `\def{\name}`), so
619/// the name is the immediately-following sibling `COMMAND`. The arity comes from the
620/// **parameter text** (`#1#2…`) between the name and the body — counted by
621/// [`def_params_and_body`] — not from a `[n]` optional. We record the body for the same
622/// catcode-signal/helper-chain analysis as `\newcommand`, which is what lets a `\def`
623/// helper participate in chain resolution ([`reaches_signal`]).
624fn scan_def(
625    command: &SyntaxNode,
626    db: &mut SignatureDb,
627    bodies: &mut HashMap<SmolStr, DefBody>,
628    aliases: &mut HashMap<SmolStr, EnvAliasCandidate>,
629) {
630    let Some(name_node) = adjacent_sibling_command(command) else {
631        return;
632    };
633    let Some(name) = command_name(&name_node) else {
634        return;
635    };
636    let (arity, body) = def_params_and_body(&name_node);
637    record_body(bodies, &name, body.as_ref());
638    record_env_alias(aliases, &name, arity, body.as_ref());
639    // Trust gate: same as `scan_newcommand` — a delegating `\def\section{\secdef …}`
640    // must not downgrade a curated built-in. See [`keeps_builtin_over_arity0`].
641    if bodies
642        .get(name.as_str())
643        .is_some_and(|body| keeps_builtin_over_arity0(&name, arity, body))
644    {
645        return;
646    }
647    db.insert_command(
648        name,
649        CommandSig {
650            // `\def` parameters carry no brace/bracket distinction; model them as the same
651            // all-mandatory-brace shape scanned `\newcommand`s use. `apply_verbatim_flags`
652            // pops the final slot and sets `verbatim` if a catcode signal is reachable.
653            args: latex2e_args(arity, false).into(),
654            sectioning: None,
655            verbatim: false,
656            verbatim_delimited: false,
657            rule: false,
658            inline: false,
659            // Never inferred for a scanned definition: block-ness is
660            // undecidable without meaning, so scanned commands stay with the
661            // formatter's residual authored-break rule.
662            block: false,
663        },
664    );
665}
666
667/// The `(arity, body)` of a `\def`-style definition, reading its parameter text off the
668/// name `COMMAND` node. Two CST shapes arise under greedy attachment:
669/// - **No parameters** (`\def\foo{body}`): the body brace group attaches as `\foo`'s first
670///   child `GROUP`, so arity is `0` and the body is `nth_group(name_node, 0)`.
671/// - **With parameters** (`\def\foo#1#2{body}`): the leading `#` (`HASH`) breaks greedy
672///   attachment, so `\foo` has no child group and the `#1`, `#2`, and `{body}` are all
673///   siblings. Arity is the number of `HASH` tokens (each `#1` lexes as `HASH` + `WORD`)
674///   before the first sibling `GROUP`, which is the body.
675///
676/// Anything other than trivia/`HASH`/`WORD` before a group means delimited or malformed
677/// parameter text we do not model; we stop and report no body (so no catcode signal is
678/// recorded for it — the conservative choice). Arity is capped at 9 like `\newcommand`.
679fn def_params_and_body(name_node: &SyntaxNode) -> (usize, Option<SyntaxNode>) {
680    // No parameter text: the body attached greedily as the name command's first group.
681    if let Some(body) = nth_group(name_node, 0) {
682        return (0, Some(body));
683    }
684    // Parameter text intervened: count `#` markers up to the first sibling group (the body).
685    let mut arity = 0usize;
686    let mut next = name_node.next_sibling_or_token();
687    while let Some(element) = next {
688        match element {
689            NodeOrToken::Token(token) if is_trivia(token.kind()) => {
690                next = token.next_sibling_or_token();
691            }
692            NodeOrToken::Token(token) if token.kind() == SyntaxKind::HASH => {
693                arity += 1;
694                next = token.next_sibling_or_token();
695            }
696            // The digit following `#`, or a literal delimiter token in a delimited macro.
697            NodeOrToken::Token(token) if token.kind() == SyntaxKind::WORD => {
698                next = token.next_sibling_or_token();
699            }
700            NodeOrToken::Node(node) if node.kind() == SyntaxKind::GROUP => {
701                return (arity.min(9), Some(node));
702            }
703            _ => return (arity.min(9), None),
704        }
705    }
706    (arity.min(9), None)
707}
708
709/// Record the catcode/called-macro facts of a command definition's replacement
710/// `body` group (absent or unresolvable body → no signal, no calls).
711fn record_body(bodies: &mut HashMap<SmolStr, DefBody>, name: &str, body: Option<&SyntaxNode>) {
712    let text = body.map(group_inner_source).unwrap_or_default();
713    bodies.insert(
714        SmolStr::new(name),
715        DefBody {
716            signal: catcode_signal(&text),
717            called: called_macros(&text),
718        },
719    );
720}
721
722/// `\newenvironment{name}[n][default]{begin}{end}` → an [`EnvironmentSig`]. Same
723/// arg-count shape as [`scan_newcommand`]. The begin-code (group 1 — the optionals
724/// `[n][default]` are `OPTIONAL` nodes, so they don't shift `nth_group` indexing) is
725/// recorded so [`apply_verbatim_env_flags`] can flag a catcode-othering body verbatim.
726fn scan_newenvironment(
727    command: &SyntaxNode,
728    db: &mut SignatureDb,
729    env_bodies: &mut HashMap<SmolStr, DefBody>,
730) {
731    let Some(name) = nth_group_text(command, 0) else {
732        return;
733    };
734    let name = name.trim();
735    if name.is_empty() {
736        return;
737    }
738    record_body(env_bodies, name, nth_group(command, 1).as_ref());
739    let (arity, first_optional) = newcommand_arity(command);
740    db.insert_environment(name, environment_sig(latex2e_args(arity, first_optional)));
741}
742
743/// A `listings`/`fancyvrb` verbatim-environment definition → an [`EnvironmentSig`]
744/// with `verbatim_body`. Unlike [`scan_newenvironment`], the verbatim-ness is *not*
745/// read from a catcode signal in the begin-code — that machinery lives inside the
746/// package — but is implied by the defining command's identity, a bounded static fact
747/// (AGENTS.md decision #1). The name is the control-word-free text in the first group:
748/// - `\lstnewenvironment{name}[n][default]{begin}{end}` — the `[n][default]` optionals
749///   give the runtime argument shape, as in [`scan_newenvironment`].
750/// - `\DefineVerbatimEnvironment{name}{base}{opts}` — the environment takes one
751///   optional `[key=val]` argument at use time (`fancyvrb`'s `Verbatim` family).
752fn scan_verbatim_environment(defining_command: &str, command: &SyntaxNode, db: &mut SignatureDb) {
753    let Some(name) = nth_group_text(command, 0) else {
754        return;
755    };
756    let name = name.trim();
757    if name.is_empty() {
758        return;
759    }
760    let args = if defining_command == "lstnewenvironment" {
761        let (arity, first_optional) = newcommand_arity(command);
762        latex2e_args(arity, first_optional)
763    } else {
764        // `\DefineVerbatimEnvironment` → a single optional `[options]` slot.
765        latex2e_args(1, true)
766    };
767    let mut sig = environment_sig(args);
768    sig.verbatim_body = true;
769    sig.reflow = false;
770    db.insert_environment(name, sig);
771}
772
773/// `\NewDocumentCommand{\name}{spec}{body}` → a [`CommandSig`] with args from the
774/// xparse spec. The unbraced `\NewDocumentCommand\name{spec}…` form is recovered the
775/// same way via [`resolve_command_def`]; `first_arg_group` indexes the spec group on
776/// whichever node hosts the arguments.
777fn scan_xparse_command(
778    command: &SyntaxNode,
779    db: &mut SignatureDb,
780    bodies: &mut HashMap<SmolStr, DefBody>,
781    aliases: &mut HashMap<SmolStr, EnvAliasCandidate>,
782) {
783    let Some(def) = resolve_command_def(command) else {
784        return;
785    };
786    let Some(spec) = nth_group(&def.host, def.first_arg_group) else {
787        return;
788    };
789    // The body follows the spec group, so it sits one index further along.
790    let body = nth_group(&def.host, def.first_arg_group + 1);
791    record_body(bodies, &def.name, body.as_ref());
792    let args = xparse::parse_spec(&group_inner_source(&spec));
793    record_env_alias(aliases, &def.name, args.len(), body.as_ref());
794    db.insert_command(
795        def.name,
796        CommandSig {
797            args: args.into(),
798            sectioning: None,
799            verbatim: false,
800            verbatim_delimited: false,
801            rule: false,
802            inline: false,
803            // Never inferred for a scanned definition: block-ness is
804            // undecidable without meaning, so scanned commands stay with the
805            // formatter's residual authored-break rule.
806            block: false,
807        },
808    );
809}
810
811/// A resolved command definition: the defined `name`, the node whose attached
812/// `OPTIONAL`/`GROUP` children carry the argument shape (`host`), and the index of
813/// the first *signature* group on that host.
814///
815/// Two name forms collapse to this shape:
816/// - **Braced** `\newcommand{\foo}…`: the host is the definition command itself; its
817///   group 0 is the `{\foo}` name, so signature groups start at index `1`.
818/// - **Unbraced** `\newcommand\foo…`: greedy attachment makes `\foo` the next sibling
819///   `COMMAND` and hangs the `[n]`/`{body}` (or xparse spec) off *it*, so the host is
820///   that sibling and signature groups start at index `0`.
821struct CommandDef {
822    name: String,
823    host: SyntaxNode,
824    first_arg_group: usize,
825}
826
827/// Resolve `command` (a `\newcommand`/xparse definition) to its [`CommandDef`],
828/// handling both the braced and unbraced name forms. Returns `None` when no command
829/// name can be read (a malformed or empty definition) — the scan then skips it.
830fn resolve_command_def(command: &SyntaxNode) -> Option<CommandDef> {
831    // Braced `{\name}`: the name control word lives in the first group, and every
832    // attached group/optional hangs off the definition command itself.
833    if command.children().any(|c| c.kind() == SyntaxKind::GROUP) {
834        let name = nth_group(command, 0)
835            .as_ref()
836            .and_then(group_command_name)?;
837        return Some(CommandDef {
838            name,
839            host: command.clone(),
840            first_arg_group: 1,
841        });
842    }
843    // Unbraced `\newcommand\foo…`: read the name and signature groups off the
844    // following sibling `COMMAND` (decision #2 — a scanner heuristic, no parser
845    // change).
846    let sibling = adjacent_sibling_command(command)?;
847    let name = command_name(&sibling)?;
848    Some(CommandDef {
849        name,
850        host: sibling,
851        first_arg_group: 0,
852    })
853}
854
855/// The immediately-following sibling `COMMAND`, separated from `command` by trivia
856/// only. Returns `None` if any non-trivia element intervenes, so `\newcommand\foo`
857/// (and the spaced `\newcommand \foo`) bind, but `\newcommand stray text \bar` does
858/// not. A blank line cannot reach here: the `\par` break splits the two commands into
859/// separate `PARAGRAPH` parents, so there is no sibling to find.
860fn adjacent_sibling_command(command: &SyntaxNode) -> Option<SyntaxNode> {
861    let mut next = command.next_sibling_or_token();
862    while let Some(element) = next {
863        match element {
864            NodeOrToken::Token(token) if is_trivia(token.kind()) => {
865                next = token.next_sibling_or_token();
866            }
867            NodeOrToken::Node(node) if node.kind() == SyntaxKind::COMMAND => return Some(node),
868            _ => return None,
869        }
870    }
871    None
872}
873
874/// Whether `kind` is trivia (whitespace/newline/comment). Mirrors the parser's
875/// private `Parser::is_trivia`; the trivia set is fixed by AGENTS.md decision #9.
876fn is_trivia(kind: SyntaxKind) -> bool {
877    matches!(
878        kind,
879        SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::COMMENT
880    )
881}
882
883/// `\NewDocumentEnvironment{name}{spec}{begin}{end}` → an [`EnvironmentSig`] with
884/// args from the xparse spec. The begin-code (group 2 — after `{name}` and `{spec}`)
885/// is recorded for verbatim detection, as in [`scan_newenvironment`].
886fn scan_xparse_environment(
887    command: &SyntaxNode,
888    db: &mut SignatureDb,
889    env_bodies: &mut HashMap<SmolStr, DefBody>,
890) {
891    let Some(name) = nth_group_text(command, 0) else {
892        return;
893    };
894    let name = name.trim();
895    if name.is_empty() {
896        return;
897    }
898    let Some(spec) = nth_group(command, 1) else {
899        return;
900    };
901    record_body(env_bodies, name, nth_group(command, 2).as_ref());
902    db.insert_environment(
903        name,
904        environment_sig(xparse::parse_spec(&group_inner_source(&spec))),
905    );
906}
907
908/// The `(arity, first_arg_optional)` pair for a LaTeX2e definition: the integer in
909/// the first `[…]` optional (default `0`), and whether a *second* optional is
910/// present (which makes the first argument optional).
911fn newcommand_arity(command: &SyntaxNode) -> (usize, bool) {
912    let optionals: Vec<Optional> = children::<Optional>(command).collect();
913    let arity = optionals
914        .first()
915        .map(|o| o.syntax())
916        .and_then(optional_number)
917        .unwrap_or(0)
918        .min(9); // LaTeX caps macro arity at 9.
919    (arity, optionals.len() >= 2)
920}
921
922/// The integer inside an `OPTIONAL` node (`[2]` → `2`), or `None` if it isn't a
923/// bare number.
924fn optional_number(node: &SyntaxNode) -> Option<usize> {
925    let text = node.text().to_string();
926    let inner = text.strip_prefix('[').unwrap_or(&text);
927    let inner = inner.strip_suffix(']').unwrap_or(inner);
928    inner.trim().parse().ok()
929}
930
931/// Build the LaTeX2e argument slots: `arity` arguments, all mandatory `{…}` unless
932/// `first_optional`, in which case the first is optional `[…]`.
933fn latex2e_args(arity: usize, first_optional: bool) -> Vec<ArgSpec> {
934    (0..arity)
935        .map(|i| {
936            if i == 0 && first_optional {
937                ArgSpec {
938                    required: false,
939                    kind: ArgKind::Bracket,
940                    content: ContentKind::Opaque,
941                }
942            } else {
943                ArgSpec {
944                    required: true,
945                    kind: ArgKind::Brace,
946                    content: ContentKind::Opaque,
947                }
948            }
949        })
950        .collect()
951}
952
953/// An [`EnvironmentSig`] for a scanned environment with the given args: a
954/// reflowable, non-math, non-verbatim body (the only shape LaTeX2e/xparse
955/// definitions give us without package-specific knowledge).
956fn environment_sig(args: Vec<ArgSpec>) -> EnvironmentSig {
957    EnvironmentSig {
958        args: args.into(),
959        verbatim_body: false,
960        // The delimited-verbatim name argument is a curated l3doc fact; a
961        // scanned definition never earns it.
962        verbatim_arg: false,
963        math: false,
964        code: false,
965        // Statement-sequence layout is a curated fact about a package's own
966        // grammar (a TikZ `;`), invisible in a `\newenvironment` body.
967        statement_body: false,
968        align: false,
969        reflow: true,
970        no_indent: false,
971        // A user `\newenvironment` is not assumed to be a list; the built-in DB
972        // is the source of truth for `\item`-bearing list layout.
973        list: false,
974        // Block-ness of a user-defined environment is unknown without
975        // package-specific knowledge; default to non-block (the parser keeps the
976        // conservative `PARAGRAPH` wrapper for it).
977        block: false,
978        // A scanned user environment carries no outline category; only the curated
979        // built-in floats/theorem-likes show up in the document-symbol outline.
980        outline: None,
981    }
982}
983
984#[cfg(test)]
985mod tests {
986    use super::*;
987    use crate::parser::{parse, reconstruct};
988
989    fn db_of(src: &str) -> SignatureDb {
990        // New parser-adjacent feature: assert losslessness on every input.
991        assert_eq!(reconstruct(src), src, "reconstruct must round-trip");
992        scan_definitions(&SyntaxNode::new_root(parse(src).green))
993    }
994
995    fn arg_kinds(args: &[ArgSpec]) -> Vec<ArgKind> {
996        args.iter().map(|a| a.kind).collect()
997    }
998
999    #[test]
1000    fn newcommand_counts_mandatory_args() {
1001        let db = db_of("\\newcommand{\\foo}[2]{#1#2}\n");
1002        let sig = db.command("foo").expect("foo defined");
1003        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace, ArgKind::Brace]);
1004        assert!(sig.args.iter().all(|a| a.required));
1005    }
1006
1007    #[test]
1008    fn newcommand_optional_first_arg() {
1009        let db = db_of("\\newcommand{\\foo}[2][d]{#1#2}\n");
1010        let sig = db.command("foo").expect("foo defined");
1011        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket, ArgKind::Brace]);
1012        assert!(!sig.args[0].required);
1013        assert!(sig.args[1].required);
1014    }
1015
1016    #[test]
1017    fn newcommand_zero_args() {
1018        let db = db_of("\\newcommand{\\foo}{bar}\n");
1019        assert!(db.command("foo").expect("foo defined").args.is_empty());
1020    }
1021
1022    #[test]
1023    fn renew_and_provide_recognized() {
1024        let db = db_of("\\renewcommand{\\a}[1]{x}\\providecommand{\\b}[1]{y}\n");
1025        assert_eq!(db.command("a").unwrap().args.len(), 1);
1026        assert_eq!(db.command("b").unwrap().args.len(), 1);
1027    }
1028
1029    #[test]
1030    fn secdef_redefinition_keeps_builtin_prose() {
1031        // jss.cls does `\renewcommand{\section}{\secdef \jsssimplesec \jsssimplesecnn}`.
1032        // The static scanner reads this as arity 0, but `\secdef` consumes the title at
1033        // expansion time, so the trust gate must *not* record a 0-arg override — the
1034        // curated built-in prose signature has to survive through the overlay.
1035        let db = db_of("\\renewcommand{\\section}{\\secdef \\a \\b}\n");
1036        assert!(
1037            db.command("section").is_none(),
1038            "the delegating redefinition must not be recorded as a scanned override"
1039        );
1040        let sigs = crate::semantic::signature::Signatures::new(&db);
1041        let sig = sigs.command("section").expect("built-in section survives");
1042        let last = sig.args.last().expect("section keeps its title argument");
1043        assert_eq!(
1044            last.content,
1045            crate::semantic::signature::ContentKind::Prose,
1046            "the title argument stays prose (reflowable)"
1047        );
1048    }
1049
1050    #[test]
1051    fn genuine_zero_arg_redefinition_downgrades_builtin() {
1052        // A self-contained body with no delegation genuinely drops the argument, so the
1053        // 0-arg reading is trustworthy and *must* override the built-in — the gate must
1054        // not fire here (the failure mode the trust gate is careful to avoid).
1055        let db = db_of("\\renewcommand{\\section}{\\textbf{Fixed}}\n");
1056        let sig = db
1057            .command("section")
1058            .expect("genuine 0-arg redefinition is recorded");
1059        assert!(
1060            sig.args.is_empty(),
1061            "no delegation means the scanned 0-arg signature wins"
1062        );
1063    }
1064
1065    #[test]
1066    fn secdef_redefinition_of_unknown_still_records() {
1067        // The gate only protects a *curated built-in*: a delegating redefinition of a
1068        // name with no built-in has nothing to preserve, so it records as normal (arity
1069        // 0), keeping the name available to completion.
1070        let db = db_of("\\renewcommand{\\mysec}{\\secdef \\a \\b}\n");
1071        let sig = db.command("mysec").expect("unknown name is still recorded");
1072        assert!(sig.args.is_empty());
1073    }
1074
1075    #[test]
1076    fn newenvironment_args() {
1077        let db = db_of("\\newenvironment{thm}[1]{begin #1}{end}\n");
1078        let sig = db.environment("thm").expect("thm defined");
1079        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace]);
1080        assert!(sig.reflow);
1081        assert!(!sig.verbatim_body);
1082        assert!(!sig.math);
1083    }
1084
1085    #[test]
1086    fn xparse_command_spec() {
1087        let db = db_of("\\NewDocumentCommand{\\foo}{m O{d} m}{x}\n");
1088        let sig = db.command("foo").expect("foo defined");
1089        assert_eq!(
1090            arg_kinds(&sig.args),
1091            vec![ArgKind::Brace, ArgKind::Bracket, ArgKind::Brace]
1092        );
1093    }
1094
1095    #[test]
1096    fn xparse_environment_spec() {
1097        let db = db_of("\\NewDocumentEnvironment{env}{O{x} m}{a}{b}\n");
1098        let sig = db.environment("env").expect("env defined");
1099        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket, ArgKind::Brace]);
1100    }
1101
1102    #[test]
1103    fn unbraced_newcommand_extracted() {
1104        // `\newcommand\foo[2]{…}` parses with `\foo` as a sibling carrying the `[2]`;
1105        // the scanner reads the signature off that sibling.
1106        let db = db_of("\\newcommand\\foo[2]{#1#2}\n");
1107        let sig = db.command("foo").expect("foo defined");
1108        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace, ArgKind::Brace]);
1109        assert!(sig.args.iter().all(|a| a.required));
1110    }
1111
1112    #[test]
1113    fn unbraced_optional_first_arg() {
1114        let db = db_of("\\newcommand\\foo[2][d]{#1#2}\n");
1115        let sig = db.command("foo").expect("foo defined");
1116        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket, ArgKind::Brace]);
1117        assert!(!sig.args[0].required);
1118        assert!(sig.args[1].required);
1119    }
1120
1121    #[test]
1122    fn unbraced_zero_args() {
1123        let db = db_of("\\newcommand\\foo{x}\n");
1124        assert!(db.command("foo").expect("foo defined").args.is_empty());
1125    }
1126
1127    #[test]
1128    fn unbraced_spaced_binds() {
1129        // Trivia between the keyword and the name still binds.
1130        let db = db_of("\\newcommand \\foo[1]{x}\n");
1131        assert_eq!(db.command("foo").unwrap().args.len(), 1);
1132    }
1133
1134    #[test]
1135    fn unbraced_renewcommand() {
1136        let db = db_of("\\renewcommand\\foo[1]{x}\n");
1137        assert_eq!(db.command("foo").unwrap().args.len(), 1);
1138    }
1139
1140    #[test]
1141    fn unbraced_xparse_command() {
1142        let db = db_of("\\NewDocumentCommand\\foo{m O{d} m}{x}\n");
1143        let sig = db.command("foo").expect("foo defined");
1144        assert_eq!(
1145            arg_kinds(&sig.args),
1146            vec![ArgKind::Brace, ArgKind::Bracket, ArgKind::Brace]
1147        );
1148    }
1149
1150    #[test]
1151    fn unbraced_stray_text_not_bound() {
1152        // Non-trivia text between the keyword and a later command breaks the bind:
1153        // neither name is a definition target.
1154        let db = db_of("\\newcommand foo \\bar{x}\n");
1155        assert!(db.command("foo").is_none());
1156        assert!(db.command("bar").is_none());
1157    }
1158
1159    #[test]
1160    fn redefinition_last_wins() {
1161        let db = db_of("\\newcommand{\\foo}[1]{x}\\renewcommand{\\foo}[3]{y}\n");
1162        assert_eq!(db.command("foo").unwrap().args.len(), 3);
1163    }
1164
1165    #[test]
1166    fn garbage_definition_degrades_to_no_insert() {
1167        // No name group at all: nothing inserted, no panic.
1168        let db = db_of("\\newcommand\n");
1169        assert!(db.command("foo").is_none());
1170    }
1171
1172    #[test]
1173    fn nested_definition_collected() {
1174        let db = db_of("\\begin{document}\n\\newcommand{\\foo}[1]{x}\n\\end{document}\n");
1175        assert_eq!(db.command("foo").unwrap().args.len(), 1);
1176    }
1177
1178    #[test]
1179    fn commented_definition_ignored() {
1180        let db = db_of("% \\newcommand{\\foo}[1]{x}\n");
1181        assert!(db.command("foo").is_none());
1182    }
1183
1184    #[test]
1185    fn verbatim_makeother_flagged() {
1186        // `\@makeother\$` in the body others `$`, so the argument is verbatim. The
1187        // single argument becomes the implicit verbatim one, leaving no leading args.
1188        let db = db_of("\\newcommand\\shellcmd[1]{\\@makeother\\$#1}\n");
1189        let sig = db.command("shellcmd").expect("shellcmd defined");
1190        assert!(sig.verbatim);
1191        assert!(sig.args.is_empty());
1192    }
1193
1194    #[test]
1195    fn verbatim_catcode_flagged() {
1196        // A `\catcode … 12` ("other") assignment is the same signal.
1197        let db = db_of("\\newcommand\\shellcmd[1]{\\catcode 36=12 #1}\n");
1198        assert!(db.command("shellcmd").expect("shellcmd defined").verbatim);
1199    }
1200
1201    #[test]
1202    fn verbatim_dospecials_flagged() {
1203        // The classic verbatim setup loop.
1204        let db = db_of("\\newcommand\\shellcmd[1]{\\let\\do\\@makeother\\dospecials #1}\n");
1205        assert!(db.command("shellcmd").expect("shellcmd defined").verbatim);
1206    }
1207
1208    #[test]
1209    fn verbatim_keeps_leading_args() {
1210        // Only the *final* argument is verbatim: a two-arg command keeps its first
1211        // (leading) slot and drops the last as the implicit verbatim argument.
1212        let db = db_of("\\newcommand\\mycode[2]{\\@makeother\\$#1#2}\n");
1213        let sig = db.command("mycode").expect("mycode defined");
1214        assert!(sig.verbatim);
1215        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace]);
1216    }
1217
1218    #[test]
1219    fn verbatim_via_chained_helper() {
1220        // The catcode signal lives in a helper the command calls, not in its own
1221        // body; the chain is followed across scanned definitions.
1222        let db =
1223            db_of("\\newcommand\\setup{\\@makeother\\$}\\newcommand\\shellcmd[1]{\\setup#1}\n");
1224        assert!(db.command("shellcmd").expect("shellcmd defined").verbatim);
1225        // The arity-0 helper itself takes no argument, so it is never flagged.
1226        assert!(!db.command("setup").expect("setup defined").verbatim);
1227    }
1228
1229    #[test]
1230    fn verbatim_chain_cycle_terminates() {
1231        // Mutually recursive helpers with no signal must terminate (visited guard)
1232        // and flag neither command.
1233        let db = db_of("\\newcommand\\a[1]{\\b#1}\\newcommand\\b[1]{\\a#1}\n");
1234        assert!(!db.command("a").expect("a defined").verbatim);
1235        assert!(!db.command("b").expect("b defined").verbatim);
1236    }
1237
1238    #[test]
1239    fn ordinary_command_not_verbatim() {
1240        let db = db_of("\\newcommand\\foo[1]{\\emph{#1}}\n");
1241        assert!(!db.command("foo").expect("foo defined").verbatim);
1242    }
1243
1244    #[test]
1245    fn verbatim_needs_an_argument() {
1246        // An arity-0 command grabs no `{…}` of its own, so a catcode signal in its
1247        // body does not make it a verbatim-*argument* command.
1248        let db = db_of("\\newcommand\\setup{\\@makeother\\$}\n");
1249        assert!(!db.command("setup").expect("setup defined").verbatim);
1250    }
1251
1252    #[test]
1253    fn def_helper_chain_followed() {
1254        // The helper is defined with `\def`; its body is now scanned, so the chain from
1255        // `\shellcmd` through `\setup` to the catcode signal resolves and flags the caller.
1256        let db = db_of("\\def\\setup{\\@makeother\\$}\\newcommand\\shellcmd[1]{\\setup#1}\n");
1257        assert!(db.command("shellcmd").expect("shellcmd defined").verbatim);
1258        // The arity-0 helper itself takes no argument, so it is never flagged.
1259        assert!(!db.command("setup").expect("setup defined").verbatim);
1260    }
1261
1262    #[test]
1263    fn def_direct_verbatim_flagged() {
1264        // A `\def` command whose own body others a special char is verbatim; its single
1265        // parameter becomes the implicit verbatim argument, leaving no leading args.
1266        let db = db_of("\\def\\shellcmd#1{\\@makeother\\$#1}\n");
1267        let sig = db.command("shellcmd").expect("shellcmd defined");
1268        assert!(sig.verbatim);
1269        assert!(sig.args.is_empty());
1270    }
1271
1272    #[test]
1273    fn def_zero_params() {
1274        // No parameter text: the body attaches as the name command's child group.
1275        let db = db_of("\\def\\foo{x}\n");
1276        let sig = db.command("foo").expect("foo defined");
1277        assert!(sig.args.is_empty());
1278        assert!(!sig.verbatim);
1279    }
1280
1281    #[test]
1282    fn def_counts_params() {
1283        // `#1#2` parameter text → arity 2, all mandatory brace slots.
1284        let db = db_of("\\def\\foo#1#2{#1#2}\n");
1285        let sig = db.command("foo").expect("foo defined");
1286        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace, ArgKind::Brace]);
1287    }
1288
1289    #[test]
1290    fn def_variants_scanned() {
1291        // `\edef`/`\gdef`/`\xdef` share `\def`'s shape and are scanned the same way.
1292        let db = db_of("\\edef\\a#1{x}\\gdef\\b{y}\\xdef\\c#1{\\@makeother\\$#1}\n");
1293        assert_eq!(db.command("a").expect("a defined").args.len(), 1);
1294        assert!(db.command("b").expect("b defined").args.is_empty());
1295        let c = db.command("c").expect("c defined");
1296        assert!(c.verbatim);
1297        assert!(c.args.is_empty());
1298    }
1299
1300    #[test]
1301    fn def_chain_through_def_helpers() {
1302        // A `\def` → `\def` helper chain still reaches the signal and flags the caller.
1303        let db = db_of(
1304            "\\def\\inner{\\@makeother\\$}\\def\\outer{\\inner}\\newcommand\\cmd[1]{\\outer#1}\n",
1305        );
1306        assert!(db.command("cmd").expect("cmd defined").verbatim);
1307    }
1308
1309    #[test]
1310    fn verbatim_xparse_flagged() {
1311        let db = db_of("\\NewDocumentCommand\\shellcmd{m}{\\@makeother\\$#1}\n");
1312        let sig = db.command("shellcmd").expect("shellcmd defined");
1313        assert!(sig.verbatim);
1314        assert!(sig.args.is_empty());
1315    }
1316
1317    #[test]
1318    fn env_makeother_flagged() {
1319        // `\@makeother\$` in the begin-code others `$`, so the environment body is
1320        // verbatim. The environment analog of `verbatim_makeother_flagged`.
1321        let db = db_of("\\newenvironment{shellenv}{\\@makeother\\$}{}\n");
1322        let sig = db.environment("shellenv").expect("shellenv defined");
1323        assert!(sig.verbatim_body);
1324        assert!(!sig.reflow); // a verbatim body is never reflowed
1325    }
1326
1327    #[test]
1328    fn env_catcode_flagged() {
1329        // A `\catcode … 12` ("other") assignment in the begin-code is the same signal.
1330        let db = db_of("\\newenvironment{shellenv}[1]{\\catcode 36=12 }{}\n");
1331        let sig = db.environment("shellenv").expect("shellenv defined");
1332        assert!(sig.verbatim_body);
1333        // Declared args are kept (they are all leading; the body follows them).
1334        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace]);
1335    }
1336
1337    #[test]
1338    fn env_via_chained_helper() {
1339        // The catcode signal lives in a helper the begin-code calls, not in the
1340        // begin-code itself; the chain is followed through the command bodies map.
1341        let db =
1342            db_of("\\newcommand\\setup{\\@makeother\\$}\\newenvironment{shellenv}{\\setup}{}\n");
1343        assert!(
1344            db.environment("shellenv")
1345                .expect("shellenv defined")
1346                .verbatim_body
1347        );
1348    }
1349
1350    #[test]
1351    fn env_without_signal_not_flagged() {
1352        // An ordinary `\newenvironment` with no catcode setup stays reflowable.
1353        let db = db_of("\\newenvironment{remark}{\\par\\noindent\\textbf{Remark.}}{\\par}\n");
1354        let sig = db.environment("remark").expect("remark defined");
1355        assert!(!sig.verbatim_body);
1356        assert!(sig.reflow);
1357    }
1358
1359    #[test]
1360    fn lstnewenvironment_flagged_verbatim() {
1361        // A `listings` environment's body is verbatim by virtue of the defining
1362        // command, with no catcode signal in the begin-code. The `[1][default]`
1363        // optionals give it one optional runtime argument (`\begin{demo}[opts]`).
1364        let db = db_of("\\lstnewenvironment{demo}[1][code]{\\lstset{#1}}{}\n");
1365        let sig = db.environment("demo").expect("demo defined");
1366        assert!(sig.verbatim_body);
1367        assert!(!sig.reflow);
1368        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket]);
1369    }
1370
1371    #[test]
1372    fn lstnewenvironment_no_args_flagged_verbatim() {
1373        let db = db_of("\\lstnewenvironment{demo}{}{}\n");
1374        let sig = db.environment("demo").expect("demo defined");
1375        assert!(sig.verbatim_body);
1376        assert!(sig.args.is_empty());
1377    }
1378
1379    #[test]
1380    fn defineverbatimenvironment_flagged_verbatim() {
1381        // `fancyvrb`: the environment takes one optional `[key=val]` argument.
1382        let db = db_of("\\DefineVerbatimEnvironment{code}{Verbatim}{fontsize=\\small}\n");
1383        let sig = db.environment("code").expect("code defined");
1384        assert!(sig.verbatim_body);
1385        assert!(!sig.reflow);
1386        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket]);
1387    }
1388
1389    #[test]
1390    fn xparse_env_makeother_flagged() {
1391        // `\NewDocumentEnvironment`: the begin-code is group 2 (after name and spec).
1392        let db = db_of("\\NewDocumentEnvironment{shellenv}{O{x}}{\\dospecials}{}\n");
1393        let sig = db.environment("shellenv").expect("shellenv defined");
1394        assert!(sig.verbatim_body);
1395        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket]);
1396    }
1397
1398    fn sites_of(src: &str) -> Vec<DefSite> {
1399        assert_eq!(reconstruct(src), src, "reconstruct must round-trip");
1400        scan_definition_sites(&SyntaxNode::new_root(parse(src).green))
1401    }
1402
1403    #[test]
1404    fn def_site_newcommand_braced_name_span() {
1405        let src = "\\newcommand{\\foo}[1]{#1}\n";
1406        let sites = sites_of(src);
1407        assert_eq!(sites.len(), 1);
1408        let site = &sites[0];
1409        assert_eq!(site.name, "foo");
1410        assert_eq!(site.kind, DefSiteKind::Command);
1411        assert_eq!(&src[site.name_range], "\\foo");
1412        assert_eq!(&src[site.range], "\\newcommand{\\foo}[1]{#1}");
1413    }
1414
1415    #[test]
1416    fn def_site_newcommand_unbraced_name_span() {
1417        let src = "\\newcommand\\foo[1]{#1}\n";
1418        let sites = sites_of(src);
1419        assert_eq!(sites.len(), 1);
1420        assert_eq!(sites[0].name, "foo");
1421        assert_eq!(&src[sites[0].name_range], "\\foo");
1422        assert_eq!(&src[sites[0].range], "\\newcommand\\foo[1]{#1}");
1423    }
1424
1425    #[test]
1426    fn def_site_def_sibling_name_span() {
1427        let src = "\\def\\foo#1{#1}\n";
1428        let sites = sites_of(src);
1429        assert_eq!(sites.len(), 1);
1430        assert_eq!(sites[0].name, "foo");
1431        assert_eq!(sites[0].kind, DefSiteKind::Command);
1432        assert_eq!(&src[sites[0].name_range], "\\foo");
1433    }
1434
1435    #[test]
1436    fn def_site_xparse_command_name_span() {
1437        let src = "\\NewDocumentCommand{\\foo}{m O{d}}{x}\n";
1438        let sites = sites_of(src);
1439        assert_eq!(sites.len(), 1);
1440        assert_eq!(sites[0].name, "foo");
1441        assert_eq!(&src[sites[0].name_range], "\\foo");
1442    }
1443
1444    #[test]
1445    fn def_site_newenvironment_name_span() {
1446        let src = "\\newenvironment{myenv}{begin}{end}\n";
1447        let sites = sites_of(src);
1448        assert_eq!(sites.len(), 1);
1449        let site = &sites[0];
1450        assert_eq!(site.name, "myenv");
1451        assert_eq!(site.kind, DefSiteKind::Environment);
1452        assert_eq!(&src[site.name_range], "myenv");
1453    }
1454
1455    #[test]
1456    fn def_site_xparse_environment_name_span() {
1457        let src = "\\NewDocumentEnvironment{myenv}{m}{a}{b}\n";
1458        let sites = sites_of(src);
1459        assert_eq!(sites.len(), 1);
1460        assert_eq!(sites[0].name, "myenv");
1461        assert_eq!(sites[0].kind, DefSiteKind::Environment);
1462        assert_eq!(&src[sites[0].name_range], "myenv");
1463    }
1464
1465    #[test]
1466    fn def_site_keeps_every_redefinition() {
1467        // Unlike `scan_definitions` (last wins), every site is a navigation target.
1468        let src = "\\newcommand{\\foo}{a}\n\\renewcommand{\\foo}{b}\n";
1469        let sites = sites_of(src);
1470        assert_eq!(sites.len(), 2);
1471        assert!(sites.iter().all(|s| s.name == "foo"));
1472        assert!(sites[0].name_range.start() < sites[1].name_range.start());
1473    }
1474
1475    #[test]
1476    fn def_site_none_for_malformed() {
1477        assert!(sites_of("\\newcommand\n").is_empty());
1478        assert!(sites_of("\\newenvironment{}{a}{b}\n").is_empty());
1479    }
1480
1481    // --- environment aliases ------------------------------------------------
1482
1483    /// A `\begin{X}`/`\end{X}` pair defined with `\newcommand`, the issue-#109 shape.
1484    const EQNARRAY_PAIR: &str =
1485        "\\newcommand{\\bea}{\\begin{eqnarray}}\n\\newcommand{\\eea}{\\end{eqnarray}}\n";
1486
1487    #[test]
1488    fn newcommand_env_alias_pair_is_recorded() {
1489        let db = db_of(EQNARRAY_PAIR);
1490        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1491        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1492        // The opener side only: the closer is not an environment opener.
1493        assert_eq!(db.env_begin_alias("eea"), None);
1494        assert_eq!(db.env_end_alias("bea"), None);
1495    }
1496
1497    #[test]
1498    fn def_env_alias_pair_is_recorded() {
1499        let db = db_of("\\def\\bea{\\begin{eqnarray}}\n\\def\\eea{\\end{eqnarray}}\n");
1500        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1501        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1502    }
1503
1504    #[test]
1505    fn xparse_env_alias_pair_is_recorded() {
1506        let db = db_of(
1507            "\\NewDocumentCommand{\\bea}{}{\\begin{eqnarray}}\n\
1508             \\NewDocumentCommand{\\eea}{}{\\end{eqnarray}}\n",
1509        );
1510        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1511        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1512    }
1513
1514    #[test]
1515    fn env_alias_body_tolerates_trivia() {
1516        // The detector reads the CST, so a reformat that re-spaces the body (or an
1517        // authored comment) must not change what is detected. This is what keeps
1518        // the alias table stable across `fmt`.
1519        let db = db_of(
1520            "\\newcommand{\\bea}{ \\begin{eqnarray} }\n\\newcommand{\\eea}{% why\n\\end{eqnarray}}\n",
1521        );
1522        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1523        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1524    }
1525
1526    #[test]
1527    fn env_alias_needs_both_halves() {
1528        let db = db_of("\\newcommand{\\bea}{\\begin{eqnarray}}\n");
1529        assert_eq!(db.env_begin_alias("bea"), None);
1530    }
1531
1532    #[test]
1533    fn env_alias_rejects_uncurated_target() {
1534        let db = db_of(
1535            "\\newcommand{\\bmy}{\\begin{notacuratedenv}}\n\
1536             \\newcommand{\\emy}{\\end{notacuratedenv}}\n",
1537        );
1538        assert_eq!(db.env_begin_alias("bmy"), None);
1539        assert_eq!(db.env_end_alias("emy"), None);
1540    }
1541
1542    #[test]
1543    fn env_alias_rejects_verbatim_target() {
1544        // `\newcommand{\bv}{\begin{verbatim}}` does not work in TeX at all: the
1545        // body is tokenized before the macro expands, so the catcode change never
1546        // applies. Pairing it would model a construct that does not exist.
1547        let db =
1548            db_of("\\newcommand{\\bv}{\\begin{verbatim}}\n\\newcommand{\\ev}{\\end{verbatim}}\n");
1549        assert_eq!(db.env_begin_alias("bv"), None);
1550    }
1551
1552    #[test]
1553    fn env_alias_rejects_argument_taking_target() {
1554        // The alias head consumes no arguments, so `tabular`'s column spec would
1555        // land in the body and the grid would render with no alignments.
1556        let db =
1557            db_of("\\newcommand{\\bt}{\\begin{tabular}}\n\\newcommand{\\et}{\\end{tabular}}\n");
1558        assert_eq!(db.env_begin_alias("bt"), None);
1559    }
1560
1561    #[test]
1562    fn env_alias_rejects_parameterized_definition() {
1563        let db = db_of(
1564            "\\newcommand{\\bt}[1]{\\begin{tabular}{#1}}\n\\newcommand{\\et}{\\end{tabular}}\n",
1565        );
1566        assert_eq!(db.env_begin_alias("bt"), None);
1567    }
1568
1569    #[test]
1570    fn env_alias_rejects_body_with_extra_content() {
1571        let db = db_of(
1572            "\\newcommand{\\bea}{\\begin{eqnarray}\\label{x}}\n\\newcommand{\\eea}{\\end{eqnarray}}\n",
1573        );
1574        assert_eq!(db.env_begin_alias("bea"), None);
1575    }
1576
1577    #[test]
1578    fn redefinition_retracts_an_earlier_env_alias() {
1579        // Last definition wins, and that has to include *losing* alias-ness —
1580        // otherwise a stale entry would keep pairing a command that no longer opens
1581        // anything.
1582        let db = db_of(
1583            "\\newcommand{\\bea}{\\begin{eqnarray}}\n\
1584             \\newcommand{\\eea}{\\end{eqnarray}}\n\
1585             \\renewcommand{\\bea}{\\textbf}\n",
1586        );
1587        assert_eq!(db.env_begin_alias("bea"), None);
1588    }
1589
1590    #[test]
1591    fn env_alias_does_not_pollute_the_environment_namespace() {
1592        // The alias is a command, not an environment: `\begin{bea}` must not be
1593        // offered by completion, and `SignatureDb::environment` must not resolve it.
1594        let db = db_of(EQNARRAY_PAIR);
1595        assert!(db.environment("bea").is_none());
1596        assert!(!db.environment_names().any(|n| n == "bea"));
1597    }
1598
1599    /// The `ENVIRONMENT` node in `src`, for the node-keyed signature lookup.
1600    fn environment_node(src: &str) -> SyntaxNode {
1601        SyntaxNode::new_root(parse(src).green)
1602            .descendants()
1603            .find(|n| n.kind() == SyntaxKind::ENVIRONMENT)
1604            .expect("an environment")
1605    }
1606
1607    #[test]
1608    fn signatures_resolves_an_env_alias_to_the_curated_target() {
1609        use crate::semantic::signature::Signatures;
1610        let src = format!("{EQNARRAY_PAIR}\\bea a \\eea\n");
1611        let db = db_of(&src);
1612        let sigs = Signatures::new(&db);
1613        let sig = sigs
1614            .environment_at(&environment_node(&src))
1615            .expect("alias resolves");
1616        let target = builtin().environment("eqnarray").expect("curated target");
1617        assert_eq!(sig, target);
1618        assert!(sig.math && sig.align);
1619        // The *name*-keyed lookup deliberately does not: `bea` names a command.
1620        assert!(sigs.environment("bea").is_none());
1621    }
1622
1623    #[test]
1624    fn a_literal_begin_does_not_inherit_the_alias_target() {
1625        // `\begin{bea}` is an environment that happens to spell the alias's name.
1626        // It is not the alias, so it must not pick up `eqnarray`'s behavior — the
1627        // reason the lookup is keyed on the node and not on the name.
1628        use crate::semantic::signature::Signatures;
1629        let src = format!("{EQNARRAY_PAIR}\\begin{{bea}} a \\end{{bea}}\n");
1630        let db = db_of(&src);
1631        let sigs = Signatures::new(&db);
1632        assert!(sigs.environment_at(&environment_node(&src)).is_none());
1633    }
1634
1635    #[test]
1636    fn a_real_environment_wins_over_an_alias_of_the_same_name() {
1637        // A `\newenvironment{bea}` and an alias `\bea` are different constructs
1638        // that collide by name; each node resolves to its own.
1639        use crate::semantic::signature::Signatures;
1640        let defs = "\\newcommand{\\bea}{\\begin{eqnarray}}\n\
1641             \\newcommand{\\eea}{\\end{eqnarray}}\n\
1642             \\newenvironment{bea}{x}{y}\n";
1643        let db = db_of(&format!("{defs}\\begin{{bea}} a \\end{{bea}}\n"));
1644        let sigs = Signatures::new(&db);
1645        let sig = sigs
1646            .environment_at(&environment_node(&format!(
1647                "{defs}\\begin{{bea}} a \\end{{bea}}\n"
1648            )))
1649            .expect("real environment resolves");
1650        assert!(
1651            !sig.math,
1652            "the scanned \\newenvironment must win for a literal `\\begin{{bea}}`"
1653        );
1654        assert!(
1655            sigs.environment_at(&environment_node(&format!("{defs}\\bea a \\eea\n")))
1656                .is_some_and(|sig| sig.math),
1657            "while the alias delimiters still resolve to eqnarray"
1658        );
1659    }
1660
1661    #[test]
1662    fn merge_from_carries_env_aliases() {
1663        let mut target = SignatureDb::default();
1664        target.merge_from(&db_of(EQNARRAY_PAIR));
1665        assert_eq!(target.env_begin_alias("bea"), Some("eqnarray"));
1666        assert_eq!(target.env_end_alias("eea"), Some("eqnarray"));
1667    }
1668}