Skip to main content

badness_parser/semantic/
signature.rs

1//! The built-in **signature database**: command/environment argument shapes plus
2//! the semantic metadata a formatter/linter needs (sectioning level,
3//! verbatim-ness, math-ness). Meaning is assigned here rather than in the parser.
4//!
5//! The data is fully static, so it lives in a process-wide [`LazyLock`],
6//! consulted directly. Per-file `\newcommand`/`\newenvironment`/xparse
7//! signatures are scanned by [`super::define`] into a separate, per-document
8//! [`SignatureDb`] and overlaid via [`Signatures`] (scanned-first, built-in
9//! fallback). The greedy parser's argument attachment is unaffected either way.
10//!
11//! ## Source of truth: one granular JSON file
12//!
13//! The built-in data is a single curated JSON file (`data/signatures.json`,
14//! [`include_str!`]-ed, [`serde`]-deserialized) holding *all* the metadata in one
15//! typed place — argument shapes *and* sectioning level / verbatim-ness /
16//! math-ness together, keyed by name. This is the high-precision tier we maintain
17//! by hand.
18//!
19//! Lower-precision external sources layer *underneath* this, ingested into the
20//! same schema rather than replacing it. The TeXstudio/Kile **CWL corpus** is one
21//! such tier: a
22//! converter (`scripts/gen_cwl_signatures.py`) harvests command/environment names
23//! and argument shapes from a curated package subset into `data/cwl_signatures.json`,
24//! exposed by [`cwl`] and consulted *under* [`builtin`]. CWL is an import format,
25//! never the source of truth: only names and arity cross over (every behavior flag
26//! stays default), so it widens completion and arity coverage without its
27//! low-confidence data reaching a lexer/formatter/outline behavior decision. The
28//! file is compiled into a `phf` perfect-hash map at build time (`build.rs`) and
29//! `include!`-ed as read-only statics — zero runtime parse or decompress.
30
31use std::borrow::Cow;
32use std::collections::HashMap;
33use std::sync::LazyLock;
34
35use serde::Deserialize;
36use smol_str::SmolStr;
37
38use crate::syntax::{SyntaxKind, SyntaxNode};
39
40/// Which bracket delimits an argument. TeX has no other real argument grouping at
41/// the surface level the formatter cares about.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum ArgKind {
44    /// An argument delimited by `{…}`.
45    Brace,
46    /// An argument delimited by `[…]`.
47    Bracket,
48}
49
50/// The TeX mode a command or environment argument is proven to establish.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum ArgumentDomain {
53    /// No safe text-or-math claim is available.
54    #[default]
55    Unknown,
56    /// The argument is parsed and interpreted as math.
57    Math,
58    /// The argument is parsed and interpreted as text.
59    Text,
60}
61
62/// How the formatter treats an argument's *content* — its whitespace and break
63/// policy. This metadata is for formatter consumers; the parser ignores it.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
65pub enum ContentKind {
66    /// Left exactly as authored: names, identifiers, code, or option lists
67    /// (`\label`, the `\newcommand` body). The default, so an unmarked argument
68    /// never reflows — for most arguments interior whitespace can matter (a
69    /// `minipage`/`\parbox` body, a label key).
70    #[default]
71    Opaque,
72    /// Running prose the formatter may reflow to the line width (e.g. a
73    /// `\footnote`/`\caption` body, a sectioning title).
74    Prose,
75    /// A comma-separated token list whose interior whitespace is *insignificant*,
76    /// so the formatter may collapse a multi-line authored form to a single line
77    /// (a `\citep`/`\cite` key list). Unlike [`Prose`](ContentKind::Prose), the
78    /// content participates in the surrounding paragraph fill only at its
79    /// top-level commas, so an over-width citation can wrap between keys without
80    /// splitting a key or detaching its delimiters. Incidental source line breaks
81    /// are normalized away, so `\citep{\n a,\n b\n}` formats identically to
82    /// `\citep{a, b}` (determinism).
83    TokenList,
84    /// A `key=value` list consumed by a keyval-family processor — keyval, xkeyval,
85    /// pgfkeys, l3keys, or LaTeX's own option-list scanner — every one of which
86    /// strips spaces around keys and values before acting on them. That is what
87    /// licenses the formatter to break the list at a comma the author *glued*
88    /// (`[xmin=-5,xmax=5]`), materializing a space token TeX will see: in a keyval
89    /// argument the space is discarded, so the typeset output cannot change.
90    ///
91    /// The distinction is load-bearing and was settled by compiling both spellings:
92    /// keyval brackets (`\usepackage`, `\includegraphics`, `tikz`/`pgfplots`,
93    /// `lstlisting`) render identically, while *textual* optionals do not
94    /// (`\item[red,green]`, `\caption[short,list]`, `\cite[see,also]`, and a
95    /// `\newcommand` default all gain a visible space). So this flag must never be
96    /// set on an argument whose content is typeset — hold it to the same curated
97    /// standard as the math-env routing.
98    ///
99    /// Unlike [`TokenList`](ContentKind::TokenList), which flows inline with its
100    /// surrounding paragraph, a keyval list expands as its own delimited group.
101    Keyval,
102}
103
104/// How a citation command participates in sentence grammar. This is a curated
105/// formatter fact, independent of the command's key-list argument shape:
106/// `\textcite` and `\citet` are grammatical text, `\parencite` and `\citep`
107/// are postpositive citations, while plain `\cite` changes behavior across
108/// LaTeX citation systems and must follow the author's source layout.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum CitationPlacement {
111    /// The command is integrated into prose and may begin a sentence.
112    Textual,
113    /// The command cites the preceding sentence or clause.
114    Postpositive,
115    /// The command's placement depends on the active package or citation style.
116    Ambiguous,
117}
118
119/// One argument slot in a command/environment signature.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub struct ArgSpec {
122    /// Whether the argument must be present, independently of its delimiter kind.
123    pub required: bool,
124    pub kind: ArgKind,
125    /// How the formatter treats this argument's content. See [`ContentKind`].
126    pub content: ContentKind,
127    /// The mode established by this positional argument, independently of
128    /// [`ContentKind`].
129    pub domain: ArgumentDomain,
130    /// Whether this braced argument is read under a curated raw-text lexer mode.
131    /// Independent of formatter [`ContentKind`] and text-or-math
132    /// [`ArgumentDomain`].
133    pub verbatim: bool,
134}
135
136/// Match an attached group to the next positional signature slot.
137///
138/// Omitted optional slots are skipped regardless of their delimiter kind. A
139/// mismatched group never consumes a pending required slot, and an unmatched
140/// group leaves `slot` at that required slot.
141pub fn match_arg_slot(args: &[ArgSpec], slot: &mut usize, kind: ArgKind) -> Option<ArgSpec> {
142    match_arg_slot_index(args, slot, kind).map(|index| args[index])
143}
144
145/// The index-returning form of [`match_arg_slot`], used by signature help.
146pub fn match_arg_slot_index(args: &[ArgSpec], slot: &mut usize, kind: ArgKind) -> Option<usize> {
147    while *slot < args.len() {
148        let index = *slot;
149        let spec = args[index];
150        if spec.kind == kind {
151            *slot += 1;
152            return Some(index);
153        }
154        if !spec.required {
155            *slot += 1;
156            continue;
157        }
158        return None;
159    }
160    None
161}
162
163/// Match an attached raw `VERB` token to the next positional verbatim slot.
164/// Existing whole-command captures (`\url`, `\lstinline`, …) are implicit and
165/// therefore match no slot.
166pub fn match_verbatim_arg_slot(args: &[ArgSpec], slot: &mut usize) -> Option<ArgSpec> {
167    while *slot < args.len() {
168        let spec = args[*slot];
169        if spec.verbatim {
170            *slot += 1;
171            return Some(spec);
172        }
173        if !spec.required {
174            *slot += 1;
175            continue;
176        }
177        return None;
178    }
179    None
180}
181
182/// The signature of a control sequence.
183#[derive(Debug, Clone, PartialEq, Eq, Default)]
184pub struct CommandSig {
185    /// The ordered argument slots. A [`Cow`] so the build-time CWL tier can hold a
186    /// `'static` slice baked into the binary (see [`command`]) while the runtime
187    /// builtin/scanned paths own a `Vec`.
188    pub args: Cow<'static, [ArgSpec]>,
189    /// `Some(level)` for a sectioning command, where `0` is the outermost
190    /// (`\part`) and larger numbers nest deeper. Relative depth only.
191    pub sectioning: Option<u8>,
192    /// `true` for commands whose final argument is raw text the formatter must
193    /// not reshape (`\verb`, `\lstinline`, `\url`, `\code`). The lexer captures
194    /// that argument as one `VERB` token. Any leading, non-verbatim arguments
195    /// (e.g. `\mintinline`'s language) are declared in `args`; the verbatim
196    /// argument itself is implicit and not listed there.
197    pub verbatim: bool,
198    /// `true` when the verbatim argument may also be a `\verb`-style delimiter
199    /// run (`\lstinline|…|`, `\url|…|`) instead of a balanced `{…}` group.
200    /// Braced-only commands (`\code`, `\path`) capture nothing when no brace
201    /// follows and lex normally — the name may be an unrelated user macro
202    /// (`\code` as a math operator, TikZ's `\path (0,0)`), and a wrong
203    /// delimiter capture swallows text across the line. Only meaningful when
204    /// `verbatim` is set.
205    pub verbatim_delimited: bool,
206    /// `true` for horizontal-rule commands (`\hline`, `\midrule`, `\toprule`, …).
207    /// In an alignment environment a physical line made up solely of rule
208    /// commands is a *passthrough* line the formatter keeps between grid rows
209    /// rather than treating as a cell (see the grid lowering in `formatter`).
210    pub rule: bool,
211    /// `true` for *inline* commands that sit in running text (`\citep`, `\ref`,
212    /// `\emph`, `\textbf`, …) rather than occupying their own line. Paragraph reflow
213    /// treats such a command as an atom that flows into the fill even when the author
214    /// isolated it on its own source line, instead of preserving it as a
215    /// command-only line (the way a `\usepackage`/`\section` line is kept). For a
216    /// command that *also* has a `prose` argument this additionally flattens the
217    /// command into the paragraph so its body wraps as running text with the `{`/`}`
218    /// glued to the adjacent words. Block-level commands that head their own line
219    /// (`\section`, `\caption`) leave this `false`. Only meaningful to the formatter;
220    /// the parser ignores it.
221    pub inline: bool,
222    /// The command's grammatical citation role, when curated. The CWL and
223    /// scanned-definition tiers never infer this: argument shape alone cannot
224    /// distinguish `\textcite` from `\parencite`, and plain `\cite` is
225    /// package-dependent. Only meaningful to the formatter.
226    pub citation: Option<CitationPlacement>,
227    /// `true` for *block-level* commands that conventionally own their physical
228    /// line (`\usepackage`, `\newcommand`, `\maketitle`, …): package/class
229    /// loading, preamble machinery, definitions, and document structure. Prose
230    /// reflow places such a command on its own line whatever trivia the author
231    /// wrote, instead of preserving the line only when the source happened to
232    /// break there (the lone-newline predicate trivia-invariant layout forbids).
233    /// Sectioning commands are block-level too, implied by [`Self::sectioning`]
234    /// at the formatter's query, so entries carrying `sectioning` do not also
235    /// set this. Curated-only, like [`Self::verbatim_delimited`]: never from
236    /// the CWL tier or scanned definitions — an unknown macro's block-ness is
237    /// undecidable without meaning, so those fall back to the formatter's
238    /// residual authored-break rule. `\caption` and `\label` are deliberately
239    /// excluded (a glued `\caption{…} \label{…}` pair must stay untouched), as
240    /// are `\item` (owned by list layout) and `\input` (its TeX-primitive bare
241    /// form `\input docstrip.tex` leaves the filename outside the node). Only
242    /// meaningful to the formatter; the parser ignores it.
243    pub block: bool,
244}
245
246/// How an environment appears in the document-symbol outline, if at all. A small
247/// curated category over the `block` environments: floats, theorem-likes, and
248/// titled Beamer frames earn outline entries, while ordinary layout environments
249/// (`center`, `quote`, …) stay out of the symbol tree. Drives `SymbolKind`
250/// selection in the LSP layer.
251#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum OutlineKind {
253    /// A float (`figure`, `table`, and their starred forms).
254    Float,
255    /// A theorem-like environment (`theorem`, `lemma`, `proof`, …).
256    Theorem,
257    /// A Beamer `frame`; it earns an entry only when its source supplies a title.
258    Frame,
259}
260
261/// The signature of an environment.
262#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct EnvironmentSig {
264    /// The ordered argument slots that follow `\begin{name}` (e.g. `tabular`'s
265    /// column spec), *excluding* the name group itself. A [`Cow`] for the same
266    /// reason as [`CommandSig::args`]: a `'static` slice for the CWL tier, an owned
267    /// `Vec` for the runtime paths.
268    pub args: Cow<'static, [ArgSpec]>,
269    /// `true` for environments whose body is raw text (`verbatim`, `lstlisting`,
270    /// `minted`, …) and must never be reflowed.
271    pub verbatim_body: bool,
272    /// `true` for environments whose *name argument* is xparse `v`-type (l3doc's
273    /// `macro`/`function`/`variable`, declared `{ O{} +v }`): besides the usual
274    /// braced form, the argument may be delimited `\verb`-style
275    /// (`\begin{macro}+\@@_compile_{:+`), chosen precisely when the name holds
276    /// unbalanced braces. The lexer captures that delimited form as one opaque
277    /// `VERB` token; the braced form lexes normally. Curated only — a wrong
278    /// grant swallows real text, so the CWL/user tiers never set it.
279    pub verbatim_arg: bool,
280    /// `true` for math environments (`equation`, `align`, …).
281    pub math: bool,
282    /// `true` for environments whose body is *real parsed code*, not prose —
283    /// the doc/ltxdoc `macrocode`/`macrocode*` (whose body is LaTeX/expl3 code,
284    /// parsed and re-lexed under the package regime, *not* an opaque verbatim
285    /// blob like `verbatim_body`). The formatter preserves the body's layout and
286    /// never reflows it as prose; the distinction from `verbatim_body` is that the
287    /// content is a real CST, not a single `VERBATIM_BODY` token.
288    pub code: bool,
289    /// `true` for environments whose body is a sequence of *delimited
290    /// statements* rather than running prose — the TikZ/pgf picture family,
291    /// whose content is `;`-terminated paths (`\draw … ;`, `\node … ;`). The
292    /// parser wraps each run up to a top-level `;` in a `STATEMENT` node, and
293    /// the formatter derives one-statement-per-line, the continuation hang,
294    /// and unit-boundary wrapping (`semantic::tikz`) from it.
295    ///
296    /// The flag also carries a **whitespace-safety claim**, the
297    /// [`ContentKind::Keyval`] pattern: whitespace *between* a flagged body's
298    /// statements is insignificant to the package that consumes them, which is
299    /// what licenses the formatter to open a new line at a statement seam the
300    /// author glued (`…;\draw`) — an inserted space token TeX sees but the
301    /// package discards. `task typeset:check` carries the proving case
302    /// (`tests/typeset/statement_seams.tex`).
303    ///
304    /// Distinct from [`code`](Self::code), which is the `.dtx` documentation
305    /// layer's `macrocode` — code re-lexed under the package regime, a fact about
306    /// *lexing*. This one is a fact about statement structure and layout, so the
307    /// two must not be conflated: a future consumer of `code` is asking a `.dtx`
308    /// question.
309    ///
310    /// **Curated tier only.** The statement terminator is package grammar, not a
311    /// TeX-surface fact, so nothing mechanical can derive this; the CWL codegen
312    /// and the runtime definition scan never set it. A wrong grant reshapes
313    /// layout for the whole body *and* asserts the whitespace claim above, so
314    /// hold it to the standard of the `math` routing flag.
315    pub statement_body: bool,
316    /// `true` when a top-level `label` entry in the environment's first optional
317    /// argument creates a LaTeX label definition. This is narrower than
318    /// [`ContentKind::Keyval`]: many key-value processors have a `label` key whose
319    /// meaning is unrelated to `\label`, so only curated package facts may set it.
320    /// Project declarations inherit the fact through `like`; the CWL and scanned
321    /// tiers never grant it.
322    pub label_key: bool,
323    /// `true` for non-float environments in which a statement-level
324    /// `\captionof` conventionally establishes the numbered target for the
325    /// surrounding content. This is a curated linter fact: admitting every block
326    /// environment would make a preceding `\label` look caption-bound without
327    /// enough evidence of the author's intent.
328    pub caption_container: bool,
329    /// `true` for alignment environments whose `&` columns the formatter lays out
330    /// into a grid (`align`, `pmatrix`, …). Independent of `math`: every flagged
331    /// environment here is also math, but the formatter consults this flag, not
332    /// `math`, to decide column alignment.
333    pub align: bool,
334    /// `true` for sectioning-level *containers* whose body the formatter must
335    /// *not* indent (`document`, the appendix-package `appendix`, …). The shared
336    /// property is that the body is whole sections/paragraphs — content at the
337    /// same structural altitude as the sections the container sits among, not leaf
338    /// content like a `figure` or `minipage` — which is conventionally written
339    /// flush to the margin. The body is still laid out on its own lines, just at
340    /// the surrounding indentation level rather than nested one step in.
341    pub no_indent: bool,
342    /// `true` for list environments (`itemize`, `enumerate`, `description`, …)
343    /// whose `\item`s the formatter lays out one per line, reflowing each item's
344    /// body with continuation lines hanging-indented under the item text.
345    pub list: bool,
346    /// `true` when this environment is explicitly known to occupy its own vertical
347    /// space (`figure`, `center`, verbatim, …). Math, list, and no-indent
348    /// environments are inherently block-level and are included by [`Self::block`].
349    pub block_explicit: bool,
350    /// `Some(_)` for an environment that can earn a document-symbol outline entry —
351    /// a float, a theorem-like, or a titled Beamer frame. `None` for everything
352    /// else. Only meaningful to the language server's `documentSymbol`; the parser
353    /// and formatter ignore it.
354    pub outline: Option<OutlineKind>,
355}
356
357impl EnvironmentSig {
358    /// Whether the body is ordinary prose that the formatter may reflow.
359    pub const fn reflow(&self) -> bool {
360        !(self.verbatim_body || self.math || self.code || self.statement_body)
361    }
362
363    /// Whether the environment occupies its own vertical space.
364    pub const fn block(&self) -> bool {
365        self.block_explicit || self.math || self.list || self.no_indent
366    }
367}
368
369// --- const constructors (shared by the runtime JSON path and build-time codegen)
370//
371// The build script (`build.rs`) emits the CWL tier as a `phf` map whose values
372// are calls to these `const fn`s, so the static data is baked into the binary
373// with no runtime parse (see `cwl`).
374
375/// One argument slot, const-constructible for the codegen path.
376pub(crate) const fn arg(required: bool, kind: ArgKind, content: ContentKind) -> ArgSpec {
377    ArgSpec {
378        required,
379        kind,
380        content,
381        domain: ArgumentDomain::Unknown,
382        verbatim: false,
383    }
384}
385
386/// Named inputs for a generated command signature.
387pub(crate) struct GeneratedCommand {
388    args: &'static [ArgSpec],
389    sectioning: Option<u8>,
390    verbatim: bool,
391    rule: bool,
392    inline: bool,
393}
394
395/// A command signature over a `'static` argument slice (the codegen path).
396pub(crate) const fn command(generated: GeneratedCommand) -> CommandSig {
397    CommandSig {
398        args: Cow::Borrowed(generated.args),
399        sectioning: generated.sectioning,
400        verbatim: generated.verbatim,
401        // The codegen (CWL) tier is arity-only, so the delimiter facet — like
402        // every behavior flag — never comes from it.
403        verbatim_delimited: false,
404        rule: generated.rule,
405        inline: generated.inline,
406        // Citation grammar is curated meaning, never a mechanical CWL fact.
407        citation: None,
408        // Curated-only facet, like the delimiter one: block-ness never comes
409        // from the codegen (CWL) tier.
410        block: false,
411    }
412}
413
414/// Named inputs for a generated environment signature.
415pub(crate) struct GeneratedEnvironment {
416    args: &'static [ArgSpec],
417    verbatim_body: bool,
418    math: bool,
419    code: bool,
420    align: bool,
421    no_indent: bool,
422    list: bool,
423    block_explicit: bool,
424    outline: Option<OutlineKind>,
425}
426
427/// An environment signature over a `'static` argument slice (the codegen path),
428/// storing the explicit source facts from the generated data.
429pub(crate) const fn environment(generated: GeneratedEnvironment) -> EnvironmentSig {
430    EnvironmentSig {
431        args: Cow::Borrowed(generated.args),
432        verbatim_body: generated.verbatim_body,
433        // The codegen (CWL) tier is arity-only, so the verbatim-argument facet —
434        // like every behavior flag — never comes from it.
435        verbatim_arg: false,
436        math: generated.math,
437        code: generated.code,
438        // Curated-only facet, like the verbatim-argument one: a statement body is
439        // package grammar the mechanical tier cannot see.
440        statement_body: false,
441        // A key named `label` is not enough to prove `\label` semantics, so the
442        // mechanical CWL tier can never grant this fact.
443        label_key: false,
444        // Caption ownership is curated package behavior, not an arity fact.
445        caption_container: false,
446        align: generated.align,
447        no_indent: generated.no_indent,
448        list: generated.list,
449        block_explicit: generated.block_explicit,
450        outline: generated.outline,
451    }
452}
453
454/// The built-in command and environment signatures, keyed by name (without the
455/// leading `\` for commands, the bare name for environments). Case-sensitive, as
456/// LaTeX names are (`Verbatim` ≠ `verbatim`).
457#[derive(Debug, Default, Clone, PartialEq, Eq)]
458pub struct SignatureDb {
459    commands: HashMap<SmolStr, CommandSig>,
460    environments: HashMap<SmolStr, EnvironmentSig>,
461    /// Which loaded package (by file stem) a command signature came from, when
462    /// it was merged with an explicit origin via [`merge_from`](Self::merge_from).
463    /// Absent for the document's own definitions and for every static tier
464    /// (built-in/CWL DBs never carry origins). A side map rather than a
465    /// `CommandSig` field so the phf-generated static tables stay untouched.
466    command_origins: HashMap<SmolStr, SmolStr>,
467    /// The environment mirror of [`command_origins`](Self::command_origins).
468    environment_origins: HashMap<SmolStr, SmolStr>,
469    /// File-local *environment aliases*, opener side: a command name (without the
470    /// leading `\`) whose definition body is exactly `\begin{X}`, mapped to the
471    /// target environment `X`. Populated only by the per-file definition scan
472    /// ([`super::define`]), which admits an alias solely when `X` is a curated
473    /// built-in environment that is non-verbatim and takes no arguments, and when
474    /// both halves of the pair are defined in the same file.
475    ///
476    /// A *side map*, deliberately not an [`EnvironmentSig`] cloned under the alias
477    /// name: the alias is a command, not an environment, so it must not appear in
478    /// [`environment_names`](Self::environment_names) (that would offer
479    /// `\begin{bea}` to completion) and must not mask a real
480    /// `\newenvironment{bea}`. Nor may a *literal* `\begin{bea}` acquire the
481    /// target's behavior — which is why the only lookup that consults this map is
482    /// [`Signatures::environment_at`], keyed on the node so it can tell an alias
483    /// delimiter from a spelled-out environment that happens to share the name.
484    /// The plain name-keyed [`Signatures::environment`] never reads it.
485    env_begin_aliases: HashMap<SmolStr, SmolStr>,
486    /// The closer mirror of [`env_begin_aliases`](Self::env_begin_aliases): a
487    /// command whose body is exactly `\end{X}`. Kept separate rather than tagged
488    /// with a side, so the parser's opener and closer indices cannot be confused
489    /// and [`Signatures::environment`] can consult the opener side alone.
490    env_end_aliases: HashMap<SmolStr, SmolStr>,
491    /// Which environment signatures came from a project *declaration*
492    /// ([`crate::declarations`]) rather than from a scan.
493    ///
494    /// Provenance, like the origin maps above, and needed for the same kind of
495    /// reason: [`Signatures::environment_at`] resolves an alias target against
496    /// *curated* data only, so it has to tell a declared entry (curated — `like`
497    /// copies a built-in and resolves against nothing else) from a scanned
498    /// `\newenvironment` of the same name (not curated, and deliberately unable
499    /// to lend an alias its behavior). Without the mark the two are
500    /// indistinguishable once merged into one scope.
501    declared_environments: std::collections::HashSet<SmolStr>,
502}
503
504impl SignatureDb {
505    /// The signature of command `name` (without the leading `\`), if known.
506    pub fn command(&self, name: &str) -> Option<&CommandSig> {
507        self.commands.get(name)
508    }
509
510    /// The signature of environment `name`, if known.
511    pub fn environment(&self, name: &str) -> Option<&EnvironmentSig> {
512        self.environments.get(name)
513    }
514
515    /// All known command names (without the leading `\`), in arbitrary order.
516    /// Backs name completion, which unions these with the per-document scanned
517    /// definitions; the lookup methods stay the only refinement path.
518    pub fn command_names(&self) -> impl Iterator<Item = &str> {
519        self.commands.keys().map(SmolStr::as_str)
520    }
521
522    /// All known environment names, in arbitrary order. See [`command_names`].
523    ///
524    /// [`command_names`]: Self::command_names
525    pub fn environment_names(&self) -> impl Iterator<Item = &str> {
526        self.environments.keys().map(SmolStr::as_str)
527    }
528
529    /// The environment `name` opens, if the per-file scan recorded it as an
530    /// environment alias ([`env_begin_aliases`](Self::env_begin_aliases)). The name
531    /// carries no leading `\`.
532    pub fn env_begin_alias(&self, name: &str) -> Option<&str> {
533        self.env_begin_aliases.get(name).map(SmolStr::as_str)
534    }
535
536    /// The closer mirror of [`env_begin_alias`](Self::env_begin_alias).
537    pub fn env_end_alias(&self, name: &str) -> Option<&str> {
538        self.env_end_aliases.get(name).map(SmolStr::as_str)
539    }
540
541    /// Every recorded opener alias, as `(alias, target)` pairs in arbitrary order.
542    /// Backs the parser's projection of the map into its parse context.
543    pub fn env_begin_aliases(&self) -> impl Iterator<Item = (&str, &str)> {
544        self.env_begin_aliases
545            .iter()
546            .map(|(k, v)| (k.as_str(), v.as_str()))
547    }
548
549    /// The closer mirror of [`env_begin_aliases`](Self::env_begin_aliases).
550    pub fn env_end_aliases(&self) -> impl Iterator<Item = (&str, &str)> {
551        self.env_end_aliases
552            .iter()
553            .map(|(k, v)| (k.as_str(), v.as_str()))
554    }
555
556    /// Record an opener alias, replacing any existing entry for `name`.
557    pub fn insert_env_begin_alias(&mut self, name: impl Into<SmolStr>, target: impl Into<SmolStr>) {
558        self.env_begin_aliases.insert(name.into(), target.into());
559    }
560
561    /// Record a closer alias, replacing any existing entry for `name`.
562    pub fn insert_env_end_alias(&mut self, name: impl Into<SmolStr>, target: impl Into<SmolStr>) {
563        self.env_end_aliases.insert(name.into(), target.into());
564    }
565
566    /// The package (file stem) whose merge supplied the current signature of
567    /// command `name`, if it came from a package
568    /// ([`merge_from`](Self::merge_from) with `Some(origin)`) rather than the
569    /// document or a static tier.
570    pub fn command_origin(&self, name: &str) -> Option<&str> {
571        self.command_origins.get(name).map(SmolStr::as_str)
572    }
573
574    /// The environment mirror of [`command_origin`](Self::command_origin).
575    pub fn environment_origin(&self, name: &str) -> Option<&str> {
576        self.environment_origins.get(name).map(SmolStr::as_str)
577    }
578
579    /// Record a command signature, replacing any existing entry for `name`. Used
580    /// by the per-file definition scan ([`super::define`]) to populate a fresh DB;
581    /// the built-in DB is built from JSON and never mutated. A redefinition wins,
582    /// mirroring TeX's last-`\newcommand`-wins behavior; any recorded package
583    /// origin is cleared, since it described the entry being replaced.
584    pub fn insert_command(&mut self, name: impl Into<SmolStr>, sig: CommandSig) {
585        let name = name.into();
586        self.command_origins.remove(&name);
587        self.commands.insert(name, sig);
588    }
589
590    /// Record an environment signature, replacing any existing entry for `name`.
591    pub fn insert_environment(&mut self, name: impl Into<SmolStr>, sig: EnvironmentSig) {
592        let name = name.into();
593        self.environment_origins.remove(&name);
594        self.declared_environments.remove(&name);
595        self.environments.insert(name, sig);
596    }
597
598    /// Record an environment signature that came from a project *declaration*,
599    /// replacing any existing entry for `name` and marking its provenance. See
600    /// [`declared_environments`](Self::declared_environments) for why the mark
601    /// exists.
602    pub fn insert_declared_environment(&mut self, name: impl Into<SmolStr>, sig: EnvironmentSig) {
603        let name = name.into();
604        self.insert_environment(name.clone(), sig);
605        self.declared_environments.insert(name);
606    }
607
608    /// Whether `name`'s signature came from a project declaration.
609    pub fn is_declared_environment(&self, name: &str) -> bool {
610        self.declared_environments.contains(name)
611    }
612
613    /// Merge every command and environment of `other` into `self`, with `other`
614    /// winning on a name clash (last-definition-wins, like an individual
615    /// `insert_*`). Used to fold a loaded package's scanned definitions into a
616    /// document's merged signature scope; the caller orders the merges so the
617    /// document's own definitions are applied last and override any package.
618    ///
619    /// When `origin` is `Some`, it replaces the provenance of every merged
620    /// signature. When it is `None`, each entry inherits `other`'s provenance,
621    /// clearing stale provenance when `other` has none.
622    pub fn merge_from(&mut self, other: &SignatureDb, origin: Option<&str>) {
623        for (name, sig) in &other.commands {
624            match origin
625                .map(SmolStr::new)
626                .or_else(|| other.command_origins.get(name).cloned())
627            {
628                Some(origin) => {
629                    self.command_origins.insert(name.clone(), origin);
630                }
631                None => {
632                    self.command_origins.remove(name);
633                }
634            }
635            self.commands.insert(name.clone(), sig.clone());
636        }
637        for (name, sig) in &other.environments {
638            match origin
639                .map(SmolStr::new)
640                .or_else(|| other.environment_origins.get(name).cloned())
641            {
642                Some(origin) => {
643                    self.environment_origins.insert(name.clone(), origin);
644                }
645                None => {
646                    self.environment_origins.remove(name);
647                }
648            }
649            // Declared-ness describes the *current* entry, exactly as the origin
650            // above does: an overwrite from a non-declared source clears it, so a
651            // scanned definition merged over a declared name cannot leave the
652            // alias resolver believing the entry is still curated.
653            if origin.is_none() && other.is_declared_environment(name) {
654                self.declared_environments.insert(name.clone());
655            } else {
656                self.declared_environments.remove(name);
657            }
658            self.environments.insert(name.clone(), sig.clone());
659        }
660        for (name, target) in &other.env_begin_aliases {
661            self.env_begin_aliases.insert(name.clone(), target.clone());
662        }
663        for (name, target) in &other.env_end_aliases {
664            self.env_end_aliases.insert(name.clone(), target.clone());
665        }
666    }
667
668    /// Overlay a project's resolved [declarations](crate::declarations) as the
669    /// **top tier** of this scope: a declaration is the user explicitly
670    /// correcting an inference, so it wins over scanned definitions and loaded
671    /// packages alike.
672    ///
673    /// A named entry rather than `merge_from(declared.as_db(), None)` at each call
674    /// site, so the precedence rule is stated once and the two scope builders
675    /// (the CLI's `collect_package_signatures` and the salsa `scope_signatures`)
676    /// cannot disagree about where in the order it goes.
677    pub fn merge_declarations(&mut self, declared: &crate::declarations::ResolvedDeclarations) {
678        self.merge_from(declared.as_db(), None);
679    }
680}
681
682/// A two-tier signature lookup: a per-document [`SignatureDb`] of scanned
683/// `\newcommand`/`\newenvironment`/xparse definitions consulted first, falling back
684/// to the process-wide [`builtin`] DB. Cheap to copy (it borrows the scanned DB),
685/// so it threads through the formatter's lowering like a context handle.
686///
687/// Scanned-first matches TeX scoping intuition: a locally (re)defined command
688/// shadows a built-in of the same name. (We do not yet model *where* a definition
689/// becomes visible — a whole-file union — which is sound for the formatter's arity
690/// needs; lexical/conditional visibility is out of scope, per AGENTS.md #1.)
691#[derive(Debug, Clone, Copy)]
692pub struct Signatures<'a> {
693    user: &'a SignatureDb,
694}
695
696impl<'a> Signatures<'a> {
697    /// Resolve against `user` first, then the built-in DB.
698    pub fn new(user: &'a SignatureDb) -> Self {
699        Self { user }
700    }
701
702    /// The signature of command `name`: scanned definition first, then the curated
703    /// built-in, then the bulk CWL tier. CWL is consulted last and contributes only
704    /// argument arity (its behavior flags are all default), so a CWL-only command is
705    /// laid out like any unknown command, just with its argument count known.
706    pub fn command(&self, name: &str) -> Option<&'a CommandSig> {
707        self.user
708            .command(name)
709            .or_else(|| builtin().command(name))
710            .or_else(|| cwl().command(name))
711    }
712
713    /// The signature of environment `name`: scanned, then built-in, then CWL. See
714    /// [`command`] for why the CWL tier is safe to consult here.
715    ///
716    /// Environment *aliases* are deliberately **not** consulted: an alias names a
717    /// command, and a name alone cannot tell a `\bea`-opened delimiter from a
718    /// literal `\begin{bea}` that happens to spell the same word. Resolve those
719    /// through [`environment_at`](Self::environment_at), which has the node.
720    ///
721    /// [`command`]: Self::command
722    pub fn environment(&self, name: &str) -> Option<&'a EnvironmentSig> {
723        self.user
724            .environment(name)
725            .or_else(|| builtin().environment(name))
726            .or_else(|| cwl().environment(name))
727    }
728
729    /// The signature `name` was *declared* with, if the scope carries one. The
730    /// curated half of the alias resolution above; never a scanned entry.
731    fn declared_environment(&self, name: &str) -> Option<&'a EnvironmentSig> {
732        self.user
733            .is_declared_environment(name)
734            .then(|| self.user.environment(name))
735            .flatten()
736    }
737
738    /// The signature governing `node` — an `ENVIRONMENT` or its `BEGIN` — which is
739    /// [`environment`](Self::environment) except that an *environment-alias*
740    /// delimiter resolves through the alias map instead.
741    ///
742    /// This is the node-keyed lookup every layout decision wants, because an alias
743    /// `BEGIN` (a bare control word, [`Begin::is_alias`]) and a literal `\begin{X}`
744    /// are indistinguishable once reduced to a name. Only the former inherits the
745    /// target's behavior; a literal `\begin{bea}` in a file that also defines `\bea`
746    /// as an alias is an unrelated environment of that name and stays unknown.
747    ///
748    /// The alias arm resolves against **curated data only**, for the same reason
749    /// the parser's `ParseCtx::is_math_environment` does: an alias declares a
750    /// *spelling*, never a *semantic*, so every behavior flag still comes from
751    /// curated data. That means [`builtin`] plus the scope's *declared* entries
752    /// — a declaration is curated (`like` copies a built-in entry and resolves
753    /// against nothing else), which is what lets `\startmyenv … \endmyenv` reach
754    /// the behavior of a `myenv` that has no built-in counterpart. A scanned
755    /// `\newenvironment` of the same name still lends an alias nothing.
756    ///
757    /// [`Begin::is_alias`]: crate::ast::Begin::is_alias
758    pub fn environment_at(&self, node: &SyntaxNode) -> Option<&'a EnvironmentSig> {
759        use crate::ast::{AstNode, Begin, child};
760        let begin = match node.kind() {
761            SyntaxKind::BEGIN => Begin::cast(node.clone())?,
762            _ => child::<Begin>(node)?,
763        };
764        let name = begin.name()?;
765        if begin.is_alias() {
766            return self.user.env_begin_alias(&name).and_then(|target| {
767                self.declared_environment(target)
768                    .or_else(|| builtin().environment(target))
769            });
770        }
771        self.environment(&name)
772    }
773}
774
775/// The bundled, curated signature data (see module docs).
776const SIGNATURES_JSON: &str = include_str!("../../data/signatures.json");
777
778static DB: LazyLock<SignatureDb> =
779    LazyLock::new(|| parse(SIGNATURES_JSON).expect("bundled data/signatures.json must be valid"));
780
781/// The process-wide built-in signature database.
782pub fn builtin() -> &'static SignatureDb {
783    &DB
784}
785
786/// The type of the build-generated CWL maps: a name-keyed perfect-hash map. The
787/// generated `static`s are spelled with this alias, so the dependency on `phf` is
788/// visible in checked-in source (not only in the generated file).
789type CwlSigMap<V> = phf::Map<&'static str, V>;
790
791// The bulk CWL tier is generated by `build.rs` from `data/cwl_signatures.json`
792// into two `CwlSigMap`s (`CWL_COMMANDS`, `CWL_ENVIRONMENTS`) whose values are
793// `command(...)`/`environment(...)`/`arg(...)` const-constructor calls — so the
794// data is baked into the binary as read-only statics with *zero* runtime parse
795// or decompress (it was a ~4.5 ms one-time `LazyLock` decompress+JSON-parse; now
796// ~0). The included file references the const constructors and `CwlSigMap` here.
797include!(concat!(env!("OUT_DIR"), "/cwl_signatures.rs"));
798
799/// Handle to the lower-precision **CWL tier**: a broad set of command/environment
800/// names plus argument shapes harvested from the TeXstudio CWL corpus (a curated
801/// package subset; see `scripts/gen_cwl_signatures.py`). It carries *names and
802/// arity only* — every behavior flag (`content`/`verbatim`/`sectioning`/`math`/…) is
803/// left at its default — so it can widen completion and the formatter's arity
804/// lookup without its low-confidence data ever reaching a lexer/outline behavior
805/// decision. Consulted strictly *under* [`builtin`] (via [`Signatures`]); the
806/// curated tier always wins. A ZST over the generated `phf` statics, so its query
807/// methods mirror [`SignatureDb`]'s without owning a heap map.
808#[derive(Debug, Clone, Copy)]
809pub struct CwlDb;
810
811impl CwlDb {
812    /// The signature of command `name` (without the leading `\`), if in the tier.
813    pub fn command(&self, name: &str) -> Option<&'static CommandSig> {
814        CWL_COMMANDS.get(name)
815    }
816
817    /// The signature of environment `name`, if in the tier.
818    pub fn environment(&self, name: &str) -> Option<&'static EnvironmentSig> {
819        CWL_ENVIRONMENTS.get(name)
820    }
821
822    /// All CWL command names (without the leading `\`), in arbitrary order. The
823    /// `&str` lifetime is tied to `&self` (not `'static`) so it unifies with the
824    /// borrowed scanned-definition names in a completion `chain` (see
825    /// `completion::command_candidates`), exactly like [`SignatureDb::command_names`].
826    pub fn command_names(&self) -> impl Iterator<Item = &str> {
827        CWL_COMMANDS.keys().map(|name| &**name)
828    }
829
830    /// All CWL environment names, in arbitrary order. See [`command_names`].
831    ///
832    /// [`command_names`]: Self::command_names
833    pub fn environment_names(&self) -> impl Iterator<Item = &str> {
834        CWL_ENVIRONMENTS.keys().map(|name| &**name)
835    }
836
837    /// All CWL command signatures (introspection; backs the invariant tests).
838    pub fn command_sigs(&self) -> impl Iterator<Item = &'static CommandSig> {
839        CWL_COMMANDS.values()
840    }
841
842    /// All CWL environment signatures (introspection; backs the invariant tests).
843    pub fn environment_sigs(&self) -> impl Iterator<Item = &'static EnvironmentSig> {
844        CWL_ENVIRONMENTS.values()
845    }
846}
847
848static CWL: CwlDb = CwlDb;
849
850/// The process-wide CWL tier (see [`CwlDb`]).
851pub fn cwl() -> &'static CwlDb {
852    &CWL
853}
854
855// --- On-disk schema (serde) ---------------------------------------------------
856//
857// A thin deserialization mirror of the in-memory types, kept separate so the
858// public API stays free of serde concerns and the JSON can use a compact,
859// hand-authorable spelling (`"req"`/`"opt"` for arguments; flags defaulting to
860// false; `reflow` derived rather than stored).
861
862/// An argument's bracket as written in the JSON: `"req"` (mandatory `{…}`) or
863/// `"opt"` (optional `[…]`).
864#[derive(Deserialize, Clone, Copy)]
865#[serde(rename_all = "lowercase")]
866enum RawArgKind {
867    Req,
868    Opt,
869}
870
871impl RawArgKind {
872    fn required(self) -> bool {
873        matches!(self, RawArgKind::Req)
874    }
875
876    fn kind(self) -> ArgKind {
877        match self {
878            RawArgKind::Req => ArgKind::Brace,
879            RawArgKind::Opt => ArgKind::Bracket,
880        }
881    }
882}
883
884/// An argument's content kind as written in the JSON: `"opaque"` (default),
885/// `"prose"`, `"tokenList"`, or `"keyval"`. Mirrors [`ContentKind`].
886#[derive(Deserialize, Clone, Copy, Default)]
887#[serde(rename_all = "camelCase")]
888enum RawContentKind {
889    #[default]
890    Opaque,
891    Prose,
892    TokenList,
893    Keyval,
894}
895
896#[derive(Deserialize, Clone, Copy)]
897#[serde(rename_all = "lowercase")]
898enum RawCitationPlacement {
899    Textual,
900    Postpositive,
901    Ambiguous,
902}
903
904impl From<RawCitationPlacement> for CitationPlacement {
905    fn from(raw: RawCitationPlacement) -> Self {
906        match raw {
907            RawCitationPlacement::Textual => CitationPlacement::Textual,
908            RawCitationPlacement::Postpositive => CitationPlacement::Postpositive,
909            RawCitationPlacement::Ambiguous => CitationPlacement::Ambiguous,
910        }
911    }
912}
913
914#[derive(Deserialize, Clone, Copy, Default)]
915#[serde(rename_all = "lowercase")]
916enum RawArgumentDomain {
917    #[default]
918    Unknown,
919    Math,
920    Text,
921}
922
923impl From<RawArgumentDomain> for ArgumentDomain {
924    fn from(raw: RawArgumentDomain) -> Self {
925        match raw {
926            RawArgumentDomain::Unknown => ArgumentDomain::Unknown,
927            RawArgumentDomain::Math => ArgumentDomain::Math,
928            RawArgumentDomain::Text => ArgumentDomain::Text,
929        }
930    }
931}
932
933impl From<RawContentKind> for ContentKind {
934    fn from(raw: RawContentKind) -> Self {
935        match raw {
936            RawContentKind::Opaque => ContentKind::Opaque,
937            RawContentKind::Prose => ContentKind::Prose,
938            RawContentKind::TokenList => ContentKind::TokenList,
939            RawContentKind::Keyval => ContentKind::Keyval,
940        }
941    }
942}
943
944/// One argument as written in the JSON. Either the compact string shorthand
945/// (`"req"` / `"opt"`, the common case, content defaulting to `"opaque"`) or an
946/// object form `{ "kind": "req", "content": "prose" }` / `{ "kind": "req",
947/// "content": "tokenList" }` that additionally marks the argument's content kind
948/// (see [`ContentKind`]).
949#[derive(Deserialize)]
950#[serde(untagged)]
951enum RawArg {
952    Short(RawArgKind),
953    Full {
954        kind: RawArgKind,
955        #[serde(default)]
956        content: RawContentKind,
957        #[serde(default)]
958        domain: RawArgumentDomain,
959        #[serde(default)]
960        verbatim: bool,
961    },
962}
963
964impl From<RawArg> for ArgSpec {
965    fn from(raw: RawArg) -> Self {
966        match raw {
967            RawArg::Short(kind) => ArgSpec {
968                required: kind.required(),
969                kind: kind.kind(),
970                content: ContentKind::Opaque,
971                domain: ArgumentDomain::Unknown,
972                verbatim: false,
973            },
974            RawArg::Full {
975                kind,
976                content,
977                domain,
978                verbatim,
979            } => ArgSpec {
980                required: kind.required(),
981                kind: kind.kind(),
982                content: content.into(),
983                domain: domain.into(),
984                verbatim,
985            },
986        }
987    }
988}
989
990#[derive(Deserialize, Default)]
991#[serde(deny_unknown_fields)]
992struct RawCommand {
993    #[serde(default)]
994    args: Vec<RawArg>,
995    #[serde(default)]
996    sectioning: Option<u8>,
997    #[serde(default)]
998    verbatim: bool,
999    #[serde(default, rename = "verbatimDelimited")]
1000    verbatim_delimited: bool,
1001    #[serde(default)]
1002    rule: bool,
1003    #[serde(default)]
1004    inline: bool,
1005    #[serde(default)]
1006    citation: Option<RawCitationPlacement>,
1007    #[serde(default)]
1008    block: bool,
1009}
1010
1011impl From<RawCommand> for CommandSig {
1012    fn from(raw: RawCommand) -> Self {
1013        CommandSig {
1014            args: Cow::Owned(raw.args.into_iter().map(ArgSpec::from).collect()),
1015            sectioning: raw.sectioning,
1016            verbatim: raw.verbatim,
1017            verbatim_delimited: raw.verbatim_delimited,
1018            rule: raw.rule,
1019            inline: raw.inline,
1020            citation: raw.citation.map(CitationPlacement::from),
1021            block: raw.block,
1022        }
1023    }
1024}
1025
1026/// An environment's outline category as written in the JSON: `"float"`,
1027/// `"theorem"`, or `"frame"` (absent → `None`, no outline entry).
1028#[derive(Deserialize, Clone, Copy)]
1029#[serde(rename_all = "lowercase")]
1030enum RawOutlineKind {
1031    Float,
1032    Theorem,
1033    Frame,
1034}
1035
1036impl From<RawOutlineKind> for OutlineKind {
1037    fn from(raw: RawOutlineKind) -> Self {
1038        match raw {
1039            RawOutlineKind::Float => OutlineKind::Float,
1040            RawOutlineKind::Theorem => OutlineKind::Theorem,
1041            RawOutlineKind::Frame => OutlineKind::Frame,
1042        }
1043    }
1044}
1045
1046#[derive(Deserialize, Default)]
1047#[serde(deny_unknown_fields)]
1048struct RawEnvironment {
1049    #[serde(default)]
1050    args: Vec<RawArg>,
1051    #[serde(default, rename = "verbatimBody")]
1052    verbatim_body: bool,
1053    #[serde(default, rename = "verbatimArg")]
1054    verbatim_arg: bool,
1055    #[serde(default)]
1056    math: bool,
1057    #[serde(default)]
1058    code: bool,
1059    #[serde(default, rename = "statementBody")]
1060    statement_body: bool,
1061    #[serde(default, rename = "labelKey")]
1062    label_key: bool,
1063    #[serde(default, rename = "captionContainer")]
1064    caption_container: bool,
1065    #[serde(default)]
1066    align: bool,
1067    #[serde(default, rename = "noIndent")]
1068    no_indent: bool,
1069    #[serde(default)]
1070    list: bool,
1071    #[serde(default)]
1072    block: bool,
1073    #[serde(default)]
1074    outline: Option<RawOutlineKind>,
1075}
1076
1077impl From<RawEnvironment> for EnvironmentSig {
1078    fn from(raw: RawEnvironment) -> Self {
1079        EnvironmentSig {
1080            args: Cow::Owned(raw.args.into_iter().map(ArgSpec::from).collect()),
1081            verbatim_body: raw.verbatim_body,
1082            verbatim_arg: raw.verbatim_arg,
1083            math: raw.math,
1084            code: raw.code,
1085            statement_body: raw.statement_body,
1086            label_key: raw.label_key,
1087            caption_container: raw.caption_container,
1088            align: raw.align,
1089            no_indent: raw.no_indent,
1090            list: raw.list,
1091            block_explicit: raw.block,
1092            outline: raw.outline.map(OutlineKind::from),
1093        }
1094    }
1095}
1096
1097#[derive(Deserialize, Default)]
1098#[serde(deny_unknown_fields)]
1099struct RawDb {
1100    /// An optional top-level provenance header (the generated `cwl_signatures.json`
1101    /// carries one); accepted and discarded so `deny_unknown_fields` still rejects
1102    /// genuine typos elsewhere.
1103    #[serde(default, rename = "_comment")]
1104    _comment: Option<serde::de::IgnoredAny>,
1105    #[serde(default)]
1106    commands: HashMap<String, RawCommand>,
1107    #[serde(default)]
1108    environments: HashMap<String, RawEnvironment>,
1109}
1110
1111/// Deserialize the bundled JSON into a [`SignatureDb`].
1112fn parse(json: &str) -> serde_json::Result<SignatureDb> {
1113    let raw: RawDb = serde_json::from_str(json)?;
1114    Ok(SignatureDb {
1115        commands: raw
1116            .commands
1117            .into_iter()
1118            .map(|(name, sig)| (SmolStr::new(name), sig.into()))
1119            .collect(),
1120        environments: raw
1121            .environments
1122            .into_iter()
1123            .map(|(name, sig)| (SmolStr::new(name), sig.into()))
1124            .collect(),
1125        command_origins: HashMap::new(),
1126        environment_origins: HashMap::new(),
1127        // Aliases are a per-file scan product only; the curated JSON never carries any.
1128        env_begin_aliases: HashMap::new(),
1129        env_end_aliases: HashMap::new(),
1130        // The built-in tier *is* the curated data a declaration copies from, so
1131        // nothing in it is itself declared.
1132        declared_environments: std::collections::HashSet::new(),
1133    })
1134}
1135
1136#[cfg(test)]
1137mod tests {
1138    use super::*;
1139
1140    #[test]
1141    fn bundled_json_loads() {
1142        let db = builtin();
1143        assert!(db.command("section").is_some());
1144        assert!(db.environment("tabular").is_some());
1145    }
1146
1147    #[test]
1148    fn loads_and_resolves_known_commands() {
1149        let db = builtin();
1150        assert_eq!(db.command("frac").map(|c| c.args.len()), Some(2));
1151        assert!(db.command("frac").unwrap().args.iter().all(|a| a.required));
1152    }
1153
1154    #[test]
1155    fn optional_then_mandatory_order_preserved() {
1156        let args = &builtin().command("includegraphics").unwrap().args;
1157        assert_eq!(args.len(), 2);
1158        assert_eq!(args[0].kind, ArgKind::Bracket);
1159        assert!(!args[0].required);
1160        assert_eq!(args[1].kind, ArgKind::Brace);
1161        assert!(args[1].required);
1162    }
1163
1164    #[test]
1165    fn mixed_argument_order_round_trips() {
1166        let args = &builtin().command("newcommand").unwrap().args;
1167        let kinds: Vec<_> = args.iter().map(|a| a.kind).collect();
1168        assert_eq!(
1169            kinds,
1170            vec![ArgKind::Brace, ArgKind::Bracket, ArgKind::Brace]
1171        );
1172    }
1173
1174    #[test]
1175    fn outline_categories_assigned() {
1176        let db = builtin();
1177        assert_eq!(
1178            db.environment("figure").unwrap().outline,
1179            Some(OutlineKind::Float)
1180        );
1181        assert_eq!(
1182            db.environment("table*").unwrap().outline,
1183            Some(OutlineKind::Float)
1184        );
1185        assert_eq!(
1186            db.environment("theorem").unwrap().outline,
1187            Some(OutlineKind::Theorem)
1188        );
1189        assert_eq!(
1190            db.environment("frame").unwrap().outline,
1191            Some(OutlineKind::Frame)
1192        );
1193        assert_eq!(db.environment("center").unwrap().outline, None);
1194    }
1195
1196    #[test]
1197    fn sectioning_levels_assigned() {
1198        let db = builtin();
1199        assert_eq!(db.command("part").unwrap().sectioning, Some(0));
1200        assert_eq!(db.command("section").unwrap().sectioning, Some(2));
1201        assert_eq!(db.command("subsubsection").unwrap().sectioning, Some(4));
1202        assert_eq!(db.command("section").unwrap().args.len(), 2);
1203        assert!(db.command("textbf").unwrap().sectioning.is_none());
1204    }
1205
1206    #[test]
1207    fn block_commands_flagged() {
1208        let db = builtin();
1209        assert!(db.command("usepackage").unwrap().block);
1210        assert!(db.command("newcommand").unwrap().block);
1211        assert!(db.command("maketitle").unwrap().block);
1212        assert!(db.command("title").unwrap().block);
1213        assert_eq!(db.command("usepackage").unwrap().args.len(), 2);
1214        assert!(!db.command("caption").unwrap().block);
1215        assert!(!db.command("label").unwrap().block);
1216        assert!(!db.command("textbf").unwrap().block);
1217        assert!(!db.command("section").unwrap().block);
1218    }
1219
1220    #[test]
1221    fn no_command_is_both_inline_and_block() {
1222        for name in builtin().command_names() {
1223            let sig = builtin().command(name).unwrap();
1224            assert!(
1225                !(sig.inline && sig.block),
1226                "`\\{name}` is flagged both inline and block"
1227            );
1228        }
1229    }
1230
1231    #[test]
1232    fn verbatim_commands_flagged() {
1233        assert!(builtin().command("verb").unwrap().verbatim);
1234        assert!(builtin().command("lstinline").unwrap().verbatim);
1235        assert!(!builtin().command("textbf").unwrap().verbatim);
1236        assert!(builtin().command("lstinline").unwrap().verbatim_delimited);
1237        assert!(!builtin().command("code").unwrap().verbatim_delimited);
1238        assert!(!builtin().command("path").unwrap().verbatim_delimited);
1239    }
1240
1241    #[test]
1242    fn content_kind_parses_from_both_forms() {
1243        let db = parse(
1244            r#"{ "commands": {
1245                "short": { "args": ["req"] },
1246                "full":  { "args": ["opt", { "kind": "req", "content": "prose" }] },
1247                "kv":    { "args": [{ "kind": "opt", "content": "keyval" }, "req"] },
1248                "list":  { "args": [{ "kind": "req", "content": "tokenList" }] }
1249            } }"#,
1250        )
1251        .expect("valid content schema");
1252        let short = &db.command("short").unwrap().args;
1253        assert_eq!(short[0].content, ContentKind::Opaque);
1254        let full = &db.command("full").unwrap().args;
1255        assert_eq!(full[0].kind, ArgKind::Bracket);
1256        assert_eq!(full[0].content, ContentKind::Opaque); // no `content` → default
1257        assert_eq!(full[1].kind, ArgKind::Brace);
1258        assert_eq!(full[1].content, ContentKind::Prose);
1259        let kv = &db.command("kv").unwrap().args;
1260        assert_eq!(kv[0].kind, ArgKind::Bracket);
1261        assert_eq!(kv[0].content, ContentKind::Keyval);
1262        assert_eq!(kv[1].content, ContentKind::Opaque);
1263        let list = &db.command("list").unwrap().args;
1264        assert_eq!(list[0].content, ContentKind::TokenList);
1265    }
1266
1267    #[test]
1268    fn citation_placement_is_curated() {
1269        let db = builtin();
1270        assert_eq!(
1271            db.command("textcite").unwrap().citation,
1272            Some(CitationPlacement::Textual)
1273        );
1274        assert_eq!(
1275            db.command("citet").unwrap().citation,
1276            Some(CitationPlacement::Textual)
1277        );
1278        assert_eq!(
1279            db.command("parencite").unwrap().citation,
1280            Some(CitationPlacement::Postpositive)
1281        );
1282        assert_eq!(
1283            db.command("citep").unwrap().citation,
1284            Some(CitationPlacement::Postpositive)
1285        );
1286        assert_eq!(
1287            db.command("cite").unwrap().citation,
1288            Some(CitationPlacement::Ambiguous)
1289        );
1290        assert_eq!(db.command("ref").unwrap().citation, None);
1291
1292        for name in db.command_names() {
1293            let sig = db.command(name).unwrap();
1294            if sig.citation.is_some() {
1295                assert!(sig.inline, "citation `\\{name}` is not inline");
1296                assert!(
1297                    sig.args
1298                        .iter()
1299                        .any(|arg| arg.content == ContentKind::TokenList),
1300                    "citation `\\{name}` has no token-list argument"
1301                );
1302            }
1303        }
1304    }
1305
1306    #[test]
1307    fn positional_verbatim_defaults_off_and_parses_from_full_form() {
1308        let db = parse(
1309            r#"{ "commands": {
1310                "short": { "args": ["req"] },
1311                "raw": { "args": [{ "kind": "req", "verbatim": true }, "req"] }
1312            } }"#,
1313        )
1314        .expect("valid positional verbatim schema");
1315
1316        assert!(!db.command("short").unwrap().args[0].verbatim);
1317        let raw = &db.command("raw").unwrap().args;
1318        assert!(raw[0].verbatim);
1319        assert!(!raw[1].verbatim);
1320    }
1321
1322    #[test]
1323    fn positional_verbatim_slot_keeps_later_groups_aligned() {
1324        let args = &builtin().command("href").unwrap().args;
1325        let mut slot = 0;
1326
1327        assert_eq!(
1328            match_arg_slot_index(args, &mut slot, ArgKind::Bracket),
1329            Some(0)
1330        );
1331        assert!(match_verbatim_arg_slot(args, &mut slot).is_some());
1332        assert_eq!(
1333            match_arg_slot_index(args, &mut slot, ArgKind::Brace),
1334            Some(2)
1335        );
1336    }
1337
1338    #[test]
1339    fn argument_domain_defaults_and_json_values_are_independent_of_content() {
1340        let db = parse(
1341            r#"{ "commands": {
1342                "short": { "args": ["req"] },
1343                "math": { "args": [{ "kind": "req", "content": "prose", "domain": "math" }] },
1344                "text": { "args": [{ "kind": "opt", "domain": "text" }] }
1345            } }"#,
1346        )
1347        .unwrap();
1348        assert_eq!(
1349            db.command("short").unwrap().args[0].domain,
1350            ArgumentDomain::Unknown
1351        );
1352        assert_eq!(
1353            db.command("math").unwrap().args[0].domain,
1354            ArgumentDomain::Math
1355        );
1356        assert_eq!(
1357            db.command("math").unwrap().args[0].content,
1358            ContentKind::Prose
1359        );
1360        assert_eq!(
1361            db.command("text").unwrap().args[0].domain,
1362            ArgumentDomain::Text
1363        );
1364        assert!(
1365            cwl()
1366                .command("multicolumn")
1367                .unwrap()
1368                .args
1369                .iter()
1370                .all(|arg| arg.domain == ArgumentDomain::Unknown)
1371        );
1372    }
1373
1374    #[test]
1375    fn positional_matching_skips_only_omitted_optionals() {
1376        let args = &builtin().command("sqrt").unwrap().args;
1377        let mut slot = 0;
1378        assert_eq!(
1379            match_arg_slot_index(args, &mut slot, ArgKind::Brace),
1380            Some(1)
1381        );
1382        assert_eq!(slot, 2);
1383
1384        let mut slot = 0;
1385        assert_eq!(
1386            match_arg_slot_index(args, &mut slot, ArgKind::Bracket),
1387            Some(0)
1388        );
1389        assert_eq!(
1390            match_arg_slot_index(args, &mut slot, ArgKind::Brace),
1391            Some(1)
1392        );
1393        assert_eq!(match_arg_slot_index(args, &mut slot, ArgKind::Brace), None);
1394
1395        let frac = &builtin().command("frac").unwrap().args;
1396        let mut slot = 0;
1397        assert_eq!(
1398            match_arg_slot_index(frac, &mut slot, ArgKind::Bracket),
1399            None
1400        );
1401        assert_eq!(slot, 0);
1402        assert_eq!(
1403            match_arg_slot_index(frac, &mut slot, ArgKind::Brace),
1404            Some(0)
1405        );
1406
1407        let optional_brace_then_required_bracket = crate::semantic::xparse::parse_spec("d{} r[]");
1408        let mut slot = 0;
1409        assert_eq!(
1410            match_arg_slot_index(
1411                &optional_brace_then_required_bracket,
1412                &mut slot,
1413                ArgKind::Bracket,
1414            ),
1415            Some(1)
1416        );
1417
1418        let required_bracket_then_brace = crate::semantic::xparse::parse_spec("r[] m");
1419        let mut slot = 0;
1420        assert_eq!(
1421            match_arg_slot_index(&required_bracket_then_brace, &mut slot, ArgKind::Brace),
1422            None
1423        );
1424        assert_eq!(slot, 0);
1425        assert_eq!(
1426            match_arg_slot_index(&required_bracket_then_brace, &mut slot, ArgKind::Bracket),
1427            Some(0)
1428        );
1429    }
1430
1431    #[test]
1432    fn bundled_prose_args_flagged() {
1433        for name in builtin().command_names() {
1434            for argument in builtin().command(name).unwrap().args.iter() {
1435                if argument.content == ContentKind::Prose {
1436                    assert_eq!(argument.domain, ArgumentDomain::Text, "\\{name}");
1437                }
1438            }
1439        }
1440        let footnote = &builtin().command("footnote").unwrap().args;
1441        assert!(footnote.iter().any(|a| a.content == ContentKind::Prose));
1442        let section = &builtin().command("section").unwrap().args;
1443        assert!(
1444            section
1445                .iter()
1446                .all(|argument| argument.domain == ArgumentDomain::Text)
1447        );
1448        let label = &builtin().command("label").unwrap().args;
1449        assert!(label.iter().all(|a| a.content == ContentKind::Opaque));
1450    }
1451
1452    #[test]
1453    fn environment_argument_shapes() {
1454        let db = builtin();
1455        let tabular = db.environment("tabular").unwrap();
1456        assert_eq!(tabular.args.len(), 2);
1457        assert_eq!(tabular.args[0].kind, ArgKind::Bracket); // [pos]
1458        assert_eq!(tabular.args[1].kind, ArgKind::Brace); // {cols}
1459        assert!(db.environment("verbatim").unwrap().args.is_empty());
1460    }
1461
1462    #[test]
1463    fn environment_derived_flags_follow_source_mutation() {
1464        let mut sig = EnvironmentSig::from(RawEnvironment::default());
1465        assert!(sig.reflow());
1466        assert!(!sig.block());
1467
1468        sig.verbatim_body = true;
1469        assert!(!sig.reflow());
1470
1471        sig.block_explicit = true;
1472        assert!(sig.block());
1473        sig.block_explicit = false;
1474
1475        sig.math = true;
1476        assert!(sig.block());
1477    }
1478
1479    #[test]
1480    fn environment_flags_and_derived_reflow() {
1481        let db = builtin();
1482        let lstlisting = db.environment("lstlisting").unwrap();
1483        assert!(lstlisting.verbatim_body);
1484        assert!(!lstlisting.reflow());
1485        let equation = db.environment("equation").unwrap();
1486        assert!(equation.math);
1487        assert!(!equation.reflow());
1488        assert!(!equation.align);
1489        let align = db.environment("align").unwrap();
1490        assert!(align.math);
1491        assert!(align.align);
1492        let pmatrix = db.environment("pmatrix").unwrap();
1493        assert!(pmatrix.math);
1494        assert!(pmatrix.align);
1495        let tabular = db.environment("tabular").unwrap();
1496        assert!(!tabular.verbatim_body);
1497        assert!(!tabular.math);
1498        assert!(tabular.align);
1499        assert!(!tabular.list);
1500        for name in ["itemize", "enumerate", "description"] {
1501            let env = db.environment(name).unwrap();
1502            assert!(env.list, "{name} should be a list environment");
1503            assert!(env.reflow());
1504            assert!(!env.math);
1505        }
1506        for name in [
1507            "Code",
1508            "CodeInput",
1509            "CodeOutput",
1510            "Sinput",
1511            "Soutput",
1512            "Scode",
1513        ] {
1514            let env = db.environment(name).unwrap();
1515            assert!(env.verbatim_body, "{name} should be a verbatim environment");
1516            assert!(!env.reflow());
1517        }
1518    }
1519
1520    #[test]
1521    fn externally_defined_verbatim_environments() {
1522        let db = builtin();
1523        for name in ["filecontents", "filecontents*"] {
1524            let env = db.environment(name).unwrap();
1525            assert!(env.verbatim_body, "{name} body is written verbatim");
1526            assert!(!env.reflow());
1527            assert_eq!(env.args.len(), 2, "{name} arity");
1528            assert_eq!(env.args[0].kind, ArgKind::Bracket);
1529            assert_eq!(env.args[1].kind, ArgKind::Brace);
1530        }
1531        for name in ["ltxcode", "ltxexample"] {
1532            let env = db.environment(name).unwrap();
1533            assert!(env.verbatim_body, "{name} body is opaque");
1534            assert!(!env.reflow());
1535            assert_eq!(env.args.len(), 1, "{name} arity");
1536            assert_eq!(env.args[0].kind, ArgKind::Bracket);
1537        }
1538    }
1539
1540    #[test]
1541    fn block_flag_is_explicit_or_derived() {
1542        let db = builtin();
1543        assert!(db.environment("figure").unwrap().block());
1544        assert!(db.environment("center").unwrap().block());
1545        assert!(db.environment("verbatim").unwrap().block());
1546        assert!(db.environment("equation").unwrap().block());
1547        assert!(db.environment("itemize").unwrap().block());
1548        assert!(db.environment("document").unwrap().block());
1549        assert!(db.environment("center").unwrap().reflow());
1550    }
1551
1552    #[test]
1553    fn doc_ltxdoc_signatures() {
1554        let db = builtin();
1555        for name in ["DocInput", "DescribeMacro", "DescribeEnv", "StopEventually"] {
1556            let cmd = db
1557                .command(name)
1558                .unwrap_or_else(|| panic!("{name} signature"));
1559            assert_eq!(cmd.args.len(), 1, "{name} arity");
1560            assert!(cmd.args[0].required, "{name} arg is mandatory");
1561        }
1562        for name in ["macro", "environment"] {
1563            let env = db.environment(name).unwrap_or_else(|| panic!("{name} env"));
1564            assert_eq!(env.args.len(), 1, "{name} arity");
1565            assert!(env.block(), "{name} is a block env");
1566            assert!(env.reflow(), "{name} body reflows as prose");
1567            assert!(!env.code, "{name} is not a code env");
1568        }
1569        for name in ["macrocode", "macrocode*"] {
1570            let env = db.environment(name).unwrap_or_else(|| panic!("{name} env"));
1571            assert!(env.code, "{name} is code");
1572            assert!(!env.reflow(), "{name} never reflows");
1573            assert!(!env.verbatim_body, "{name} body is parsed, not verbatim");
1574            assert!(env.block(), "{name} is a block env");
1575        }
1576    }
1577
1578    #[test]
1579    fn code_flag_parses_and_drives_reflow() {
1580        let db = parse(
1581            r#"{ "environments": {
1582                "plain": {},
1583                "codeish": { "code": true }
1584            } }"#,
1585        )
1586        .expect("valid code schema");
1587        let plain = db.environment("plain").unwrap();
1588        assert!(!plain.code);
1589        assert!(plain.reflow());
1590        let codeish = db.environment("codeish").unwrap();
1591        assert!(codeish.code);
1592        assert!(!codeish.reflow());
1593        assert!(!codeish.verbatim_body);
1594    }
1595
1596    #[test]
1597    fn statement_body_flag_parses_and_drives_reflow() {
1598        let db = parse(
1599            r#"{ "environments": {
1600                "plain": {},
1601                "stmt": { "statementBody": true }
1602            } }"#,
1603        )
1604        .expect("valid statementBody schema");
1605        let plain = db.environment("plain").unwrap();
1606        assert!(!plain.statement_body);
1607        assert!(plain.reflow());
1608        let stmt = db.environment("stmt").unwrap();
1609        assert!(stmt.statement_body);
1610        assert!(!stmt.reflow());
1611        assert!(!stmt.code);
1612        assert!(!stmt.verbatim_body);
1613    }
1614
1615    #[test]
1616    fn gathered_is_math_without_alignment_grid_semantics() {
1617        let gathered = builtin().environment("gathered").expect("curated");
1618        assert!(gathered.math);
1619        assert!(!gathered.align);
1620    }
1621
1622    #[test]
1623    fn tabularray_environment_arguments_are_curated_keyvals() {
1624        let db = builtin();
1625        for name in ["tblr", "longtblr", "talltblr"] {
1626            let env = db.environment(name).unwrap_or_else(|| panic!("{name} env"));
1627            assert!(env.block(), "{name} is a block environment");
1628            assert!(!env.align, "{name} has no raw column-spec argument");
1629            assert_eq!(env.args.len(), 2, "{name} takes outer and inner specs");
1630            assert_eq!(env.args[0].kind, ArgKind::Bracket, "{name} outer spec");
1631            assert!(!env.args[0].required, "{name} outer spec is optional");
1632            assert_eq!(env.args[1].kind, ArgKind::Brace, "{name} inner spec");
1633            assert!(env.args[1].required, "{name} inner spec is mandatory");
1634            assert!(
1635                env.args
1636                    .iter()
1637                    .all(|arg| arg.content == ContentKind::Keyval),
1638                "{name} specs are keyval lists"
1639            );
1640        }
1641    }
1642
1643    #[test]
1644    fn label_key_flag_is_curated_and_defaults_false() {
1645        let db = parse(
1646            r#"{
1647              "environments": {
1648                "plain": {},
1649                "labels": { "labelKey": true }
1650              }
1651            }"#,
1652        )
1653        .expect("valid labelKey schema");
1654        assert!(!db.environment("plain").unwrap().label_key);
1655        assert!(db.environment("labels").unwrap().label_key);
1656
1657        assert!(builtin().environment("frame").unwrap().label_key);
1658        assert!(builtin().environment("lstlisting").unwrap().label_key);
1659        assert!(!builtin().environment("tikzpicture").unwrap().label_key);
1660    }
1661
1662    #[test]
1663    fn caption_container_flag_is_curated_and_defaults_false() {
1664        let db = parse(
1665            r#"{
1666              "environments": {
1667                "plain": {},
1668                "captioned": { "captionContainer": true }
1669              }
1670            }"#,
1671        )
1672        .expect("valid captionContainer schema");
1673        assert!(!db.environment("plain").unwrap().caption_container);
1674        assert!(db.environment("captioned").unwrap().caption_container);
1675
1676        assert!(builtin().environment("minipage").unwrap().caption_container);
1677        assert!(!builtin().environment("center").unwrap().caption_container);
1678    }
1679
1680    #[test]
1681    fn picture_environments_are_statement_bodies() {
1682        let db = builtin();
1683        for name in [
1684            "tikzpicture",
1685            "pgfpicture",
1686            "scope",
1687            "pgfonlayer",
1688            "axis",
1689            "loglogaxis",
1690            "semilogxaxis",
1691            "semilogyaxis",
1692            "groupplot",
1693            "polaraxis",
1694            "ternaryaxis",
1695        ] {
1696            let env = db.environment(name).unwrap_or_else(|| panic!("{name} env"));
1697            assert!(env.statement_body, "{name} holds statements, not prose");
1698            assert!(!env.reflow(), "{name} never reflows as prose");
1699            assert!(!env.code, "{name} is not `.dtx` macrocode");
1700            assert!(!env.verbatim_body, "{name} body is parsed, not verbatim");
1701            assert!(env.block(), "{name} is a block env");
1702        }
1703        for name in [
1704            "tikzpicture",
1705            "scope",
1706            "axis",
1707            "loglogaxis",
1708            "semilogxaxis",
1709            "semilogyaxis",
1710            "groupplot",
1711            "polaraxis",
1712            "ternaryaxis",
1713        ] {
1714            let env = db.environment(name).unwrap();
1715            assert_eq!(env.args.len(), 1, "{name} takes an option bracket");
1716            assert!(!env.args[0].required, "{name} option is optional");
1717            assert_eq!(env.args[0].content, ContentKind::Keyval, "{name} keyval");
1718        }
1719        assert_eq!(db.environment("pgfonlayer").unwrap().args.len(), 1);
1720        assert!(db.environment("pgfonlayer").unwrap().args[0].required);
1721        assert!(db.environment("pgfpicture").unwrap().args.is_empty());
1722    }
1723
1724    #[test]
1725    fn unknown_names_resolve_to_none() {
1726        let db = builtin();
1727        assert!(db.command("definitelynotacommand").is_none());
1728        assert!(db.environment("definitelynotanenv").is_none());
1729    }
1730
1731    #[test]
1732    fn rejects_unknown_fields() {
1733        let err = parse(r#"{ "commands": { "x": { "sektioning": 2 } } }"#);
1734        assert!(err.is_err());
1735    }
1736
1737    #[test]
1738    fn empty_document_is_valid() {
1739        let db = parse("{}").expect("empty object is valid");
1740        assert!(db.command("anything").is_none());
1741    }
1742
1743    #[test]
1744    fn cwl_tier_loads_and_covers_long_tail() {
1745        let db = cwl();
1746        assert!(db.command("siunitx").is_some() || db.command("SI").is_some());
1747        assert!(
1748            db.command_names().count() > 1000,
1749            "the CWL subset should contribute a broad name set"
1750        );
1751    }
1752
1753    #[test]
1754    fn cwl_entries_carry_only_arity_no_behavior_flags() {
1755        let db = cwl();
1756        for sig in db.command_sigs() {
1757            assert!(sig.sectioning.is_none());
1758            assert!(!sig.verbatim && !sig.rule && !sig.inline && !sig.block);
1759            assert!(sig.args.iter().all(|a| match a.content {
1760                ContentKind::Opaque => true,
1761                ContentKind::Keyval => !a.required,
1762                _ => false,
1763            }));
1764        }
1765        for sig in db.environment_sigs() {
1766            assert!(!sig.verbatim_body && !sig.math && !sig.code && !sig.align);
1767            assert!(!sig.no_indent && !sig.list && !sig.block());
1768            assert!(sig.outline.is_none());
1769            assert!(sig.args.iter().all(|a| match a.content {
1770                ContentKind::Opaque => true,
1771                ContentKind::Keyval => !a.required,
1772                _ => false,
1773            }));
1774        }
1775    }
1776
1777    #[test]
1778    fn curated_builtin_wins_over_cwl_tier() {
1779        let empty = SignatureDb::default();
1780        let sigs = Signatures::new(&empty);
1781        assert!(
1782            cwl().command("section").is_some(),
1783            "test premise: in CWL tier"
1784        );
1785        assert_eq!(sigs.command("section").unwrap().sectioning, Some(2));
1786    }
1787
1788    #[test]
1789    fn cwl_only_name_resolves_through_signatures() {
1790        let empty = SignatureDb::default();
1791        let sigs = Signatures::new(&empty);
1792        let Some(name) = cwl()
1793            .command_names()
1794            .find(|n| builtin().command(n).is_none())
1795        else {
1796            panic!("expected at least one CWL-only command name");
1797        };
1798        let sig = sigs.command(name).expect("CWL-only name resolves");
1799        assert!(sig.sectioning.is_none() && !sig.inline && !sig.verbatim && !sig.block);
1800    }
1801
1802    fn db_with_command(name: &str) -> SignatureDb {
1803        let mut db = SignatureDb::default();
1804        db.insert_command(name, CommandSig::default());
1805        db
1806    }
1807
1808    #[test]
1809    fn merge_with_package_origin_records_origin() {
1810        let mut scope = SignatureDb::default();
1811        scope.merge_from(&db_with_command("myfoo"), Some("mypkg"));
1812        assert_eq!(scope.command_origin("myfoo"), Some("mypkg"));
1813        assert!(scope.command("myfoo").is_some());
1814    }
1815
1816    #[test]
1817    fn plain_merge_clears_origin_on_shadow() {
1818        let mut scope = SignatureDb::default();
1819        scope.merge_from(&db_with_command("dup"), Some("mypkg"));
1820        scope.merge_from(&db_with_command("dup"), None);
1821        assert_eq!(scope.command_origin("dup"), None);
1822        assert!(scope.command("dup").is_some());
1823    }
1824
1825    #[test]
1826    fn later_package_merge_overwrites_origin() {
1827        let mut scope = SignatureDb::default();
1828        scope.merge_from(&db_with_command("shared"), Some("first"));
1829        scope.merge_from(&db_with_command("shared"), Some("second"));
1830        assert_eq!(scope.command_origin("shared"), Some("second"));
1831    }
1832
1833    #[test]
1834    fn insert_clears_origin() {
1835        let mut scope = SignatureDb::default();
1836        scope.merge_from(&db_with_command("myfoo"), Some("mypkg"));
1837        scope.insert_command("myfoo", CommandSig::default());
1838        assert_eq!(scope.command_origin("myfoo"), None);
1839    }
1840
1841    #[test]
1842    fn merge_propagates_existing_origins() {
1843        let mut inner = SignatureDb::default();
1844        inner.merge_from(&db_with_command("dep"), Some("deppkg"));
1845        let mut scope = SignatureDb::default();
1846        scope.merge_from(&inner, None);
1847        assert_eq!(scope.command_origin("dep"), Some("deppkg"));
1848    }
1849}