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