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