Skip to main content

badness_parser/semantic/
define.rs

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