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). This is the place where *meaning* is assigned to
4//! names, kept strictly out of the parser (AGENTS.md decision #2).
5//!
6//! The data is fully static, so it lives in a process-wide [`LazyLock`],
7//! consulted directly. Per-file `\newcommand`/`\newenvironment`/xparse
8//! signatures are scanned by [`super::define`] into a separate, per-document
9//! [`SignatureDb`] and overlaid via [`Signatures`] (scanned-first, built-in
10//! fallback); the greedy parser's argument attachment is unaffected either way. A
11//! salsa input only becomes necessary once that overlay must be cached across
12//! queries (a later item, when an LSP consumer appears).
13//!
14//! ## Source of truth: one granular JSON file
15//!
16//! The built-in data is a single curated JSON file (`data/signatures.json`,
17//! [`include_str!`]-ed, [`serde`]-deserialized) holding *all* the metadata in one
18//! typed place — argument shapes *and* sectioning level / verbatim-ness /
19//! math-ness together, keyed by name. This is the high-precision tier we maintain
20//! by hand.
21//!
22//! Lower-precision external sources layer *underneath* this, ingested into the
23//! same schema rather than replacing it. The TeXstudio/Kile **CWL corpus** is one
24//! such tier: a
25//! converter (`scripts/gen_cwl_signatures.py`) harvests command/environment names
26//! and argument shapes from a curated package subset into `data/cwl_signatures.json`,
27//! exposed by [`cwl`] and consulted *under* [`builtin`]. CWL is an import format,
28//! never the source of truth: only names and arity cross over (every behavior flag
29//! stays default), so it widens completion and arity coverage without its
30//! low-confidence data reaching a lexer/formatter/outline behavior decision. The
31//! file is compiled into a `phf` perfect-hash map at build time (`build.rs`) and
32//! `include!`-ed as read-only statics — zero runtime parse or decompress.
33
34use std::borrow::Cow;
35use std::collections::HashMap;
36use std::sync::LazyLock;
37
38use serde::Deserialize;
39use smol_str::SmolStr;
40
41/// Which bracket delimits an argument. TeX has no other real argument grouping at
42/// the surface level the formatter cares about.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ArgKind {
45    /// A mandatory `{…}` argument.
46    Brace,
47    /// An optional `[…]` argument.
48    Bracket,
49}
50
51/// How the formatter treats an argument's *content* — its whitespace and break
52/// policy. Exactly one kind per slot (replaces the former mutually-exclusive
53/// `prose`/`collapse` bools). Only meaningful for the formatter; the parser
54/// ignores it (AGENTS.md decision #2).
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
56pub enum ContentKind {
57    /// Left exactly as authored: names, identifiers, code, or option lists
58    /// (`\label`, the `\newcommand` body). The default, so an unmarked argument
59    /// never reflows — for most arguments interior whitespace can matter (a
60    /// `minipage`/`\parbox` body, a label key).
61    #[default]
62    Opaque,
63    /// Running prose the formatter may reflow to the line width (e.g. a
64    /// `\footnote`/`\caption` body, a sectioning title).
65    Prose,
66    /// A comma-separated token list whose interior whitespace is *insignificant*,
67    /// so the formatter may collapse a multi-line authored form to a single line
68    /// (a `\citep`/`\cite` key list). Unlike [`Prose`](ContentKind::Prose), the
69    /// content is *not* reflowed to the width: the keys stay together as one atom;
70    /// only incidental source line breaks inside the braces are normalized away,
71    /// so `\citep{\n a,\n b\n}` formats identically to `\citep{a, b}` (determinism).
72    TokenList,
73}
74
75/// One argument slot in a command/environment signature.
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct ArgSpec {
78    /// `true` for a mandatory `{…}` argument, `false` for an optional `[…]` one.
79    pub required: bool,
80    pub kind: ArgKind,
81    /// How the formatter treats this argument's content. See [`ContentKind`].
82    pub content: ContentKind,
83}
84
85/// The signature of a control sequence.
86#[derive(Debug, Clone, PartialEq, Eq, Default)]
87pub struct CommandSig {
88    /// The ordered argument slots. A [`Cow`] so the build-time CWL tier can hold a
89    /// `'static` slice baked into the binary (see [`command`]) while the runtime
90    /// builtin/scanned paths own a `Vec`.
91    pub args: Cow<'static, [ArgSpec]>,
92    /// `Some(level)` for a sectioning command, where `0` is the outermost
93    /// (`\part`) and larger numbers nest deeper. Relative depth only.
94    pub sectioning: Option<u8>,
95    /// `true` for commands whose final argument is raw text the formatter must
96    /// not reshape (`\verb`, `\lstinline`, `\url`, `\code`). The lexer captures
97    /// that argument as one `VERB` token. Any leading, non-verbatim arguments
98    /// (e.g. `\mintinline`'s language) are declared in `args`; the verbatim
99    /// argument itself is implicit and not listed there.
100    pub verbatim: bool,
101    /// `true` when the verbatim argument may also be a `\verb`-style delimiter
102    /// run (`\lstinline|…|`, `\url|…|`) instead of a balanced `{…}` group.
103    /// Braced-only commands (`\code`, `\path`) capture nothing when no brace
104    /// follows and lex normally — the name may be an unrelated user macro
105    /// (`\code` as a math operator, TikZ's `\path (0,0)`), and a wrong
106    /// delimiter capture swallows text across the line. Only meaningful when
107    /// `verbatim` is set.
108    pub verbatim_delimited: bool,
109    /// `true` for horizontal-rule commands (`\hline`, `\midrule`, `\toprule`, …).
110    /// In an alignment environment a physical line made up solely of rule
111    /// commands is a *passthrough* line the formatter keeps between grid rows
112    /// rather than treating as a cell (see the grid lowering in `formatter`).
113    pub rule: bool,
114    /// `true` for *inline* commands that sit in running text (`\citep`, `\ref`,
115    /// `\emph`, `\textbf`, …) rather than occupying their own line. Paragraph reflow
116    /// treats such a command as an atom that flows into the fill even when the author
117    /// isolated it on its own source line, instead of preserving it as a
118    /// command-only line (the way a `\usepackage`/`\section` line is kept). For a
119    /// command that *also* has a `prose` argument this additionally flattens the
120    /// command into the paragraph so its body wraps as running text with the `{`/`}`
121    /// glued to the adjacent words. Block-level commands that head their own line
122    /// (`\section`, `\caption`) leave this `false`. Only meaningful to the formatter;
123    /// the parser ignores it.
124    pub inline: bool,
125}
126
127/// How an environment appears in the document-symbol outline, if at all. A small
128/// curated category over the `block` environments: only floats and theorem-likes
129/// earn an outline entry, so layout environments (`center`, `quote`, `frame`, …)
130/// stay out of the symbol tree. Drives `SymbolKind` selection in the LSP layer.
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum OutlineKind {
133    /// A float (`figure`, `table`, and their starred forms).
134    Float,
135    /// A theorem-like environment (`theorem`, `lemma`, `proof`, …).
136    Theorem,
137}
138
139/// The signature of an environment.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct EnvironmentSig {
142    /// The ordered argument slots that follow `\begin{name}` (e.g. `tabular`'s
143    /// column spec), *excluding* the name group itself. A [`Cow`] for the same
144    /// reason as [`CommandSig::args`]: a `'static` slice for the CWL tier, an owned
145    /// `Vec` for the runtime paths.
146    pub args: Cow<'static, [ArgSpec]>,
147    /// `true` for environments whose body is raw text (`verbatim`, `lstlisting`,
148    /// `minted`, …) and must never be reflowed.
149    pub verbatim_body: bool,
150    /// `true` for environments whose *name argument* is xparse `v`-type (l3doc's
151    /// `macro`/`function`/`variable`, declared `{ O{} +v }`): besides the usual
152    /// braced form, the argument may be delimited `\verb`-style
153    /// (`\begin{macro}+\@@_compile_{:+`), chosen precisely when the name holds
154    /// unbalanced braces. The lexer captures that delimited form as one opaque
155    /// `VERB` token; the braced form lexes normally. Curated only — a wrong
156    /// grant swallows real text, so the CWL/user tiers never set it.
157    pub verbatim_arg: bool,
158    /// `true` for math environments (`equation`, `align`, …).
159    pub math: bool,
160    /// `true` for environments whose body is *real parsed code*, not prose —
161    /// the doc/ltxdoc `macrocode`/`macrocode*` (whose body is LaTeX/expl3 code,
162    /// parsed and re-lexed under the package regime, *not* an opaque verbatim
163    /// blob like `verbatim_body`). The formatter preserves the body's layout and
164    /// never reflows it as prose; the distinction from `verbatim_body` is that the
165    /// content is a real CST, not a single `VERBATIM_BODY` token.
166    pub code: bool,
167    /// `true` for alignment environments whose `&` columns the formatter lays out
168    /// into a grid (`align`, `pmatrix`, …). Independent of `math`: every flagged
169    /// environment here is also math, but the formatter consults this flag, not
170    /// `math`, to decide column alignment.
171    pub align: bool,
172    /// `true` when the body is ordinary prose the formatter may reflow. Derived as
173    /// `!(verbatim_body || math || code)`. (Reflow itself is a later item; this is
174    /// the recorded intent.)
175    pub reflow: bool,
176    /// `true` for sectioning-level *containers* whose body the formatter must
177    /// *not* indent (`document`, the appendix-package `appendix`, …). The shared
178    /// property is that the body is whole sections/paragraphs — content at the
179    /// same structural altitude as the sections the container sits among, not leaf
180    /// content like a `figure` or `minipage` — which is conventionally written
181    /// flush to the margin. The body is still laid out on its own lines, just at
182    /// the surrounding indentation level rather than nested one step in.
183    pub no_indent: bool,
184    /// `true` for list environments (`itemize`, `enumerate`, `description`, …)
185    /// whose `\item`s the formatter lays out one per line, reflowing each item's
186    /// body with continuation lines hanging-indented under the item text.
187    pub list: bool,
188    /// `true` for block/display environments that occupy their own vertical space
189    /// (`figure`, `center`, lists, display math, verbatim, …). The parser uses this
190    /// to avoid wrapping a lone such environment in a redundant `PARAGRAPH`. Derived
191    /// as `block_explicit || math || list || no_indent`.
192    pub block: bool,
193    /// `Some(_)` for an environment that earns a document-symbol outline entry — a
194    /// float or a theorem-like. `None` for everything else. Only meaningful to the
195    /// language server's `documentSymbol`; the parser and formatter ignore it.
196    pub outline: Option<OutlineKind>,
197}
198
199// --- const constructors (shared by the runtime JSON path and build-time codegen)
200//
201// The build script (`build.rs`) emits the CWL tier as a `phf` map whose values
202// are calls to these `const fn`s, so the static data is baked into the binary
203// with no runtime parse (see `cwl`). They are the single home of the `reflow`/
204// `block` *derivations*, reused by `From<RawEnvironment>` below so the JSON path
205// (builtin DB, scanned defs) and the codegen path can never derive them
206// differently.
207
208/// `reflow`: a body is reflowable prose unless it is verbatim, math, or code.
209pub(crate) const fn derive_reflow(verbatim_body: bool, math: bool, code: bool) -> bool {
210    !(verbatim_body || math || code)
211}
212
213/// `block`: math, lists, and no-indent containers are inherently block/display;
214/// the explicit flag covers the rest (figure, center, verbatim, theorem-likes, …).
215pub(crate) const fn derive_block(
216    block_explicit: bool,
217    math: bool,
218    list: bool,
219    no_indent: bool,
220) -> bool {
221    block_explicit || math || list || no_indent
222}
223
224/// One argument slot, const-constructible for the codegen path.
225pub(crate) const fn arg(required: bool, kind: ArgKind, content: ContentKind) -> ArgSpec {
226    ArgSpec {
227        required,
228        kind,
229        content,
230    }
231}
232
233/// A command signature over a `'static` argument slice (the codegen path).
234pub(crate) const fn command(
235    args: &'static [ArgSpec],
236    sectioning: Option<u8>,
237    verbatim: bool,
238    rule: bool,
239    inline: bool,
240) -> CommandSig {
241    CommandSig {
242        args: Cow::Borrowed(args),
243        sectioning,
244        verbatim,
245        // The codegen (CWL) tier is arity-only, so the delimiter facet — like
246        // every behavior flag — never comes from it.
247        verbatim_delimited: false,
248        rule,
249        inline,
250    }
251}
252
253/// An environment signature over a `'static` argument slice (the codegen path),
254/// applying the same `reflow`/`block` derivations as the JSON path.
255#[allow(clippy::too_many_arguments)]
256pub(crate) const fn environment(
257    args: &'static [ArgSpec],
258    verbatim_body: bool,
259    math: bool,
260    code: bool,
261    align: bool,
262    no_indent: bool,
263    list: bool,
264    block_explicit: bool,
265    outline: Option<OutlineKind>,
266) -> EnvironmentSig {
267    EnvironmentSig {
268        args: Cow::Borrowed(args),
269        verbatim_body,
270        // The codegen (CWL) tier is arity-only, so the verbatim-argument facet —
271        // like every behavior flag — never comes from it.
272        verbatim_arg: false,
273        math,
274        code,
275        align,
276        reflow: derive_reflow(verbatim_body, math, code),
277        no_indent,
278        list,
279        block: derive_block(block_explicit, math, list, no_indent),
280        outline,
281    }
282}
283
284/// The built-in command and environment signatures, keyed by name (without the
285/// leading `\` for commands, the bare name for environments). Case-sensitive, as
286/// LaTeX names are (`Verbatim` ≠ `verbatim`).
287#[derive(Debug, Default, Clone, PartialEq, Eq)]
288pub struct SignatureDb {
289    commands: HashMap<SmolStr, CommandSig>,
290    environments: HashMap<SmolStr, EnvironmentSig>,
291    /// Which loaded package (by file stem) a command signature came from, when
292    /// it was merged via [`merge_from_package`](Self::merge_from_package).
293    /// Absent for the document's own definitions and for every static tier
294    /// (built-in/CWL DBs never carry origins). A side map rather than a
295    /// `CommandSig` field so the phf-generated static tables stay untouched.
296    command_origins: HashMap<SmolStr, SmolStr>,
297    /// The environment mirror of [`command_origins`](Self::command_origins).
298    environment_origins: HashMap<SmolStr, SmolStr>,
299}
300
301impl SignatureDb {
302    /// The signature of command `name` (without the leading `\`), if known.
303    pub fn command(&self, name: &str) -> Option<&CommandSig> {
304        self.commands.get(name)
305    }
306
307    /// The signature of environment `name`, if known.
308    pub fn environment(&self, name: &str) -> Option<&EnvironmentSig> {
309        self.environments.get(name)
310    }
311
312    /// All known command names (without the leading `\`), in arbitrary order.
313    /// Backs name completion, which unions these with the per-document scanned
314    /// definitions; the lookup methods stay the only refinement path.
315    pub fn command_names(&self) -> impl Iterator<Item = &str> {
316        self.commands.keys().map(SmolStr::as_str)
317    }
318
319    /// All known environment names, in arbitrary order. See [`command_names`].
320    ///
321    /// [`command_names`]: Self::command_names
322    pub fn environment_names(&self) -> impl Iterator<Item = &str> {
323        self.environments.keys().map(SmolStr::as_str)
324    }
325
326    /// The package (file stem) whose merge supplied the current signature of
327    /// command `name`, if it came from a package
328    /// ([`merge_from_package`](Self::merge_from_package)) rather than the
329    /// document or a static tier.
330    pub fn command_origin(&self, name: &str) -> Option<&str> {
331        self.command_origins.get(name).map(SmolStr::as_str)
332    }
333
334    /// The environment mirror of [`command_origin`](Self::command_origin).
335    pub fn environment_origin(&self, name: &str) -> Option<&str> {
336        self.environment_origins.get(name).map(SmolStr::as_str)
337    }
338
339    /// Record a command signature, replacing any existing entry for `name`. Used
340    /// by the per-file definition scan ([`super::define`]) to populate a fresh DB;
341    /// the built-in DB is built from JSON and never mutated. A redefinition wins,
342    /// mirroring TeX's last-`\newcommand`-wins behavior; any recorded package
343    /// origin is cleared, since it described the entry being replaced.
344    pub fn insert_command(&mut self, name: impl Into<SmolStr>, sig: CommandSig) {
345        let name = name.into();
346        self.command_origins.remove(&name);
347        self.commands.insert(name, sig);
348    }
349
350    /// Record an environment signature, replacing any existing entry for `name`.
351    pub fn insert_environment(&mut self, name: impl Into<SmolStr>, sig: EnvironmentSig) {
352        let name = name.into();
353        self.environment_origins.remove(&name);
354        self.environments.insert(name, sig);
355    }
356
357    /// Merge every command and environment of `other` into `self`, with `other`
358    /// winning on a name clash (last-definition-wins, like an individual
359    /// `insert_*`). Used to fold a loaded package's scanned definitions into a
360    /// document's merged signature scope; the caller orders the merges so the
361    /// document's own definitions are applied last and override any package.
362    ///
363    /// Origins always describe the *current* entry: each merged name takes
364    /// `other`'s origin when it has one, and clears any stale one of `self`'s
365    /// otherwise — so the document overlay (scanned defs carry no origins)
366    /// automatically strips package provenance from a shadowed name.
367    pub fn merge_from(&mut self, other: &SignatureDb) {
368        for (name, sig) in &other.commands {
369            match other.command_origins.get(name) {
370                Some(origin) => {
371                    self.command_origins.insert(name.clone(), origin.clone());
372                }
373                None => {
374                    self.command_origins.remove(name);
375                }
376            }
377            self.commands.insert(name.clone(), sig.clone());
378        }
379        for (name, sig) in &other.environments {
380            match other.environment_origins.get(name) {
381                Some(origin) => {
382                    self.environment_origins
383                        .insert(name.clone(), origin.clone());
384                }
385                None => {
386                    self.environment_origins.remove(name);
387                }
388            }
389            self.environments.insert(name.clone(), sig.clone());
390        }
391    }
392
393    /// Like [`merge_from`](Self::merge_from), additionally recording `origin`
394    /// (a package file stem, e.g. `mypkg`) as the provenance of every merged
395    /// name. Used when folding a loaded package's scanned definitions into a
396    /// document scope, so hover can name the defining package.
397    /// Package-over-package: the last merge wins, consistent with the
398    /// signature overwrite itself.
399    pub fn merge_from_package(&mut self, other: &SignatureDb, origin: &str) {
400        for (name, sig) in &other.commands {
401            self.command_origins
402                .insert(name.clone(), SmolStr::from(origin));
403            self.commands.insert(name.clone(), sig.clone());
404        }
405        for (name, sig) in &other.environments {
406            self.environment_origins
407                .insert(name.clone(), SmolStr::from(origin));
408            self.environments.insert(name.clone(), sig.clone());
409        }
410    }
411}
412
413/// A two-tier signature lookup: a per-document [`SignatureDb`] of scanned
414/// `\newcommand`/`\newenvironment`/xparse definitions consulted first, falling back
415/// to the process-wide [`builtin`] DB. Cheap to copy (it borrows the scanned DB),
416/// so it threads through the formatter's lowering like a context handle.
417///
418/// Scanned-first matches TeX scoping intuition: a locally (re)defined command
419/// shadows a built-in of the same name. (We do not yet model *where* a definition
420/// becomes visible — a whole-file union — which is sound for the formatter's arity
421/// needs; lexical/conditional visibility is out of scope, per AGENTS.md #1.)
422#[derive(Debug, Clone, Copy)]
423pub struct Signatures<'a> {
424    user: &'a SignatureDb,
425}
426
427impl<'a> Signatures<'a> {
428    /// Resolve against `user` first, then the built-in DB.
429    pub fn new(user: &'a SignatureDb) -> Self {
430        Self { user }
431    }
432
433    /// The signature of command `name`: scanned definition first, then the curated
434    /// built-in, then the bulk CWL tier. CWL is consulted last and contributes only
435    /// argument arity (its behavior flags are all default), so a CWL-only command is
436    /// laid out like any unknown command, just with its argument count known.
437    pub fn command(&self, name: &str) -> Option<&'a CommandSig> {
438        self.user
439            .command(name)
440            .or_else(|| builtin().command(name))
441            .or_else(|| cwl().command(name))
442    }
443
444    /// The signature of environment `name`: scanned, then built-in, then CWL. See
445    /// [`command`] for why the CWL tier is safe to consult here.
446    ///
447    /// [`command`]: Self::command
448    pub fn environment(&self, name: &str) -> Option<&'a EnvironmentSig> {
449        self.user
450            .environment(name)
451            .or_else(|| builtin().environment(name))
452            .or_else(|| cwl().environment(name))
453    }
454}
455
456/// The bundled, curated signature data (see module docs).
457const SIGNATURES_JSON: &str = include_str!("../../data/signatures.json");
458
459static DB: LazyLock<SignatureDb> =
460    LazyLock::new(|| parse(SIGNATURES_JSON).expect("bundled data/signatures.json must be valid"));
461
462/// The process-wide built-in signature database.
463pub fn builtin() -> &'static SignatureDb {
464    &DB
465}
466
467/// The type of the build-generated CWL maps: a name-keyed perfect-hash map. The
468/// generated `static`s are spelled with this alias, so the dependency on `phf` is
469/// visible in checked-in source (not only in the generated file).
470type CwlSigMap<V> = phf::Map<&'static str, V>;
471
472// The bulk CWL tier is generated by `build.rs` from `data/cwl_signatures.json`
473// into two `CwlSigMap`s (`CWL_COMMANDS`, `CWL_ENVIRONMENTS`) whose values are
474// `command(...)`/`environment(...)`/`arg(...)` const-constructor calls — so the
475// data is baked into the binary as read-only statics with *zero* runtime parse
476// or decompress (it was a ~4.5 ms one-time `LazyLock` decompress+JSON-parse; now
477// ~0). The included file references the const constructors and `CwlSigMap` here.
478include!(concat!(env!("OUT_DIR"), "/cwl_signatures.rs"));
479
480/// Handle to the lower-precision **CWL tier**: a broad set of command/environment
481/// names plus argument shapes harvested from the TeXstudio CWL corpus (a curated
482/// package subset; see `scripts/gen_cwl_signatures.py`). It carries *names and
483/// arity only* — every behavior flag (`content`/`verbatim`/`sectioning`/`math`/…) is
484/// left at its default — so it can widen completion and the formatter's arity
485/// lookup without its low-confidence data ever reaching a lexer/outline behavior
486/// decision. Consulted strictly *under* [`builtin`] (via [`Signatures`]); the
487/// curated tier always wins. A ZST over the generated `phf` statics, so its query
488/// methods mirror [`SignatureDb`]'s without owning a heap map.
489#[derive(Debug, Clone, Copy)]
490pub struct CwlDb;
491
492impl CwlDb {
493    /// The signature of command `name` (without the leading `\`), if in the tier.
494    pub fn command(&self, name: &str) -> Option<&'static CommandSig> {
495        CWL_COMMANDS.get(name)
496    }
497
498    /// The signature of environment `name`, if in the tier.
499    pub fn environment(&self, name: &str) -> Option<&'static EnvironmentSig> {
500        CWL_ENVIRONMENTS.get(name)
501    }
502
503    /// All CWL command names (without the leading `\`), in arbitrary order. The
504    /// `&str` lifetime is tied to `&self` (not `'static`) so it unifies with the
505    /// borrowed scanned-definition names in a completion `chain` (see
506    /// `completion::command_candidates`), exactly like [`SignatureDb::command_names`].
507    pub fn command_names(&self) -> impl Iterator<Item = &str> {
508        CWL_COMMANDS.keys().map(|name| &**name)
509    }
510
511    /// All CWL environment names, in arbitrary order. See [`command_names`].
512    ///
513    /// [`command_names`]: Self::command_names
514    pub fn environment_names(&self) -> impl Iterator<Item = &str> {
515        CWL_ENVIRONMENTS.keys().map(|name| &**name)
516    }
517
518    /// All CWL command signatures (introspection; backs the invariant tests).
519    pub fn command_sigs(&self) -> impl Iterator<Item = &'static CommandSig> {
520        CWL_COMMANDS.values()
521    }
522
523    /// All CWL environment signatures (introspection; backs the invariant tests).
524    pub fn environment_sigs(&self) -> impl Iterator<Item = &'static EnvironmentSig> {
525        CWL_ENVIRONMENTS.values()
526    }
527}
528
529static CWL: CwlDb = CwlDb;
530
531/// The process-wide CWL tier (see [`CwlDb`]).
532pub fn cwl() -> &'static CwlDb {
533    &CWL
534}
535
536// The baked `.sty`/`.cls` **name** lists for `\usepackage`/`\documentclass`
537// completion, generated by `scripts/gen_package_names.py` from TeX Live's tlpdb
538// (see that script and `data/package_names.txt`). Names only — no arity/flags — a
539// read-only tier philosophically identical to the CWL data, never a runtime distro
540// query. Lines starting with `#` and the `---` primary/secondary separator are
541// skipped; the file order is the completion *rank* (namesake/common names first).
542const PACKAGE_NAMES_TXT: &str = include_str!("../../data/package_names.txt");
543const CLASS_NAMES_TXT: &str = include_str!("../../data/class_names.txt");
544
545static PACKAGE_NAMES: LazyLock<Vec<&'static str>> =
546    LazyLock::new(|| parse_name_list(PACKAGE_NAMES_TXT));
547static CLASS_NAMES: LazyLock<Vec<&'static str>> =
548    LazyLock::new(|| parse_name_list(CLASS_NAMES_TXT));
549
550/// Parse a baked name list into names in rank order (primary block, then the
551/// long tail), dropping the `#` header comments and the `---` separator line.
552fn parse_name_list(text: &'static str) -> Vec<&'static str> {
553    text.lines()
554        .filter(|line| !line.is_empty() && !line.starts_with('#') && *line != "---")
555        .collect()
556}
557
558/// All known `.sty` package name stems for `\usepackage` completion, in rank order
559/// (namesake/common names first). See [`PACKAGE_NAMES_TXT`].
560pub fn package_names() -> &'static [&'static str] {
561    &PACKAGE_NAMES
562}
563
564/// All known `.cls` class name stems for `\documentclass` completion, in rank
565/// order. See [`CLASS_NAMES_TXT`].
566pub fn class_names() -> &'static [&'static str] {
567    &CLASS_NAMES
568}
569
570// Static color and TikZ/PGF library name lists for `\color`/`\textcolor`/
571// `\definecolor` and `\usetikzlibrary`/`\usepgflibrary` completion. Small,
572// hand-curated, and option-agnostic (advisory completion), so a plain
573// `LazyLock` serde parse suffices — the bib_fields.json posture, not the
574// phf-baked package tiers. The owned `String`s live for the process in the
575// `LazyLock`, so each accessor's paired `Vec<&'static str>` can borrow them and
576// hand back `&'static [&'static str]` like the name lists above.
577const COLORS_JSON: &str = include_str!("../../data/colors.json");
578const TIKZ_LIBRARIES_JSON: &str = include_str!("../../data/tikz_libraries.json");
579const ARG_ENUMS_JSON: &str = include_str!("../../data/arg_enums.json");
580
581/// `data/colors.json`: the built-in color-name and color-model lists.
582#[derive(Deserialize)]
583struct ColorsData {
584    names: Vec<String>,
585    models: Vec<String>,
586}
587
588/// `data/tikz_libraries.json`: the built-in TikZ and PGF library-name lists.
589#[derive(Deserialize)]
590struct TikzLibrariesData {
591    tikz: Vec<String>,
592    pgf: Vec<String>,
593}
594
595static COLORS: LazyLock<ColorsData> = LazyLock::new(|| {
596    serde_json::from_str(COLORS_JSON).expect("bundled data/colors.json must be valid")
597});
598static TIKZ_LIBRARIES: LazyLock<TikzLibrariesData> = LazyLock::new(|| {
599    serde_json::from_str(TIKZ_LIBRARIES_JSON)
600        .expect("bundled data/tikz_libraries.json must be valid")
601});
602/// `data/arg_enums.json`: fixed value sets for enumerated command arguments,
603/// keyed by command name then *brace-group* index (the same index
604/// `completion::group_index` computes — `OPTIONAL` slots are skipped). Consumed by
605/// completion only; never read by the formatter or parser.
606static ARG_ENUMS: LazyLock<HashMap<String, HashMap<usize, Vec<String>>>> = LazyLock::new(|| {
607    serde_json::from_str(ARG_ENUMS_JSON).expect("bundled data/arg_enums.json must be valid")
608});
609
610/// Borrow a `'static` list of owned names as `&'static str` slices.
611fn as_static_slice(names: &'static [String]) -> Vec<&'static str> {
612    names.iter().map(String::as_str).collect()
613}
614
615/// Built-in color names for `\color`/`\textcolor`/… completion (color/xcolor base
616/// set + dvipsnames). See [`COLORS_JSON`].
617pub fn color_names() -> &'static [&'static str] {
618    static NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| as_static_slice(&COLORS.names));
619    &NAMES
620}
621
622/// Built-in color models for the `\definecolor{name}{model}{spec}` model argument.
623/// See [`COLORS_JSON`].
624pub fn color_models() -> &'static [&'static str] {
625    static MODELS: LazyLock<Vec<&'static str>> = LazyLock::new(|| as_static_slice(&COLORS.models));
626    &MODELS
627}
628
629/// Built-in TikZ library names for `\usetikzlibrary` completion. See
630/// [`TIKZ_LIBRARIES_JSON`].
631pub fn tikz_libraries() -> &'static [&'static str] {
632    static LIBS: LazyLock<Vec<&'static str>> =
633        LazyLock::new(|| as_static_slice(&TIKZ_LIBRARIES.tikz));
634    &LIBS
635}
636
637/// Built-in PGF library names for `\usepgflibrary` completion. See
638/// [`TIKZ_LIBRARIES_JSON`].
639pub fn pgf_libraries() -> &'static [&'static str] {
640    static LIBS: LazyLock<Vec<&'static str>> =
641        LazyLock::new(|| as_static_slice(&TIKZ_LIBRARIES.pgf));
642    &LIBS
643}
644
645/// The fixed value set for the `index`-th *brace* argument of command `name`, if
646/// that argument takes an enumerated value (`\pagestyle{plain}`,
647/// `\pagenumbering{roman}`, …). `index` is the brace-only group index (matching
648/// `completion::group_index`). Values are completion *suggestions*, not a closed
649/// set. See [`ARG_ENUMS_JSON`].
650pub fn arg_enum_values(name: &str, index: usize) -> Option<&'static [String]> {
651    ARG_ENUMS.get(name)?.get(&index).map(Vec::as_slice)
652}
653
654// The baked CTAN metadata tier: a one-line description and CTAN catalogue id per
655// `.sty`/`.cls` stem. `data/package_metadata.json` (generated by
656// `scripts/gen_package_names.py` from the pinned tlpdb) is the reviewable source of
657// truth; `build.rs` bakes it into a `phf::Map` of `const fn` constructor calls at
658// `$OUT_DIR/package_metadata.rs`, so the ~730 KB of data is read-only statics with
659// *zero* runtime parse — the same treatment (and reason) as the CWL tier above,
660// whose runtime JSON parse was a measurable startup delay. A *shipped, static*
661// dataset the TEXMF scan cannot cheaply derive; consumed by package hover and
662// completion detail, never a runtime distro query.
663type PackageMetaMap = phf::Map<&'static str, PackageMeta>;
664
665/// CTAN metadata for one package/class stem: an optional one-line description and
666/// the CTAN catalogue id (for a `https://ctan.org/pkg/<id>` URL). Field values are
667/// `&'static str` so the whole map is a compile-time `phf` constant.
668#[derive(Debug, Clone, Copy)]
669pub struct PackageMeta {
670    /// The package's one-line `shortdesc`, absent when tlpdb carried none.
671    pub desc: Option<&'static str>,
672    /// The CTAN catalogue id (defaults to the package name in the generator), absent
673    /// only for a malformed entry.
674    pub ctan: Option<&'static str>,
675}
676
677impl PackageMeta {
678    /// The canonical CTAN package page, `https://ctan.org/pkg/<id>`, when a catalogue
679    /// id is known.
680    pub fn ctan_url(&self) -> Option<String> {
681        self.ctan.map(|id| format!("https://ctan.org/pkg/{id}"))
682    }
683}
684
685/// The `const fn` constructor the generated `phf` map calls per entry (mirrors the
686/// CWL tier's `command`/`environment` constructors).
687const fn meta(desc: Option<&'static str>, ctan: Option<&'static str>) -> PackageMeta {
688    PackageMeta { desc, ctan }
689}
690
691// Defines `static PACKAGE_METADATA: PackageMetaMap = …;`.
692include!(concat!(env!("OUT_DIR"), "/package_metadata.rs"));
693
694/// The shipped CTAN metadata for a `\usepackage`/`\documentclass` stem, if any.
695/// Keyed by the stem the user writes (`amsmath`, `tikz`, `scrartcl`), resolving to
696/// the owning package's description + CTAN id. A zero-parse `phf` lookup (see
697/// [`PackageMetaMap`]).
698pub fn package_metadata(name: &str) -> Option<&'static PackageMeta> {
699    PACKAGE_METADATA.get(name)
700}
701
702// --- On-disk schema (serde) ---------------------------------------------------
703//
704// A thin deserialization mirror of the in-memory types, kept separate so the
705// public API stays free of serde concerns and the JSON can use a compact,
706// hand-authorable spelling (`"req"`/`"opt"` for arguments; flags defaulting to
707// false; `reflow` derived rather than stored).
708
709/// An argument's bracket as written in the JSON: `"req"` (mandatory `{…}`) or
710/// `"opt"` (optional `[…]`).
711#[derive(Deserialize, Clone, Copy)]
712#[serde(rename_all = "lowercase")]
713enum RawArgKind {
714    Req,
715    Opt,
716}
717
718impl RawArgKind {
719    fn required(self) -> bool {
720        matches!(self, RawArgKind::Req)
721    }
722
723    fn kind(self) -> ArgKind {
724        match self {
725            RawArgKind::Req => ArgKind::Brace,
726            RawArgKind::Opt => ArgKind::Bracket,
727        }
728    }
729}
730
731/// An argument's content kind as written in the JSON: `"opaque"` (default),
732/// `"prose"`, or `"tokenList"`. Mirrors [`ContentKind`].
733#[derive(Deserialize, Clone, Copy, Default)]
734#[serde(rename_all = "camelCase")]
735enum RawContentKind {
736    #[default]
737    Opaque,
738    Prose,
739    TokenList,
740}
741
742impl From<RawContentKind> for ContentKind {
743    fn from(raw: RawContentKind) -> Self {
744        match raw {
745            RawContentKind::Opaque => ContentKind::Opaque,
746            RawContentKind::Prose => ContentKind::Prose,
747            RawContentKind::TokenList => ContentKind::TokenList,
748        }
749    }
750}
751
752/// One argument as written in the JSON. Either the compact string shorthand
753/// (`"req"` / `"opt"`, the common case, content defaulting to `"opaque"`) or an
754/// object form `{ "kind": "req", "content": "prose" }` / `{ "kind": "req",
755/// "content": "tokenList" }` that additionally marks the argument's content kind
756/// (see [`ContentKind`]).
757#[derive(Deserialize)]
758#[serde(untagged)]
759enum RawArg {
760    Short(RawArgKind),
761    Full {
762        kind: RawArgKind,
763        #[serde(default)]
764        content: RawContentKind,
765    },
766}
767
768impl From<RawArg> for ArgSpec {
769    fn from(raw: RawArg) -> Self {
770        match raw {
771            RawArg::Short(kind) => ArgSpec {
772                required: kind.required(),
773                kind: kind.kind(),
774                content: ContentKind::Opaque,
775            },
776            RawArg::Full { kind, content } => ArgSpec {
777                required: kind.required(),
778                kind: kind.kind(),
779                content: content.into(),
780            },
781        }
782    }
783}
784
785#[derive(Deserialize, Default)]
786#[serde(deny_unknown_fields)]
787struct RawCommand {
788    #[serde(default)]
789    args: Vec<RawArg>,
790    #[serde(default)]
791    sectioning: Option<u8>,
792    #[serde(default)]
793    verbatim: bool,
794    #[serde(default, rename = "verbatimDelimited")]
795    verbatim_delimited: bool,
796    #[serde(default)]
797    rule: bool,
798    #[serde(default)]
799    inline: bool,
800}
801
802impl From<RawCommand> for CommandSig {
803    fn from(raw: RawCommand) -> Self {
804        CommandSig {
805            args: Cow::Owned(raw.args.into_iter().map(ArgSpec::from).collect()),
806            sectioning: raw.sectioning,
807            verbatim: raw.verbatim,
808            verbatim_delimited: raw.verbatim_delimited,
809            rule: raw.rule,
810            inline: raw.inline,
811        }
812    }
813}
814
815/// An environment's outline category as written in the JSON: `"float"` or
816/// `"theorem"` (absent → `None`, no outline entry).
817#[derive(Deserialize, Clone, Copy)]
818#[serde(rename_all = "lowercase")]
819enum RawOutlineKind {
820    Float,
821    Theorem,
822}
823
824impl From<RawOutlineKind> for OutlineKind {
825    fn from(raw: RawOutlineKind) -> Self {
826        match raw {
827            RawOutlineKind::Float => OutlineKind::Float,
828            RawOutlineKind::Theorem => OutlineKind::Theorem,
829        }
830    }
831}
832
833#[derive(Deserialize, Default)]
834#[serde(deny_unknown_fields)]
835struct RawEnvironment {
836    #[serde(default)]
837    args: Vec<RawArg>,
838    #[serde(default, rename = "verbatimBody")]
839    verbatim_body: bool,
840    #[serde(default, rename = "verbatimArg")]
841    verbatim_arg: bool,
842    #[serde(default)]
843    math: bool,
844    #[serde(default)]
845    code: bool,
846    #[serde(default)]
847    align: bool,
848    #[serde(default, rename = "noIndent")]
849    no_indent: bool,
850    #[serde(default)]
851    list: bool,
852    #[serde(default)]
853    block: bool,
854    #[serde(default)]
855    outline: Option<RawOutlineKind>,
856}
857
858impl From<RawEnvironment> for EnvironmentSig {
859    fn from(raw: RawEnvironment) -> Self {
860        // The `reflow`/`block` derivations live in `derive_reflow`/`derive_block`
861        // (shared with the codegen path); only `args` differs (owned here).
862        EnvironmentSig {
863            args: Cow::Owned(raw.args.into_iter().map(ArgSpec::from).collect()),
864            verbatim_body: raw.verbatim_body,
865            verbatim_arg: raw.verbatim_arg,
866            math: raw.math,
867            code: raw.code,
868            align: raw.align,
869            reflow: derive_reflow(raw.verbatim_body, raw.math, raw.code),
870            no_indent: raw.no_indent,
871            list: raw.list,
872            block: derive_block(raw.block, raw.math, raw.list, raw.no_indent),
873            outline: raw.outline.map(OutlineKind::from),
874        }
875    }
876}
877
878#[derive(Deserialize, Default)]
879#[serde(deny_unknown_fields)]
880struct RawDb {
881    /// An optional top-level provenance header (the generated `cwl_signatures.json`
882    /// carries one); accepted and discarded so `deny_unknown_fields` still rejects
883    /// genuine typos elsewhere.
884    #[serde(default, rename = "_comment")]
885    _comment: Option<serde::de::IgnoredAny>,
886    #[serde(default)]
887    commands: HashMap<String, RawCommand>,
888    #[serde(default)]
889    environments: HashMap<String, RawEnvironment>,
890}
891
892/// Deserialize the bundled JSON into a [`SignatureDb`].
893fn parse(json: &str) -> serde_json::Result<SignatureDb> {
894    let raw: RawDb = serde_json::from_str(json)?;
895    Ok(SignatureDb {
896        commands: raw
897            .commands
898            .into_iter()
899            .map(|(name, sig)| (SmolStr::new(name), sig.into()))
900            .collect(),
901        environments: raw
902            .environments
903            .into_iter()
904            .map(|(name, sig)| (SmolStr::new(name), sig.into()))
905            .collect(),
906        command_origins: HashMap::new(),
907        environment_origins: HashMap::new(),
908    })
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914
915    #[test]
916    fn bundled_json_loads() {
917        // Exercises the bundled file through the real loader; a malformed or
918        // unknown-field entry would panic here.
919        let db = builtin();
920        assert!(db.command("section").is_some());
921        assert!(db.environment("tabular").is_some());
922    }
923
924    #[test]
925    fn arg_enums_json_loads_and_resolves() {
926        // Exercises data/arg_enums.json through the real loader; a malformed file
927        // would panic here. A modeled brace argument resolves, an unmodeled index
928        // and an unknown command do not.
929        assert_eq!(
930            arg_enum_values("pagenumbering", 0),
931            Some(
932                ["arabic", "roman", "Roman", "alph", "Alph"]
933                    .map(String::from)
934                    .as_slice()
935            )
936        );
937        assert!(arg_enum_values("pagestyle", 0).is_some());
938        assert!(arg_enum_values("pagestyle", 1).is_none());
939        assert!(arg_enum_values("definitelynotacommand", 0).is_none());
940    }
941
942    #[test]
943    fn loads_and_resolves_known_commands() {
944        let db = builtin();
945        assert_eq!(db.command("frac").map(|c| c.args.len()), Some(2));
946        assert!(db.command("frac").unwrap().args.iter().all(|a| a.required));
947    }
948
949    #[test]
950    fn optional_then_mandatory_order_preserved() {
951        let args = &builtin().command("includegraphics").unwrap().args;
952        assert_eq!(args.len(), 2);
953        assert_eq!(args[0].kind, ArgKind::Bracket);
954        assert!(!args[0].required);
955        assert_eq!(args[1].kind, ArgKind::Brace);
956        assert!(args[1].required);
957    }
958
959    #[test]
960    fn mixed_argument_order_round_trips() {
961        // `\newcommand{cmd}[nargs]{def}` — mandatory, optional, mandatory.
962        let args = &builtin().command("newcommand").unwrap().args;
963        let kinds: Vec<_> = args.iter().map(|a| a.kind).collect();
964        assert_eq!(
965            kinds,
966            vec![ArgKind::Brace, ArgKind::Bracket, ArgKind::Brace]
967        );
968    }
969
970    #[test]
971    fn outline_categories_assigned() {
972        let db = builtin();
973        assert_eq!(
974            db.environment("figure").unwrap().outline,
975            Some(OutlineKind::Float)
976        );
977        assert_eq!(
978            db.environment("table*").unwrap().outline,
979            Some(OutlineKind::Float)
980        );
981        assert_eq!(
982            db.environment("theorem").unwrap().outline,
983            Some(OutlineKind::Theorem)
984        );
985        // A block layout environment is not outline-worthy.
986        assert_eq!(db.environment("center").unwrap().outline, None);
987    }
988
989    #[test]
990    fn sectioning_levels_assigned() {
991        let db = builtin();
992        assert_eq!(db.command("part").unwrap().sectioning, Some(0));
993        assert_eq!(db.command("section").unwrap().sectioning, Some(2));
994        assert_eq!(db.command("subsubsection").unwrap().sectioning, Some(4));
995        // A sectioning command still carries its argument shape.
996        assert_eq!(db.command("section").unwrap().args.len(), 2);
997        assert!(db.command("textbf").unwrap().sectioning.is_none());
998    }
999
1000    #[test]
1001    fn verbatim_commands_flagged() {
1002        assert!(builtin().command("verb").unwrap().verbatim);
1003        assert!(builtin().command("lstinline").unwrap().verbatim);
1004        assert!(!builtin().command("textbf").unwrap().verbatim);
1005        // The delimiter form is opt-in: `\lstinline|…|` has it, the braced-only
1006        // `\code`/`\path` (jss, url) do not — their names collide with common
1007        // user macros (issue #53).
1008        assert!(builtin().command("lstinline").unwrap().verbatim_delimited);
1009        assert!(!builtin().command("code").unwrap().verbatim_delimited);
1010        assert!(!builtin().command("path").unwrap().verbatim_delimited);
1011    }
1012
1013    #[test]
1014    fn content_kind_parses_from_both_forms() {
1015        // The string shorthand defaults content to `Opaque`; the object form's
1016        // `content` discriminant sets it.
1017        let db = parse(
1018            r#"{ "commands": {
1019                "short": { "args": ["req"] },
1020                "full":  { "args": ["opt", { "kind": "req", "content": "prose" }] }
1021            } }"#,
1022        )
1023        .expect("valid content schema");
1024        let short = &db.command("short").unwrap().args;
1025        assert_eq!(short[0].content, ContentKind::Opaque);
1026        let full = &db.command("full").unwrap().args;
1027        assert_eq!(full[0].kind, ArgKind::Bracket);
1028        assert_eq!(full[0].content, ContentKind::Opaque); // no `content` → default
1029        assert_eq!(full[1].kind, ArgKind::Brace);
1030        assert_eq!(full[1].content, ContentKind::Prose);
1031    }
1032
1033    #[test]
1034    fn bundled_prose_args_flagged() {
1035        // A representative prose-bearing command marks its mandatory body slot,
1036        // while a name-bearing command leaves every slot opaque.
1037        let footnote = &builtin().command("footnote").unwrap().args;
1038        assert!(footnote.iter().any(|a| a.content == ContentKind::Prose));
1039        let label = &builtin().command("label").unwrap().args;
1040        assert!(label.iter().all(|a| a.content == ContentKind::Opaque));
1041    }
1042
1043    #[test]
1044    fn environment_argument_shapes() {
1045        let db = builtin();
1046        let tabular = db.environment("tabular").unwrap();
1047        assert_eq!(tabular.args.len(), 2);
1048        assert_eq!(tabular.args[0].kind, ArgKind::Bracket); // [pos]
1049        assert_eq!(tabular.args[1].kind, ArgKind::Brace); // {cols}
1050        assert!(db.environment("verbatim").unwrap().args.is_empty());
1051    }
1052
1053    #[test]
1054    fn environment_flags_and_derived_reflow() {
1055        let db = builtin();
1056        let lstlisting = db.environment("lstlisting").unwrap();
1057        assert!(lstlisting.verbatim_body);
1058        assert!(!lstlisting.reflow);
1059        let equation = db.environment("equation").unwrap();
1060        assert!(equation.math);
1061        assert!(!equation.reflow);
1062        // `equation` is math but not an alignment environment (no `&` columns).
1063        assert!(!equation.align);
1064        // An alignment environment carries the `align` flag (and is also math).
1065        let align = db.environment("align").unwrap();
1066        assert!(align.math);
1067        assert!(align.align);
1068        let pmatrix = db.environment("pmatrix").unwrap();
1069        assert!(pmatrix.math);
1070        assert!(pmatrix.align);
1071        // `tabular` is an alignment environment (its `&` columns grid-align) but,
1072        // unlike the math families, it is not math.
1073        let tabular = db.environment("tabular").unwrap();
1074        assert!(!tabular.verbatim_body);
1075        assert!(!tabular.math);
1076        assert!(tabular.align);
1077        assert!(!tabular.list);
1078        // List environments carry the `list` flag (and still reflow their bodies).
1079        for name in ["itemize", "enumerate", "description"] {
1080            let env = db.environment(name).unwrap();
1081            assert!(env.list, "{name} should be a list environment");
1082            assert!(env.reflow);
1083            assert!(!env.math);
1084        }
1085        // jss/Sweave verbatim environments are curated built-ins: their bodies are
1086        // opaque (preserved verbatim, never reflowed).
1087        for name in [
1088            "Code",
1089            "CodeInput",
1090            "CodeOutput",
1091            "Sinput",
1092            "Soutput",
1093            "Scode",
1094        ] {
1095            let env = db.environment(name).unwrap();
1096            assert!(env.verbatim_body, "{name} should be a verbatim environment");
1097            assert!(!env.reflow);
1098        }
1099    }
1100
1101    /// Verbatim bodies whose defining code no in-file scan can reach: the kernel's
1102    /// `filecontents` (it `\@makeother`s `\dospecials`, `%` included, so the body is
1103    /// written out byte-for-byte) and ltxdockit's listings-based `ltxcode`/
1104    /// `ltxexample`, defined in an external class. Curation is the only place these
1105    /// facts can live. Smoke-test issue #98 (`plk/biblatex`).
1106    #[test]
1107    fn externally_defined_verbatim_environments() {
1108        let db = builtin();
1109        for name in ["filecontents", "filecontents*"] {
1110            let env = db.environment(name).unwrap();
1111            assert!(env.verbatim_body, "{name} body is written verbatim");
1112            assert!(!env.reflow);
1113            // `\begin{filecontents}[force]{\jobname.bib}`: the two leading args are
1114            // structured; everything after them is the opaque body.
1115            assert_eq!(env.args.len(), 2, "{name} arity");
1116            assert_eq!(env.args[0].kind, ArgKind::Bracket);
1117            assert_eq!(env.args[1].kind, ArgKind::Brace);
1118        }
1119        for name in ["ltxcode", "ltxexample"] {
1120            let env = db.environment(name).unwrap();
1121            assert!(env.verbatim_body, "{name} body is opaque");
1122            assert!(!env.reflow);
1123            // `\lstnewenvironment{…}[1][]` — one optional `\lstset` argument.
1124            assert_eq!(env.args.len(), 1, "{name} arity");
1125            assert_eq!(env.args[0].kind, ArgKind::Bracket);
1126        }
1127    }
1128
1129    #[test]
1130    fn block_flag_is_explicit_or_derived() {
1131        let db = builtin();
1132        // Explicitly flagged display environments.
1133        assert!(db.environment("figure").unwrap().block);
1134        assert!(db.environment("center").unwrap().block);
1135        assert!(db.environment("verbatim").unwrap().block);
1136        // Derived from `math`, `list`, and `no_indent` respectively.
1137        assert!(db.environment("equation").unwrap().block);
1138        assert!(db.environment("itemize").unwrap().block);
1139        assert!(db.environment("document").unwrap().block);
1140        // The new explicit flag leaves `reflow` derivation untouched: `center`
1141        // is a block env but still reflows its prose body.
1142        assert!(db.environment("center").unwrap().reflow);
1143    }
1144
1145    #[test]
1146    fn doc_ltxdoc_signatures() {
1147        let db = builtin();
1148        // doc/ltxdoc driver commands each take one mandatory argument.
1149        for name in ["DocInput", "DescribeMacro", "DescribeEnv", "StopEventually"] {
1150            let cmd = db
1151                .command(name)
1152                .unwrap_or_else(|| panic!("{name} signature"));
1153            assert_eq!(cmd.args.len(), 1, "{name} arity");
1154            assert!(cmd.args[0].required, "{name} arg is mandatory");
1155        }
1156        // The `macro`/`environment` doc envs document one item and are block
1157        // containers, but their body is ordinary prose (it still reflows).
1158        for name in ["macro", "environment"] {
1159            let env = db.environment(name).unwrap_or_else(|| panic!("{name} env"));
1160            assert_eq!(env.args.len(), 1, "{name} arity");
1161            assert!(env.block, "{name} is a block env");
1162            assert!(env.reflow, "{name} body reflows as prose");
1163            assert!(!env.code, "{name} is not a code env");
1164        }
1165        // `macrocode`/`macrocode*` are code-not-prose: real parsed code (not an
1166        // opaque verbatim blob), so `code` is set, `reflow` is off, and
1167        // `verbatim_body` stays off (otherwise the lexer would swallow the body).
1168        for name in ["macrocode", "macrocode*"] {
1169            let env = db.environment(name).unwrap_or_else(|| panic!("{name} env"));
1170            assert!(env.code, "{name} is code");
1171            assert!(!env.reflow, "{name} never reflows");
1172            assert!(!env.verbatim_body, "{name} body is parsed, not verbatim");
1173            assert!(env.block, "{name} is a block env");
1174        }
1175    }
1176
1177    #[test]
1178    fn code_flag_parses_and_drives_reflow() {
1179        // The `code` flag defaults false and, when set, suppresses reflow without
1180        // making the body verbatim.
1181        let db = parse(
1182            r#"{ "environments": {
1183                "plain": {},
1184                "codeish": { "code": true }
1185            } }"#,
1186        )
1187        .expect("valid code schema");
1188        let plain = db.environment("plain").unwrap();
1189        assert!(!plain.code);
1190        assert!(plain.reflow);
1191        let codeish = db.environment("codeish").unwrap();
1192        assert!(codeish.code);
1193        assert!(!codeish.reflow);
1194        assert!(!codeish.verbatim_body);
1195    }
1196
1197    #[test]
1198    fn unknown_names_resolve_to_none() {
1199        let db = builtin();
1200        assert!(db.command("definitelynotacommand").is_none());
1201        assert!(db.environment("definitelynotanenv").is_none());
1202    }
1203
1204    #[test]
1205    fn rejects_unknown_fields() {
1206        // A typo'd field must fail loudly rather than be silently ignored.
1207        let err = parse(r#"{ "commands": { "x": { "sektioning": 2 } } }"#);
1208        assert!(err.is_err());
1209    }
1210
1211    #[test]
1212    fn empty_document_is_valid() {
1213        let db = parse("{}").expect("empty object is valid");
1214        assert!(db.command("anything").is_none());
1215    }
1216
1217    #[test]
1218    fn cwl_tier_loads_and_covers_long_tail() {
1219        // Exercises the gzipped bundle through the real decompress+parse path, and
1220        // confirms the curated package subset reached the tier (a command unlikely
1221        // to be in the hand-curated built-in DB).
1222        let db = cwl();
1223        assert!(db.command("siunitx").is_some() || db.command("SI").is_some());
1224        assert!(
1225            db.command_names().count() > 1000,
1226            "the CWL subset should contribute a broad name set"
1227        );
1228    }
1229
1230    #[test]
1231    fn cwl_entries_carry_only_arity_no_behavior_flags() {
1232        // The converter guard: every CWL command/environment is names+arity only, so
1233        // none of its low-confidence data can flip a formatter/lexer/outline decision.
1234        let db = cwl();
1235        for sig in db.command_sigs() {
1236            assert!(sig.sectioning.is_none());
1237            assert!(!sig.verbatim && !sig.rule && !sig.inline);
1238            assert!(sig.args.iter().all(|a| a.content == ContentKind::Opaque));
1239        }
1240        for sig in db.environment_sigs() {
1241            assert!(!sig.verbatim_body && !sig.math && !sig.code && !sig.align);
1242            assert!(!sig.no_indent && !sig.list && !sig.block);
1243            assert!(sig.outline.is_none());
1244        }
1245    }
1246
1247    #[test]
1248    fn curated_builtin_wins_over_cwl_tier() {
1249        // `Signatures` resolves a name present in both tiers to the curated entry,
1250        // never the bulk CWL one — proven via a curated-only flag (`\section` is a
1251        // sectioning command in the built-in DB; the CWL tier never sets that).
1252        let empty = SignatureDb::default();
1253        let sigs = Signatures::new(&empty);
1254        assert!(
1255            cwl().command("section").is_some(),
1256            "test premise: in CWL tier"
1257        );
1258        assert_eq!(sigs.command("section").unwrap().sectioning, Some(2));
1259    }
1260
1261    #[test]
1262    fn cwl_only_name_resolves_through_signatures() {
1263        // A name only the CWL tier knows still resolves (arity coverage win), with
1264        // all behavior flags at their conservative defaults.
1265        let empty = SignatureDb::default();
1266        let sigs = Signatures::new(&empty);
1267        let Some(name) = cwl()
1268            .command_names()
1269            .find(|n| builtin().command(n).is_none())
1270        else {
1271            panic!("expected at least one CWL-only command name");
1272        };
1273        let sig = sigs.command(name).expect("CWL-only name resolves");
1274        assert!(sig.sectioning.is_none() && !sig.inline && !sig.verbatim);
1275    }
1276
1277    /// A minimal one-command DB for the origin-merge tests.
1278    fn db_with_command(name: &str) -> SignatureDb {
1279        let mut db = SignatureDb::default();
1280        db.insert_command(name, CommandSig::default());
1281        db
1282    }
1283
1284    #[test]
1285    fn merge_from_package_records_origin() {
1286        let mut scope = SignatureDb::default();
1287        scope.merge_from_package(&db_with_command("myfoo"), "mypkg");
1288        assert_eq!(scope.command_origin("myfoo"), Some("mypkg"));
1289        assert!(scope.command("myfoo").is_some());
1290    }
1291
1292    #[test]
1293    fn plain_merge_clears_origin_on_shadow() {
1294        // The document overlay: its scanned defs carry no origins, so merging
1295        // them last strips the package provenance of a shadowed name.
1296        let mut scope = SignatureDb::default();
1297        scope.merge_from_package(&db_with_command("dup"), "mypkg");
1298        scope.merge_from(&db_with_command("dup"));
1299        assert_eq!(scope.command_origin("dup"), None);
1300        assert!(scope.command("dup").is_some());
1301    }
1302
1303    #[test]
1304    fn later_package_merge_overwrites_origin() {
1305        let mut scope = SignatureDb::default();
1306        scope.merge_from_package(&db_with_command("shared"), "first");
1307        scope.merge_from_package(&db_with_command("shared"), "second");
1308        assert_eq!(scope.command_origin("shared"), Some("second"));
1309    }
1310
1311    #[test]
1312    fn insert_clears_origin() {
1313        let mut scope = SignatureDb::default();
1314        scope.merge_from_package(&db_with_command("myfoo"), "mypkg");
1315        scope.insert_command("myfoo", CommandSig::default());
1316        assert_eq!(scope.command_origin("myfoo"), None);
1317    }
1318
1319    #[test]
1320    fn merge_propagates_existing_origins() {
1321        // Merging a scope that itself carries origins (a package's own scope
1322        // pulled a dependency) keeps them.
1323        let mut inner = SignatureDb::default();
1324        inner.merge_from_package(&db_with_command("dep"), "deppkg");
1325        let mut scope = SignatureDb::default();
1326        scope.merge_from(&inner);
1327        assert_eq!(scope.command_origin("dep"), Some("deppkg"));
1328    }
1329
1330    #[test]
1331    fn package_metadata_resolves_stem_to_ctan_facts() {
1332        // The baked CTAN tier maps the typed stem to the owning package's description
1333        // and catalogue id (`amsmath` -> `latex-amsmath` on CTAN).
1334        let meta = package_metadata("amsmath").expect("amsmath in metadata DB");
1335        assert_eq!(meta.desc, Some("AMS mathematical facilities for LaTeX"));
1336        assert_eq!(
1337            meta.ctan_url().as_deref(),
1338            Some("https://ctan.org/pkg/latex-amsmath")
1339        );
1340        // A stem the tlpdb never shipped has no metadata.
1341        assert!(package_metadata("definitely-not-a-real-package").is_none());
1342    }
1343}