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, is_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`. 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            db.insert_environment(name, sig);
417        }
418    }
419}
420
421/// Whether a catcode-othering signal is reachable from `name`'s body, following
422/// chained helper macros within the scanned definition set. A `visited` set breaks
423/// definition cycles (mutually recursive helpers terminate). A helper defined via
424/// `\def` (not scanned) is absent from `bodies`, so the chain breaks there and we do
425/// not flag — the conservative false-negative.
426fn reaches_signal(
427    name: &str,
428    bodies: &HashMap<SmolStr, DefBody>,
429    visited: &mut HashSet<SmolStr>,
430) -> bool {
431    if !visited.insert(SmolStr::new(name)) {
432        return false;
433    }
434    let Some(body) = bodies.get(name) else {
435        return false;
436    };
437    reaches_signal_body(body, bodies, visited)
438}
439
440/// Whether a catcode-othering signal is reachable from a definition `body` —
441/// present directly, or in the body of a scanned command it transitively calls. The
442/// body-level entry point used both by [`reaches_signal`] (after a name lookup) and by
443/// [`apply_verbatim_env_flags`] (for an environment's begin-code, which has no command
444/// name to look up).
445fn reaches_signal_body(
446    body: &DefBody,
447    bodies: &HashMap<SmolStr, DefBody>,
448    visited: &mut HashSet<SmolStr>,
449) -> bool {
450    body.signal
451        || body
452            .called
453            .iter()
454            .any(|callee| reaches_signal(callee, bodies, visited))
455}
456
457/// Whether `body` text reassigns a special char's catcode to "other" — the static
458/// fingerprint of a verbatim-argument command's setup. Strict, to avoid false
459/// positives (which would silence real diagnostics): each pattern is verbatim-setup
460/// specific. We match surface text only; no catcode arithmetic is evaluated.
461fn catcode_signal(body: &str) -> bool {
462    body.contains("\\@makeother")
463        || body.contains("\\@sanitize")
464        || body.contains("\\dospecials")
465        || body
466            .match_indices("\\catcode")
467            .any(|(start, _)| catcode_assigns_other(&body[start + "\\catcode".len()..]))
468}
469
470/// Recognize the bounded surface shape `\catcode`…`=12` after the primitive.
471///
472/// TeX's number scanner is broader than this deliberately conservative check.
473/// Verbatim inference may miss an exotic assignment, but it must not combine an
474/// unrelated `12` with a different catcode assignment and silence diagnostics.
475fn catcode_assigns_other(tail: &str) -> bool {
476    let mut chars = tail.chars();
477    if chars
478        .clone()
479        .next()
480        .is_some_and(|c| c.is_ascii_alphabetic() || matches!(c, '@' | '_' | ':'))
481    {
482        return false;
483    }
484
485    let mut after_equals = chars
486        .by_ref()
487        .take(64)
488        .skip_while(|&c| c != '=')
489        .skip(1)
490        .skip_while(|c| c.is_whitespace());
491    after_equals.next() == Some('1')
492        && after_equals.next() == Some('2')
493        && !after_equals.next().is_some_and(|c| c.is_ascii_digit())
494}
495
496/// The control-word names (leading `\` stripped) the body invokes, for chained-helper
497/// resolution. `@` is treated as a name char so `\@makeother`/`\@codex`-style helpers
498/// are captured; control symbols (`\$`, `\\`) yield no name and are skipped. Reads
499/// surface text only.
500fn called_macros(body: &str) -> Vec<SmolStr> {
501    body.match_indices('\\')
502        .filter_map(|(pos, _)| {
503            let after = &body[pos + 1..];
504            let len: usize = after
505                .chars()
506                .take_while(|c| c.is_ascii_alphabetic() || *c == '@')
507                .map(char::len_utf8)
508                .sum();
509            (len > 0).then(|| SmolStr::new(&after[..len]))
510        })
511        .collect()
512}
513
514/// Kernel sectioning primitives that themselves scan a `(*/[toc]/{title})` argument
515/// the static scanner cannot see from a redefinition body. A `\renewcommand{\cs}{…}`
516/// whose body is `\secdef …`/`\@startsection …` carries no `#` parameter and no `[n]`,
517/// so [`newcommand_arity`] reads it as arity 0 — but `\cs` really does consume a prose
518/// title at expansion time (jss's `\renewcommand{\section}{\secdef …}` is the canonical
519/// case). Curated and deliberately narrow: a *missed* name falls back to the safe status
520/// quo (the redefinition wins and the argument is left un-reflowed), while a *false*
521/// match is the only way to over-trust a built-in, so we keep the set tight and match
522/// only these kernel primitives.
523const DELEGATING_PRIMITIVES: &[&str] = &["secdef", "@startsection", "@dblarg", "@sect", "@ssect"];
524
525/// The **trust gate** for a `\newcommand`/`\def` the static scanner reads as taking no
526/// arguments. When the body *delegates* to a token-consuming kernel primitive
527/// ([`DELEGATING_PRIMITIVES`]), the arity-0 reading is provably unreliable, so it must
528/// not overwrite a curated built-in with a strictly less informative 0-arg signature
529/// (which would drop, e.g., a sectioning command's `prose` title and its reflow — the
530/// jss-class bug). The caller keeps the built-in showing through the overlay instead.
531///
532/// Narrow by construction (AGENTS.md conservatism): fires only when arity is 0, the body
533/// delegates, *and* a built-in exists to preserve. A genuine 0-arg redefinition has a
534/// self-contained body (no delegation) and is left to win, so it correctly loses prose.
535fn keeps_builtin_over_arity0(name: &str, arity: usize, body: &DefBody) -> bool {
536    arity == 0
537        && body
538            .called
539            .iter()
540            .any(|callee| DELEGATING_PRIMITIVES.contains(&callee.as_str()))
541        && crate::semantic::signature::builtin()
542            .command(name)
543            .is_some()
544}
545
546/// Whether `name` is a definition command the scanner recognizes
547/// (`\newcommand`/`\def`/xparse families; see [`DefKind`]). Exposed so consumers
548/// that must treat a definition's arguments as *code carried, not executed* (the
549/// linter's `missing-required-argument` rule skips partial applications like
550/// `\newcommand{\bold}{\textbf}`) share the scanner's one name list instead of
551/// duplicating it.
552pub fn is_definition_command(name: &str) -> bool {
553    DefKind::of(name).is_some()
554}
555
556/// Which definition family a control word names, if any.
557enum DefKind {
558    Command,
559    Def,
560    Environment,
561    XparseCommand,
562    XparseEnvironment,
563    /// A package command whose defined environment has a *verbatim* body, a static
564    /// fact of the *defining command's identity* (not of any catcode signal in its
565    /// begin-code, which lives inside the package's own machinery): `listings`'
566    /// `\lstnewenvironment` and `fancyvrb`'s `\DefineVerbatimEnvironment`.
567    VerbatimEnvironment,
568}
569
570impl DefKind {
571    fn of(name: &str) -> Option<Self> {
572        Some(match name {
573            "newcommand" | "renewcommand" | "providecommand" | "DeclareRobustCommand" => {
574                DefKind::Command
575            }
576            // Plain TeX `\def` and its global/expanded variants. `\let` is excluded: it
577            // aliases an existing meaning rather than carrying a replacement body to scan.
578            "def" | "edef" | "gdef" | "xdef" => DefKind::Def,
579            "newenvironment" | "renewenvironment" => DefKind::Environment,
580            "NewDocumentCommand"
581            | "RenewDocumentCommand"
582            | "ProvideDocumentCommand"
583            | "DeclareDocumentCommand" => DefKind::XparseCommand,
584            "NewDocumentEnvironment"
585            | "RenewDocumentEnvironment"
586            | "ProvideDocumentEnvironment"
587            | "DeclareDocumentEnvironment" => DefKind::XparseEnvironment,
588            // `listings`/`fancyvrb` verbatim-environment definitions: the body is raw
589            // text, a fact of the defining command, not of any scannable catcode signal.
590            "lstnewenvironment" | "DefineVerbatimEnvironment" => DefKind::VerbatimEnvironment,
591            _ => return None,
592        })
593    }
594}
595
596/// `\newcommand{\name}[n][default]{body}` → a [`CommandSig`]. The name is the
597/// control word in the first group; `[n]` (if present) is the arg count, and a
598/// second optional `[default]` makes the first argument optional `[…]` while the
599/// rest are mandatory `{…}` — LaTeX2e's `\newcommand` shape. The unbraced
600/// `\newcommand\name[n]…` form is recovered the same way via [`resolve_command_def`].
601fn scan_newcommand(
602    command: &SyntaxNode,
603    db: &mut SignatureDb,
604    bodies: &mut HashMap<SmolStr, DefBody>,
605    aliases: &mut HashMap<SmolStr, EnvAliasCandidate>,
606) {
607    let Some(def) = resolve_command_def(command) else {
608        return;
609    };
610    let (arity, first_optional) = newcommand_arity(&def.host);
611    // The replacement body is the group right after the name: index `first_arg_group`
612    // on the host (group 1 for the braced form, group 0 for the unbraced sibling).
613    let body = nth_group(&def.host, def.first_arg_group);
614    record_body(bodies, &def.name, body.as_ref());
615    record_env_alias(aliases, &def.name, arity, body.as_ref());
616    // Trust gate: a `\secdef`/`\@startsection`-style body reads as arity 0 but really
617    // consumes a title, so don't let it downgrade a curated built-in (keep the overlay
618    // falling through to the built-in). See [`keeps_builtin_over_arity0`].
619    if bodies
620        .get(def.name.as_str())
621        .is_some_and(|body| keeps_builtin_over_arity0(&def.name, arity, body))
622    {
623        return;
624    }
625    db.insert_command(
626        def.name,
627        CommandSig {
628            args: latex2e_args(arity, first_optional).into(),
629            sectioning: None,
630            verbatim: false,
631            verbatim_delimited: false,
632            rule: false,
633            inline: false,
634            // Never inferred for a scanned definition: block-ness is
635            // undecidable without meaning, so scanned commands stay with the
636            // formatter's residual authored-break rule.
637            block: false,
638        },
639    );
640}
641
642/// `\def\name<param text>{body}` (and the `\edef`/`\gdef`/`\xdef` variants) → a
643/// [`CommandSig`]. `\def` has only the unbraced name form (TeX has no `\def{\name}`), so
644/// the name is the immediately-following sibling `COMMAND`. The arity comes from the
645/// **parameter text** (`#1#2…`) between the name and the body — counted by
646/// [`def_params_and_body`] — not from a `[n]` optional. We record the body for the same
647/// catcode-signal/helper-chain analysis as `\newcommand`, which is what lets a `\def`
648/// helper participate in chain resolution ([`reaches_signal`]).
649fn scan_def(
650    command: &SyntaxNode,
651    db: &mut SignatureDb,
652    bodies: &mut HashMap<SmolStr, DefBody>,
653    aliases: &mut HashMap<SmolStr, EnvAliasCandidate>,
654) {
655    let Some(name_node) = adjacent_sibling_command(command) else {
656        return;
657    };
658    let Some(name) = command_name(&name_node) else {
659        return;
660    };
661    let (arity, body) = def_params_and_body(&name_node);
662    record_body(bodies, &name, body.as_ref());
663    record_env_alias(aliases, &name, arity, body.as_ref());
664    // Trust gate: same as `scan_newcommand` — a delegating `\def\section{\secdef …}`
665    // must not downgrade a curated built-in. See [`keeps_builtin_over_arity0`].
666    if bodies
667        .get(name.as_str())
668        .is_some_and(|body| keeps_builtin_over_arity0(&name, arity, body))
669    {
670        return;
671    }
672    db.insert_command(
673        name,
674        CommandSig {
675            // `\def` parameters carry no brace/bracket distinction; model them as the same
676            // all-mandatory-brace shape scanned `\newcommand`s use. `apply_verbatim_flags`
677            // pops the final slot and sets `verbatim` if a catcode signal is reachable.
678            args: latex2e_args(arity, false).into(),
679            sectioning: None,
680            verbatim: false,
681            verbatim_delimited: false,
682            rule: false,
683            inline: false,
684            // Never inferred for a scanned definition: block-ness is
685            // undecidable without meaning, so scanned commands stay with the
686            // formatter's residual authored-break rule.
687            block: false,
688        },
689    );
690}
691
692/// The `(arity, body)` of a `\def`-style definition, reading its parameter text off the
693/// name `COMMAND` node. Two CST shapes arise under greedy attachment:
694/// - **No parameters** (`\def\foo{body}`): the body brace group attaches as `\foo`'s first
695///   child `GROUP`, so arity is `0` and the body is `nth_group(name_node, 0)`.
696/// - **With parameters** (`\def\foo#1#2{body}`): the leading `#` (`HASH`) breaks greedy
697///   attachment, so `\foo` has no child group and the `#1`, `#2`, and `{body}` are all
698///   siblings. Arity is the number of `HASH` tokens (each `#1` lexes as `HASH` + `WORD`)
699///   before the first sibling `GROUP`, which is the body.
700///
701/// Anything other than trivia/`HASH`/`WORD` before a group means delimited or malformed
702/// parameter text we do not model; we stop and report no body (so no catcode signal is
703/// recorded for it — the conservative choice). Arity is capped at 9 like `\newcommand`.
704fn def_params_and_body(name_node: &SyntaxNode) -> (usize, Option<SyntaxNode>) {
705    // No parameter text: the body attached greedily as the name command's first group.
706    if let Some(body) = nth_group(name_node, 0) {
707        return (0, Some(body));
708    }
709    // Parameter text intervened: count `#` markers up to the first sibling group (the body).
710    let mut arity = 0usize;
711    let mut next = name_node.next_sibling_or_token();
712    while let Some(element) = next {
713        match element {
714            NodeOrToken::Token(token) if is_trivia(token.kind()) => {
715                next = token.next_sibling_or_token();
716            }
717            NodeOrToken::Token(token) if token.kind() == SyntaxKind::HASH => {
718                arity += 1;
719                next = token.next_sibling_or_token();
720            }
721            // The digit following `#`, or a literal delimiter token in a delimited macro.
722            NodeOrToken::Token(token) if token.kind() == SyntaxKind::WORD => {
723                next = token.next_sibling_or_token();
724            }
725            NodeOrToken::Node(node) if node.kind() == SyntaxKind::GROUP => {
726                return (arity.min(9), Some(node));
727            }
728            _ => return (arity.min(9), None),
729        }
730    }
731    (arity.min(9), None)
732}
733
734/// Record the catcode/called-macro facts of a command definition's replacement
735/// `body` group (absent or unresolvable body → no signal, no calls).
736fn record_body(bodies: &mut HashMap<SmolStr, DefBody>, name: &str, body: Option<&SyntaxNode>) {
737    let text = body.map(group_inner_source).unwrap_or_default();
738    bodies.insert(
739        SmolStr::new(name),
740        DefBody {
741            signal: catcode_signal(&text),
742            called: called_macros(&text),
743        },
744    );
745}
746
747/// `\newenvironment{name}[n][default]{begin}{end}` → an [`EnvironmentSig`]. Same
748/// arg-count shape as [`scan_newcommand`]. The begin-code (group 1 — the optionals
749/// `[n][default]` are `OPTIONAL` nodes, so they don't shift `nth_group` indexing) is
750/// recorded so [`apply_verbatim_env_flags`] can flag a catcode-othering body verbatim.
751fn scan_newenvironment(
752    command: &SyntaxNode,
753    db: &mut SignatureDb,
754    env_bodies: &mut HashMap<SmolStr, DefBody>,
755) {
756    let Some(name) = nth_group_text(command, 0) else {
757        return;
758    };
759    let name = name.trim();
760    if name.is_empty() {
761        return;
762    }
763    record_body(env_bodies, name, nth_group(command, 1).as_ref());
764    let (arity, first_optional) = newcommand_arity(command);
765    db.insert_environment(name, environment_sig(latex2e_args(arity, first_optional)));
766}
767
768/// A `listings`/`fancyvrb` verbatim-environment definition → an [`EnvironmentSig`]
769/// with `verbatim_body`. Unlike [`scan_newenvironment`], the verbatim-ness is *not*
770/// read from a catcode signal in the begin-code — that machinery lives inside the
771/// package — but is implied by the defining command's identity, a bounded static fact
772/// (AGENTS.md decision #1). The name is the control-word-free text in the first group:
773/// - `\lstnewenvironment{name}[n][default]{begin}{end}` — the `[n][default]` optionals
774///   give the runtime argument shape, as in [`scan_newenvironment`].
775/// - `\DefineVerbatimEnvironment{name}{base}{opts}` — the environment takes one
776///   optional `[key=val]` argument at use time (`fancyvrb`'s `Verbatim` family).
777fn scan_verbatim_environment(defining_command: &str, command: &SyntaxNode, db: &mut SignatureDb) {
778    let Some(name) = nth_group_text(command, 0) else {
779        return;
780    };
781    let name = name.trim();
782    if name.is_empty() {
783        return;
784    }
785    let args = if defining_command == "lstnewenvironment" {
786        let (arity, first_optional) = newcommand_arity(command);
787        latex2e_args(arity, first_optional)
788    } else {
789        // `\DefineVerbatimEnvironment` → a single optional `[options]` slot.
790        latex2e_args(1, true)
791    };
792    let mut sig = environment_sig(args);
793    sig.verbatim_body = true;
794    db.insert_environment(name, sig);
795}
796
797/// `\NewDocumentCommand{\name}{spec}{body}` → a [`CommandSig`] with args from the
798/// xparse spec. The unbraced `\NewDocumentCommand\name{spec}…` form is recovered the
799/// same way via [`resolve_command_def`]; `first_arg_group` indexes the spec group on
800/// whichever node hosts the arguments.
801fn scan_xparse_command(
802    command: &SyntaxNode,
803    db: &mut SignatureDb,
804    bodies: &mut HashMap<SmolStr, DefBody>,
805    aliases: &mut HashMap<SmolStr, EnvAliasCandidate>,
806) {
807    let Some(def) = resolve_command_def(command) else {
808        return;
809    };
810    let Some(spec) = nth_group(&def.host, def.first_arg_group) else {
811        return;
812    };
813    // The body follows the spec group, so it sits one index further along.
814    let body = nth_group(&def.host, def.first_arg_group + 1);
815    record_body(bodies, &def.name, body.as_ref());
816    let args = xparse::parse_spec(&group_inner_source(&spec));
817    record_env_alias(aliases, &def.name, args.len(), body.as_ref());
818    db.insert_command(
819        def.name,
820        CommandSig {
821            args: args.into(),
822            sectioning: None,
823            verbatim: false,
824            verbatim_delimited: false,
825            rule: false,
826            inline: false,
827            // Never inferred for a scanned definition: block-ness is
828            // undecidable without meaning, so scanned commands stay with the
829            // formatter's residual authored-break rule.
830            block: false,
831        },
832    );
833}
834
835/// A resolved command definition: the defined `name`, the node whose attached
836/// `OPTIONAL`/`GROUP` children carry the argument shape (`host`), and the index of
837/// the first *signature* group on that host.
838///
839/// Two name forms collapse to this shape:
840/// - **Braced** `\newcommand{\foo}…`: the host is the definition command itself; its
841///   group 0 is the `{\foo}` name, so signature groups start at index `1`.
842/// - **Unbraced** `\newcommand\foo…`: greedy attachment makes `\foo` the next sibling
843///   `COMMAND` and hangs the `[n]`/`{body}` (or xparse spec) off *it*, so the host is
844///   that sibling and signature groups start at index `0`.
845struct CommandDef {
846    name: SmolStr,
847    host: SyntaxNode,
848    first_arg_group: usize,
849}
850
851/// Resolve `command` (a `\newcommand`/xparse definition) to its [`CommandDef`],
852/// handling both the braced and unbraced name forms. Returns `None` when no command
853/// name can be read (a malformed or empty definition) — the scan then skips it.
854fn resolve_command_def(command: &SyntaxNode) -> Option<CommandDef> {
855    // Braced `{\name}`: the name control word lives in the first group, and every
856    // attached group/optional hangs off the definition command itself.
857    if command.children().any(|c| c.kind() == SyntaxKind::GROUP) {
858        let name = nth_group(command, 0)
859            .as_ref()
860            .and_then(group_command_name)?;
861        return Some(CommandDef {
862            name,
863            host: command.clone(),
864            first_arg_group: 1,
865        });
866    }
867    // Unbraced `\newcommand\foo…`: read the name and signature groups off the
868    // following sibling `COMMAND` (decision #2 — a scanner heuristic, no parser
869    // change).
870    let sibling = adjacent_sibling_command(command)?;
871    let name = command_name(&sibling)?;
872    Some(CommandDef {
873        name,
874        host: sibling,
875        first_arg_group: 0,
876    })
877}
878
879/// The immediately-following sibling `COMMAND`, separated from `command` by trivia
880/// only. Returns `None` if any non-trivia element intervenes, so `\newcommand\foo`
881/// (and the spaced `\newcommand \foo`) bind, but `\newcommand stray text \bar` does
882/// not. A blank line cannot reach here: the `\par` break splits the two commands into
883/// separate `PARAGRAPH` parents, so there is no sibling to find.
884fn adjacent_sibling_command(command: &SyntaxNode) -> Option<SyntaxNode> {
885    let mut next = command.next_sibling_or_token();
886    while let Some(element) = next {
887        match element {
888            NodeOrToken::Token(token) if is_trivia(token.kind()) => {
889                next = token.next_sibling_or_token();
890            }
891            NodeOrToken::Node(node) if node.kind() == SyntaxKind::COMMAND => return Some(node),
892            _ => return None,
893        }
894    }
895    None
896}
897
898/// `\NewDocumentEnvironment{name}{spec}{begin}{end}` → an [`EnvironmentSig`] with
899/// args from the xparse spec. The begin-code (group 2 — after `{name}` and `{spec}`)
900/// is recorded for verbatim detection, as in [`scan_newenvironment`].
901fn scan_xparse_environment(
902    command: &SyntaxNode,
903    db: &mut SignatureDb,
904    env_bodies: &mut HashMap<SmolStr, DefBody>,
905) {
906    let Some(name) = nth_group_text(command, 0) else {
907        return;
908    };
909    let name = name.trim();
910    if name.is_empty() {
911        return;
912    }
913    let Some(spec) = nth_group(command, 1) else {
914        return;
915    };
916    record_body(env_bodies, name, nth_group(command, 2).as_ref());
917    db.insert_environment(
918        name,
919        environment_sig(xparse::parse_spec(&group_inner_source(&spec))),
920    );
921}
922
923/// The `(arity, first_arg_optional)` pair for a LaTeX2e definition: the integer in
924/// the first `[…]` optional (default `0`), and whether a *second* optional is
925/// present (which makes the first argument optional).
926fn newcommand_arity(command: &SyntaxNode) -> (usize, bool) {
927    let optionals: Vec<Optional> = children::<Optional>(command).collect();
928    let arity = optionals
929        .first()
930        .map(|o| o.syntax())
931        .and_then(optional_number)
932        .unwrap_or(0)
933        .min(9); // LaTeX caps macro arity at 9.
934    (arity, optionals.len() >= 2)
935}
936
937/// The integer inside an `OPTIONAL` node (`[2]` → `2`), or `None` if it isn't a
938/// bare number.
939fn optional_number(node: &SyntaxNode) -> Option<usize> {
940    let text = node.text().to_string();
941    let inner = text.strip_prefix('[').unwrap_or(&text);
942    let inner = inner.strip_suffix(']').unwrap_or(inner);
943    inner.trim().parse().ok()
944}
945
946/// Build the LaTeX2e argument slots: `arity` arguments, all mandatory `{…}` unless
947/// `first_optional`, in which case the first is optional `[…]`.
948fn latex2e_args(arity: usize, first_optional: bool) -> Vec<ArgSpec> {
949    (0..arity)
950        .map(|i| {
951            if i == 0 && first_optional {
952                ArgSpec {
953                    required: false,
954                    kind: ArgKind::Bracket,
955                    content: ContentKind::Opaque,
956                    domain: crate::semantic::ArgumentDomain::Unknown,
957                    verbatim: false,
958                }
959            } else {
960                ArgSpec {
961                    required: true,
962                    kind: ArgKind::Brace,
963                    content: ContentKind::Opaque,
964                    domain: crate::semantic::ArgumentDomain::Unknown,
965                    verbatim: false,
966                }
967            }
968        })
969        .collect()
970}
971
972/// An [`EnvironmentSig`] for a scanned environment with the given args: a
973/// reflowable, non-math, non-verbatim body (the only shape LaTeX2e/xparse
974/// definitions give us without package-specific knowledge).
975fn environment_sig(args: Vec<ArgSpec>) -> EnvironmentSig {
976    EnvironmentSig {
977        args: args.into(),
978        verbatim_body: false,
979        // The delimited-verbatim name argument is a curated l3doc fact; a
980        // scanned definition never earns it.
981        verbatim_arg: false,
982        math: false,
983        code: false,
984        // Statement-sequence layout is a curated fact about a package's own
985        // grammar (a TikZ `;`), invisible in a `\newenvironment` body.
986        statement_body: false,
987        // A source definition exposes no package-specific `label` key semantics.
988        label_key: false,
989        align: false,
990        no_indent: false,
991        // A user `\newenvironment` is not assumed to be a list; the built-in DB
992        // is the source of truth for `\item`-bearing list layout.
993        list: false,
994        // Block-ness of a user-defined environment is unknown without
995        // package-specific knowledge; default to non-block (the parser keeps the
996        // conservative `PARAGRAPH` wrapper for it).
997        block_explicit: false,
998        // A scanned user environment carries no outline category; only the curated
999        // built-in floats/theorem-likes show up in the document-symbol outline.
1000        outline: None,
1001    }
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007    use crate::parser::{parse, reconstruct};
1008
1009    fn db_of(src: &str) -> SignatureDb {
1010        assert_eq!(reconstruct(src), src, "reconstruct must round-trip");
1011        scan_definitions(&SyntaxNode::new_root(parse(src).green))
1012    }
1013
1014    fn arg_kinds(args: &[ArgSpec]) -> Vec<ArgKind> {
1015        args.iter().map(|a| a.kind).collect()
1016    }
1017
1018    #[test]
1019    fn newcommand_counts_mandatory_args() {
1020        let db = db_of("\\newcommand{\\foo}[2]{#1#2}\n");
1021        let sig = db.command("foo").expect("foo defined");
1022        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace, ArgKind::Brace]);
1023        assert!(sig.args.iter().all(|a| a.required));
1024        assert!(
1025            sig.args
1026                .iter()
1027                .all(|arg| arg.domain == crate::semantic::ArgumentDomain::Unknown)
1028        );
1029    }
1030
1031    #[test]
1032    fn newcommand_optional_first_arg() {
1033        let db = db_of("\\newcommand{\\foo}[2][d]{#1#2}\n");
1034        let sig = db.command("foo").expect("foo defined");
1035        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket, ArgKind::Brace]);
1036        assert!(!sig.args[0].required);
1037        assert!(sig.args[1].required);
1038    }
1039
1040    #[test]
1041    fn newcommand_zero_args() {
1042        let db = db_of("\\newcommand{\\foo}{bar}\n");
1043        assert!(db.command("foo").expect("foo defined").args.is_empty());
1044    }
1045
1046    #[test]
1047    fn renew_and_provide_recognized() {
1048        let db = db_of("\\renewcommand{\\a}[1]{x}\\providecommand{\\b}[1]{y}\n");
1049        assert_eq!(db.command("a").unwrap().args.len(), 1);
1050        assert_eq!(db.command("b").unwrap().args.len(), 1);
1051    }
1052
1053    #[test]
1054    fn secdef_redefinition_keeps_builtin_prose() {
1055        let db = db_of("\\renewcommand{\\section}{\\secdef \\a \\b}\n");
1056        assert!(
1057            db.command("section").is_none(),
1058            "the delegating redefinition must not be recorded as a scanned override"
1059        );
1060        let sigs = crate::semantic::signature::Signatures::new(&db);
1061        let sig = sigs.command("section").expect("built-in section survives");
1062        let last = sig.args.last().expect("section keeps its title argument");
1063        assert_eq!(
1064            last.content,
1065            crate::semantic::signature::ContentKind::Prose,
1066            "the title argument stays prose (reflowable)"
1067        );
1068    }
1069
1070    #[test]
1071    fn genuine_zero_arg_redefinition_downgrades_builtin() {
1072        let db = db_of("\\renewcommand{\\section}{\\textbf{Fixed}}\n");
1073        let sig = db
1074            .command("section")
1075            .expect("genuine 0-arg redefinition is recorded");
1076        assert!(
1077            sig.args.is_empty(),
1078            "no delegation means the scanned 0-arg signature wins"
1079        );
1080    }
1081
1082    #[test]
1083    fn secdef_redefinition_of_unknown_still_records() {
1084        let db = db_of("\\renewcommand{\\mysec}{\\secdef \\a \\b}\n");
1085        let sig = db.command("mysec").expect("unknown name is still recorded");
1086        assert!(sig.args.is_empty());
1087    }
1088
1089    #[test]
1090    fn newenvironment_args() {
1091        let db = db_of("\\newenvironment{thm}[1]{begin #1}{end}\n");
1092        let sig = db.environment("thm").expect("thm defined");
1093        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace]);
1094        assert!(sig.reflow());
1095        assert!(!sig.verbatim_body);
1096        assert!(!sig.math);
1097    }
1098
1099    #[test]
1100    fn xparse_command_spec() {
1101        let db = db_of("\\NewDocumentCommand{\\foo}{m O{d} m}{x}\n");
1102        let sig = db.command("foo").expect("foo defined");
1103        assert_eq!(
1104            arg_kinds(&sig.args),
1105            vec![ArgKind::Brace, ArgKind::Bracket, ArgKind::Brace]
1106        );
1107        assert!(
1108            sig.args
1109                .iter()
1110                .all(|arg| arg.domain == crate::semantic::ArgumentDomain::Unknown)
1111        );
1112    }
1113
1114    #[test]
1115    fn xparse_environment_spec() {
1116        let db = db_of("\\NewDocumentEnvironment{env}{O{x} m}{a}{b}\n");
1117        let sig = db.environment("env").expect("env defined");
1118        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket, ArgKind::Brace]);
1119        assert!(
1120            sig.args
1121                .iter()
1122                .all(|arg| arg.domain == crate::semantic::ArgumentDomain::Unknown)
1123        );
1124    }
1125
1126    #[test]
1127    fn unbraced_newcommand_extracted() {
1128        let db = db_of("\\newcommand\\foo[2]{#1#2}\n");
1129        let sig = db.command("foo").expect("foo defined");
1130        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace, ArgKind::Brace]);
1131        assert!(sig.args.iter().all(|a| a.required));
1132    }
1133
1134    #[test]
1135    fn unbraced_optional_first_arg() {
1136        let db = db_of("\\newcommand\\foo[2][d]{#1#2}\n");
1137        let sig = db.command("foo").expect("foo defined");
1138        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket, ArgKind::Brace]);
1139        assert!(!sig.args[0].required);
1140        assert!(sig.args[1].required);
1141    }
1142
1143    #[test]
1144    fn unbraced_zero_args() {
1145        let db = db_of("\\newcommand\\foo{x}\n");
1146        assert!(db.command("foo").expect("foo defined").args.is_empty());
1147    }
1148
1149    #[test]
1150    fn unbraced_spaced_binds() {
1151        let db = db_of("\\newcommand \\foo[1]{x}\n");
1152        assert_eq!(db.command("foo").unwrap().args.len(), 1);
1153    }
1154
1155    #[test]
1156    fn unbraced_renewcommand() {
1157        let db = db_of("\\renewcommand\\foo[1]{x}\n");
1158        assert_eq!(db.command("foo").unwrap().args.len(), 1);
1159    }
1160
1161    #[test]
1162    fn unbraced_xparse_command() {
1163        let db = db_of("\\NewDocumentCommand\\foo{m O{d} m}{x}\n");
1164        let sig = db.command("foo").expect("foo defined");
1165        assert_eq!(
1166            arg_kinds(&sig.args),
1167            vec![ArgKind::Brace, ArgKind::Bracket, ArgKind::Brace]
1168        );
1169    }
1170
1171    #[test]
1172    fn unbraced_stray_text_not_bound() {
1173        let db = db_of("\\newcommand foo \\bar{x}\n");
1174        assert!(db.command("foo").is_none());
1175        assert!(db.command("bar").is_none());
1176    }
1177
1178    #[test]
1179    fn redefinition_last_wins() {
1180        let db = db_of("\\newcommand{\\foo}[1]{x}\\renewcommand{\\foo}[3]{y}\n");
1181        assert_eq!(db.command("foo").unwrap().args.len(), 3);
1182    }
1183
1184    #[test]
1185    fn garbage_definition_degrades_to_no_insert() {
1186        let db = db_of("\\newcommand\n");
1187        assert!(db.command("foo").is_none());
1188    }
1189
1190    #[test]
1191    fn nested_definition_collected() {
1192        let db = db_of("\\begin{document}\n\\newcommand{\\foo}[1]{x}\n\\end{document}\n");
1193        assert_eq!(db.command("foo").unwrap().args.len(), 1);
1194    }
1195
1196    #[test]
1197    fn commented_definition_ignored() {
1198        let db = db_of("% \\newcommand{\\foo}[1]{x}\n");
1199        assert!(db.command("foo").is_none());
1200    }
1201
1202    #[test]
1203    fn verbatim_makeother_flagged() {
1204        let db = db_of("\\newcommand\\shellcmd[1]{\\@makeother\\$#1}\n");
1205        let sig = db.command("shellcmd").expect("shellcmd defined");
1206        assert!(sig.verbatim);
1207        assert!(sig.args.is_empty());
1208    }
1209
1210    #[test]
1211    fn verbatim_catcode_flagged() {
1212        let db = db_of("\\newcommand\\shellcmd[1]{\\catcode 36=12 #1}\n");
1213        assert!(db.command("shellcmd").expect("shellcmd defined").verbatim);
1214    }
1215
1216    #[test]
1217    fn unrelated_twelve_does_not_flag_catcode_assignment() {
1218        let db = db_of("\\newcommand\\ordinary[1]{\\catcode 36=\\active \\hspace{12pt}#1}\n");
1219        assert!(!db.command("ordinary").expect("ordinary defined").verbatim);
1220    }
1221
1222    #[test]
1223    fn verbatim_dospecials_flagged() {
1224        let db = db_of("\\newcommand\\shellcmd[1]{\\let\\do\\@makeother\\dospecials #1}\n");
1225        assert!(db.command("shellcmd").expect("shellcmd defined").verbatim);
1226    }
1227
1228    #[test]
1229    fn verbatim_keeps_leading_args() {
1230        let db = db_of("\\newcommand\\mycode[2]{\\@makeother\\$#1#2}\n");
1231        let sig = db.command("mycode").expect("mycode defined");
1232        assert!(sig.verbatim);
1233        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace]);
1234    }
1235
1236    #[test]
1237    fn verbatim_via_chained_helper() {
1238        let db =
1239            db_of("\\newcommand\\setup{\\@makeother\\$}\\newcommand\\shellcmd[1]{\\setup#1}\n");
1240        assert!(db.command("shellcmd").expect("shellcmd defined").verbatim);
1241        assert!(!db.command("setup").expect("setup defined").verbatim);
1242    }
1243
1244    #[test]
1245    fn verbatim_chain_cycle_terminates() {
1246        let db = db_of("\\newcommand\\a[1]{\\b#1}\\newcommand\\b[1]{\\a#1}\n");
1247        assert!(!db.command("a").expect("a defined").verbatim);
1248        assert!(!db.command("b").expect("b defined").verbatim);
1249    }
1250
1251    #[test]
1252    fn ordinary_command_not_verbatim() {
1253        let db = db_of("\\newcommand\\foo[1]{\\emph{#1}}\n");
1254        assert!(!db.command("foo").expect("foo defined").verbatim);
1255    }
1256
1257    #[test]
1258    fn verbatim_needs_an_argument() {
1259        let db = db_of("\\newcommand\\setup{\\@makeother\\$}\n");
1260        assert!(!db.command("setup").expect("setup defined").verbatim);
1261    }
1262
1263    #[test]
1264    fn def_helper_chain_followed() {
1265        let db = db_of("\\def\\setup{\\@makeother\\$}\\newcommand\\shellcmd[1]{\\setup#1}\n");
1266        assert!(db.command("shellcmd").expect("shellcmd defined").verbatim);
1267        assert!(!db.command("setup").expect("setup defined").verbatim);
1268    }
1269
1270    #[test]
1271    fn def_direct_verbatim_flagged() {
1272        let db = db_of("\\def\\shellcmd#1{\\@makeother\\$#1}\n");
1273        let sig = db.command("shellcmd").expect("shellcmd defined");
1274        assert!(sig.verbatim);
1275        assert!(sig.args.is_empty());
1276    }
1277
1278    #[test]
1279    fn def_zero_params() {
1280        let db = db_of("\\def\\foo{x}\n");
1281        let sig = db.command("foo").expect("foo defined");
1282        assert!(sig.args.is_empty());
1283        assert!(!sig.verbatim);
1284    }
1285
1286    #[test]
1287    fn def_counts_params() {
1288        let db = db_of("\\def\\foo#1#2{#1#2}\n");
1289        let sig = db.command("foo").expect("foo defined");
1290        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace, ArgKind::Brace]);
1291    }
1292
1293    #[test]
1294    fn def_variants_scanned() {
1295        let db = db_of("\\edef\\a#1{x}\\gdef\\b{y}\\xdef\\c#1{\\@makeother\\$#1}\n");
1296        assert_eq!(db.command("a").expect("a defined").args.len(), 1);
1297        assert!(db.command("b").expect("b defined").args.is_empty());
1298        let c = db.command("c").expect("c defined");
1299        assert!(c.verbatim);
1300        assert!(c.args.is_empty());
1301    }
1302
1303    #[test]
1304    fn def_chain_through_def_helpers() {
1305        let db = db_of(
1306            "\\def\\inner{\\@makeother\\$}\\def\\outer{\\inner}\\newcommand\\cmd[1]{\\outer#1}\n",
1307        );
1308        assert!(db.command("cmd").expect("cmd defined").verbatim);
1309    }
1310
1311    #[test]
1312    fn verbatim_xparse_flagged() {
1313        let db = db_of("\\NewDocumentCommand\\shellcmd{m}{\\@makeother\\$#1}\n");
1314        let sig = db.command("shellcmd").expect("shellcmd defined");
1315        assert!(sig.verbatim);
1316        assert!(sig.args.is_empty());
1317    }
1318
1319    #[test]
1320    fn env_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        let db = db_of("\\newenvironment{shellenv}[1]{\\catcode 36=12 }{}\n");
1330        let sig = db.environment("shellenv").expect("shellenv defined");
1331        assert!(sig.verbatim_body);
1332        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Brace]);
1333    }
1334
1335    #[test]
1336    fn env_via_chained_helper() {
1337        let db =
1338            db_of("\\newcommand\\setup{\\@makeother\\$}\\newenvironment{shellenv}{\\setup}{}\n");
1339        assert!(
1340            db.environment("shellenv")
1341                .expect("shellenv defined")
1342                .verbatim_body
1343        );
1344    }
1345
1346    #[test]
1347    fn env_without_signal_not_flagged() {
1348        let db = db_of("\\newenvironment{remark}{\\par\\noindent\\textbf{Remark.}}{\\par}\n");
1349        let sig = db.environment("remark").expect("remark defined");
1350        assert!(!sig.verbatim_body);
1351        assert!(sig.reflow());
1352    }
1353
1354    #[test]
1355    fn lstnewenvironment_flagged_verbatim() {
1356        let db = db_of("\\lstnewenvironment{demo}[1][code]{\\lstset{#1}}{}\n");
1357        let sig = db.environment("demo").expect("demo defined");
1358        assert!(sig.verbatim_body);
1359        assert!(!sig.reflow());
1360        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket]);
1361    }
1362
1363    #[test]
1364    fn lstnewenvironment_no_args_flagged_verbatim() {
1365        let db = db_of("\\lstnewenvironment{demo}{}{}\n");
1366        let sig = db.environment("demo").expect("demo defined");
1367        assert!(sig.verbatim_body);
1368        assert!(sig.args.is_empty());
1369    }
1370
1371    #[test]
1372    fn defineverbatimenvironment_flagged_verbatim() {
1373        let db = db_of("\\DefineVerbatimEnvironment{code}{Verbatim}{fontsize=\\small}\n");
1374        let sig = db.environment("code").expect("code defined");
1375        assert!(sig.verbatim_body);
1376        assert!(!sig.reflow());
1377        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket]);
1378    }
1379
1380    #[test]
1381    fn xparse_env_makeother_flagged() {
1382        let db = db_of("\\NewDocumentEnvironment{shellenv}{O{x}}{\\dospecials}{}\n");
1383        let sig = db.environment("shellenv").expect("shellenv defined");
1384        assert!(sig.verbatim_body);
1385        assert_eq!(arg_kinds(&sig.args), vec![ArgKind::Bracket]);
1386    }
1387
1388    fn sites_of(src: &str) -> Vec<DefSite> {
1389        assert_eq!(reconstruct(src), src, "reconstruct must round-trip");
1390        scan_definition_sites(&SyntaxNode::new_root(parse(src).green))
1391    }
1392
1393    #[test]
1394    fn def_site_newcommand_braced_name_span() {
1395        let src = "\\newcommand{\\foo}[1]{#1}\n";
1396        let sites = sites_of(src);
1397        assert_eq!(sites.len(), 1);
1398        let site = &sites[0];
1399        assert_eq!(site.name, "foo");
1400        assert_eq!(site.kind, DefSiteKind::Command);
1401        assert_eq!(&src[site.name_range], "\\foo");
1402        assert_eq!(&src[site.range], "\\newcommand{\\foo}[1]{#1}");
1403    }
1404
1405    #[test]
1406    fn def_site_newcommand_unbraced_name_span() {
1407        let src = "\\newcommand\\foo[1]{#1}\n";
1408        let sites = sites_of(src);
1409        assert_eq!(sites.len(), 1);
1410        assert_eq!(sites[0].name, "foo");
1411        assert_eq!(&src[sites[0].name_range], "\\foo");
1412        assert_eq!(&src[sites[0].range], "\\newcommand\\foo[1]{#1}");
1413    }
1414
1415    #[test]
1416    fn def_site_def_sibling_name_span() {
1417        let src = "\\def\\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!(sites[0].kind, DefSiteKind::Command);
1422        assert_eq!(&src[sites[0].name_range], "\\foo");
1423    }
1424
1425    #[test]
1426    fn def_site_xparse_command_name_span() {
1427        let src = "\\NewDocumentCommand{\\foo}{m O{d}}{x}\n";
1428        let sites = sites_of(src);
1429        assert_eq!(sites.len(), 1);
1430        assert_eq!(sites[0].name, "foo");
1431        assert_eq!(&src[sites[0].name_range], "\\foo");
1432    }
1433
1434    #[test]
1435    fn def_site_newenvironment_name_span() {
1436        let src = "\\newenvironment{myenv}{begin}{end}\n";
1437        let sites = sites_of(src);
1438        assert_eq!(sites.len(), 1);
1439        let site = &sites[0];
1440        assert_eq!(site.name, "myenv");
1441        assert_eq!(site.kind, DefSiteKind::Environment);
1442        assert_eq!(&src[site.name_range], "myenv");
1443    }
1444
1445    #[test]
1446    fn def_site_xparse_environment_name_span() {
1447        let src = "\\NewDocumentEnvironment{myenv}{m}{a}{b}\n";
1448        let sites = sites_of(src);
1449        assert_eq!(sites.len(), 1);
1450        assert_eq!(sites[0].name, "myenv");
1451        assert_eq!(sites[0].kind, DefSiteKind::Environment);
1452        assert_eq!(&src[sites[0].name_range], "myenv");
1453    }
1454
1455    #[test]
1456    fn def_site_keeps_every_redefinition() {
1457        let src = "\\newcommand{\\foo}{a}\n\\renewcommand{\\foo}{b}\n";
1458        let sites = sites_of(src);
1459        assert_eq!(sites.len(), 2);
1460        assert!(sites.iter().all(|s| s.name == "foo"));
1461        assert!(sites[0].name_range.start() < sites[1].name_range.start());
1462    }
1463
1464    #[test]
1465    fn def_site_none_for_malformed() {
1466        assert!(sites_of("\\newcommand\n").is_empty());
1467        assert!(sites_of("\\newenvironment{}{a}{b}\n").is_empty());
1468    }
1469
1470    const EQNARRAY_PAIR: &str =
1471        "\\newcommand{\\bea}{\\begin{eqnarray}}\n\\newcommand{\\eea}{\\end{eqnarray}}\n";
1472
1473    #[test]
1474    fn newcommand_env_alias_pair_is_recorded() {
1475        let db = db_of(EQNARRAY_PAIR);
1476        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1477        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1478        assert_eq!(db.env_begin_alias("eea"), None);
1479        assert_eq!(db.env_end_alias("bea"), None);
1480    }
1481
1482    #[test]
1483    fn def_env_alias_pair_is_recorded() {
1484        let db = db_of("\\def\\bea{\\begin{eqnarray}}\n\\def\\eea{\\end{eqnarray}}\n");
1485        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1486        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1487    }
1488
1489    #[test]
1490    fn xparse_env_alias_pair_is_recorded() {
1491        let db = db_of(
1492            "\\NewDocumentCommand{\\bea}{}{\\begin{eqnarray}}\n\
1493             \\NewDocumentCommand{\\eea}{}{\\end{eqnarray}}\n",
1494        );
1495        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1496        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1497    }
1498
1499    #[test]
1500    fn env_alias_body_tolerates_trivia() {
1501        let db = db_of(
1502            "\\newcommand{\\bea}{ \\begin{eqnarray} }\n\\newcommand{\\eea}{% why\n\\end{eqnarray}}\n",
1503        );
1504        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1505        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1506    }
1507
1508    #[test]
1509    fn a_lone_env_alias_half_is_recorded() {
1510        let db = db_of("\\newcommand{\\bea}{\\begin{eqnarray}}\n");
1511        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
1512        assert_eq!(db.env_end_alias("bea"), None);
1513
1514        let db = db_of("\\newcommand{\\eea}{\\end{eqnarray}}\n");
1515        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
1516        assert_eq!(db.env_begin_alias("eea"), None);
1517    }
1518
1519    #[test]
1520    fn a_lone_half_still_obeys_every_target_rule() {
1521        assert_eq!(
1522            db_of("\\newcommand{\\bmy}{\\begin{notacuratedenv}}\n").env_begin_alias("bmy"),
1523            None
1524        );
1525        assert_eq!(
1526            db_of("\\newcommand{\\ev}{\\end{verbatim}}\n").env_end_alias("ev"),
1527            None
1528        );
1529        assert_eq!(
1530            db_of("\\newcommand{\\bt}{\\begin{tabular}}\n").env_begin_alias("bt"),
1531            None
1532        );
1533        assert_eq!(
1534            db_of("\\newcommand{\\bea}[1]{\\begin{eqnarray}}\n").env_begin_alias("bea"),
1535            None
1536        );
1537    }
1538
1539    #[test]
1540    fn env_alias_rejects_uncurated_target() {
1541        let db = db_of(
1542            "\\newcommand{\\bmy}{\\begin{notacuratedenv}}\n\
1543             \\newcommand{\\emy}{\\end{notacuratedenv}}\n",
1544        );
1545        assert_eq!(db.env_begin_alias("bmy"), None);
1546        assert_eq!(db.env_end_alias("emy"), None);
1547    }
1548
1549    #[test]
1550    fn env_alias_rejects_verbatim_target() {
1551        let db =
1552            db_of("\\newcommand{\\bv}{\\begin{verbatim}}\n\\newcommand{\\ev}{\\end{verbatim}}\n");
1553        assert_eq!(db.env_begin_alias("bv"), None);
1554    }
1555
1556    #[test]
1557    fn env_alias_rejects_argument_taking_target() {
1558        let db =
1559            db_of("\\newcommand{\\bt}{\\begin{tabular}}\n\\newcommand{\\et}{\\end{tabular}}\n");
1560        assert_eq!(db.env_begin_alias("bt"), None);
1561    }
1562
1563    #[test]
1564    fn env_alias_rejects_parameterized_definition() {
1565        let db = db_of(
1566            "\\newcommand{\\bt}[1]{\\begin{tabular}{#1}}\n\\newcommand{\\et}{\\end{tabular}}\n",
1567        );
1568        assert_eq!(db.env_begin_alias("bt"), None);
1569    }
1570
1571    #[test]
1572    fn env_alias_rejects_body_with_extra_content() {
1573        let db = db_of(
1574            "\\newcommand{\\bea}{\\begin{eqnarray}\\label{x}}\n\\newcommand{\\eea}{\\end{eqnarray}}\n",
1575        );
1576        assert_eq!(db.env_begin_alias("bea"), None);
1577    }
1578
1579    #[test]
1580    fn redefinition_retracts_an_earlier_env_alias() {
1581        let db = db_of(
1582            "\\newcommand{\\bea}{\\begin{eqnarray}}\n\
1583             \\newcommand{\\eea}{\\end{eqnarray}}\n\
1584             \\renewcommand{\\bea}{\\textbf}\n",
1585        );
1586        assert_eq!(db.env_begin_alias("bea"), None);
1587    }
1588
1589    #[test]
1590    fn env_alias_does_not_pollute_the_environment_namespace() {
1591        let db = db_of(EQNARRAY_PAIR);
1592        assert!(db.environment("bea").is_none());
1593        assert!(!db.environment_names().any(|n| n == "bea"));
1594    }
1595
1596    fn environment_node(src: &str) -> SyntaxNode {
1597        SyntaxNode::new_root(parse(src).green)
1598            .descendants()
1599            .find(|n| n.kind() == SyntaxKind::ENVIRONMENT)
1600            .expect("an environment")
1601    }
1602
1603    #[test]
1604    fn signatures_resolves_an_env_alias_to_the_curated_target() {
1605        use crate::semantic::signature::Signatures;
1606        let src = format!("{EQNARRAY_PAIR}\\bea a \\eea\n");
1607        let db = db_of(&src);
1608        let sigs = Signatures::new(&db);
1609        let sig = sigs
1610            .environment_at(&environment_node(&src))
1611            .expect("alias resolves");
1612        let target = builtin().environment("eqnarray").expect("curated target");
1613        assert_eq!(sig, target);
1614        assert!(sig.math && sig.align);
1615        assert!(sigs.environment("bea").is_none());
1616    }
1617
1618    #[test]
1619    fn a_literal_begin_does_not_inherit_the_alias_target() {
1620        use crate::semantic::signature::Signatures;
1621        let src = format!("{EQNARRAY_PAIR}\\begin{{bea}} a \\end{{bea}}\n");
1622        let db = db_of(&src);
1623        let sigs = Signatures::new(&db);
1624        assert!(sigs.environment_at(&environment_node(&src)).is_none());
1625    }
1626
1627    #[test]
1628    fn a_real_environment_wins_over_an_alias_of_the_same_name() {
1629        use crate::semantic::signature::Signatures;
1630        let defs = "\\newcommand{\\bea}{\\begin{eqnarray}}\n\
1631             \\newcommand{\\eea}{\\end{eqnarray}}\n\
1632             \\newenvironment{bea}{x}{y}\n";
1633        let db = db_of(&format!("{defs}\\begin{{bea}} a \\end{{bea}}\n"));
1634        let sigs = Signatures::new(&db);
1635        let sig = sigs
1636            .environment_at(&environment_node(&format!(
1637                "{defs}\\begin{{bea}} a \\end{{bea}}\n"
1638            )))
1639            .expect("real environment resolves");
1640        assert!(
1641            !sig.math,
1642            "the scanned \\newenvironment must win for a literal `\\begin{{bea}}`"
1643        );
1644        assert!(
1645            sigs.environment_at(&environment_node(&format!("{defs}\\bea a \\eea\n")))
1646                .is_some_and(|sig| sig.math),
1647            "while the alias delimiters still resolve to eqnarray"
1648        );
1649    }
1650
1651    #[test]
1652    fn merge_from_carries_env_aliases() {
1653        let mut target = SignatureDb::default();
1654        target.merge_from(&db_of(EQNARRAY_PAIR), None);
1655        assert_eq!(target.env_begin_alias("bea"), Some("eqnarray"));
1656        assert_eq!(target.env_end_alias("eea"), Some("eqnarray"));
1657    }
1658}