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