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