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