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