Skip to main content

badness_parser/
declarations.rs

1//! Project declarations for constructs syntax or semantics cannot infer from
2//! source without macro expansion.
3//!
4//! Declarations supplement the aliases found by [`crate::semantic::define`].
5//! They can describe environments defined in another file or through constructs
6//! the definition scanner does not recognize.
7//!
8//! Environment declarations name spellings, not pairings: shape gates still
9//! decide whether the source supports a construct. Command declarations are
10//! semantic-only ref/cite aliases and never affect tree shape.
11//!
12//! The schema follows three rules:
13//!
14//! 1. Each syntactic category has its own name map.
15//! 2. `like` copies a built-in entry from the same category. Cross-category
16//!    relationships use explicit fields such as [`EnvironmentDecl::begin`].
17//! 3. [`Declarations::resolve`] performs validation after deserialization so
18//!    errors can identify the original configuration key.
19//!
20//! These types are shared by all parser front ends. Their serialized field names
21//! are public API.
22
23use std::collections::BTreeMap;
24use std::fmt;
25
26use serde::{Deserialize, Serialize};
27use smol_str::SmolStr;
28
29use crate::parser::lexer::is_control_word_name;
30use crate::semantic::builder::{cite_command, ref_command};
31use crate::semantic::signature::{EnvironmentSig, SignatureDb, builtin};
32
33/// A control-word name as written in a declaration, stored **without** the
34/// leading backslash — the spelling every signature and `ParseCtx` map is keyed
35/// by.
36///
37/// Users write `\bea`, which in TOML wants a literal string (`'\bea'`) to avoid
38/// escaping. Both spellings are accepted and normalize to the same value: a
39/// control word can never itself contain a backslash, so there is nothing to
40/// disambiguate. Normalization lives in the type rather than at one call site so
41/// every front end gets it.
42#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
43#[serde(transparent)]
44pub struct CommandName(SmolStr);
45
46impl CommandName {
47    /// Normalize `name` by stripping one leading backslash, if present.
48    pub fn new(name: &str) -> Self {
49        Self(SmolStr::new(name.strip_prefix('\\').unwrap_or(name)))
50    }
51
52    /// The name without its leading backslash.
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl From<&str> for CommandName {
59    fn from(name: &str) -> Self {
60        Self::new(name)
61    }
62}
63
64impl fmt::Display for CommandName {
65    /// Renders *with* the backslash, since that is how a diagnostic should spell
66    /// it back to the user.
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(f, "\\{}", self.0)
69    }
70}
71
72impl<'de> Deserialize<'de> for CommandName {
73    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
74    where
75        D: serde::Deserializer<'de>,
76    {
77        let raw = String::deserialize(deserializer)?;
78        Ok(Self::new(&raw))
79    }
80}
81
82/// One `[environments.<name>]` entry: what the environment named by the key
83/// behaves like, and which command spellings stand in for its delimiters.
84///
85/// The key is the environment's *own* name, whether or not it is one the
86/// built-in database knows. That is what lets a single entry serve both shapes
87/// the issue asked for — `\begin{myenv} … \end{myenv}` needing only behavior,
88/// and `\startmyenv … \endmyenv` needing behavior *and* spellings — without a
89/// union-typed entry.
90#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
92pub struct EnvironmentDecl {
93    /// The curated built-in environment whose behavior this one copies — math,
94    /// alignment, list-ness, verbatim-ness, and every flag added later.
95    ///
96    /// Resolved against the built-in database alone, never the CWL tier or
97    /// scanned definitions, for the same reason the alias arm of
98    /// `Signatures::environment_at` is: a declaration supplies a *spelling*, and
99    /// behavior always comes from curated data. An unknown target is an error
100    /// rather than a silent no-op, because a mistyped `like = "algin"` is
101    /// otherwise invisible.
102    pub like: Option<SmolStr>,
103    /// Command spellings that stand in for this environment's `\begin{…}`
104    /// (`\bea`, `\startmyenv`). Any of them opens the environment; the closers
105    /// in [`end`](Self::end) close it — and so does the literal `\end{…}`, which
106    /// is why either list may stand alone (issue #117).
107    pub begin: Vec<CommandName>,
108    /// Command spellings that stand in for this environment's `\end{…}`. Kept a
109    /// separate list rather than begin/end tuples because pairing is by *kind*,
110    /// not by index: `\bea … \eea` pairs whichever spellings the author used.
111    pub end: Vec<CommandName>,
112}
113
114impl EnvironmentDecl {
115    /// Whether this entry declares delimiter spellings (as opposed to behavior
116    /// alone).
117    pub fn has_delimiters(&self) -> bool {
118        !self.begin.is_empty() || !self.end.is_empty()
119    }
120}
121
122/// The name-keyed `[environments]` map. A type alias so the CLI's `Config` can
123/// name the field's type without restating the key type.
124pub type EnvironmentDecls = BTreeMap<SmolStr, EnvironmentDecl>;
125
126/// One `[commands.<name>]` entry: the built-in reference or citation command
127/// whose semantic behavior the project command copies.
128#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
130pub struct CommandDecl {
131    /// The built-in reference or citation command whose key behavior is copied.
132    pub like: Option<SmolStr>,
133}
134
135/// The name-keyed `[commands]` declaration map.
136pub type CommandDecls = BTreeMap<SmolStr, CommandDecl>;
137
138/// Every declaration a project makes, as authored — unresolved and unvalidated.
139///
140/// `BTreeMap` rather than `HashMap` so iteration order is deterministic:
141/// resolution reports errors in the order the user reads them, and the value
142/// ends up on a salsa input whose equality must not depend on hash order.
143#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
144#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
145pub struct Declarations {
146    /// The `[commands.<name>]` semantic aliases.
147    pub commands: CommandDecls,
148    /// The `[environments.<name>]` entries.
149    pub environments: EnvironmentDecls,
150}
151
152impl Declarations {
153    /// Whether the project declares nothing at all — the overwhelmingly common
154    /// case, and the one the parse must not pay anything for.
155    pub fn is_empty(&self) -> bool {
156        self.commands.is_empty() && self.environments.is_empty()
157    }
158
159    /// Check every rule and project the declarations into a
160    /// [`ResolvedDeclarations`]: an environment signature per `like`, and the
161    /// delimiter spellings as opener and closer alias entries.
162    ///
163    /// Environment behavior and delimiter aliases resolve into a [`SignatureDb`]
164    /// so they fold into the existing scope machinery. Command aliases stay in a
165    /// separate deterministic map because they must not become parser or
166    /// formatter signatures.
167    ///
168    /// **Every failure is an error, never a silent no-op.** A declaration that
169    /// quietly does nothing is the worst outcome available here: the user sees
170    /// unchanged output and has no way to tell a typo from an unimplemented
171    /// feature. Errors surface in key order (the map is a `BTreeMap`), so the
172    /// message is stable across runs.
173    ///
174    /// An entry that declares behavior alone is unrestricted — `like =
175    /// "lstlisting"` is exactly how a project names a verbatim environment the
176    /// definition scan cannot find. The extra restrictions below apply only to
177    /// an entry that declares *delimiter spellings*, since those are the ones a
178    /// command has to stand in for. An entry that declares **nothing** is the
179    /// one shape rejected for saying too little rather than too much.
180    ///
181    /// One side alone is fine (issue #117): the literal `\begin{X}`/`\end{X}` is
182    /// a spelling of each side too, so `begin = ['\bsplit']` with no `end`
183    /// declares an opener the written-out `\end{split}` closes. This used to be
184    /// two errors, on the reasoning that a half-declared pair could never pair.
185    pub fn resolve(&self) -> Result<ResolvedDeclarations, DeclarationError> {
186        let mut db = SignatureDb::default();
187        let mut commands = BTreeMap::new();
188
189        for (name, entry) in &self.commands {
190            let error = |kind| DeclarationError {
191                key: dotted_key(["commands", name]),
192                kind,
193            };
194            if !is_control_word_name(name) {
195                return Err(error(DeclarationErrorKind::InvalidCommandName {
196                    name: SmolStr::new(name),
197                }));
198            }
199            if is_builtin_command(name) {
200                return Err(error(DeclarationErrorKind::BuiltinCommandName {
201                    name: SmolStr::new(name),
202                }));
203            }
204            let target = entry
205                .like
206                .as_ref()
207                .ok_or_else(|| error(DeclarationErrorKind::EmptyCommandEntry))?;
208            // The family tables alone decide what `like` copies, so they are the
209            // whole test. Requiring a `signatures.json` entry too would reject the
210            // targets a wrapper most needs: that file carries layout data and omits
211            // most of the ref/cite families, `\cpageref` — the only list-valued page
212            // reference — among them.
213            if ref_command(target).is_none() && cite_command(target).is_none() {
214                return Err(DeclarationError {
215                    key: dotted_key(["commands", name, "like"]),
216                    kind: DeclarationErrorKind::UnknownCommandLikeTarget {
217                        target: target.clone(),
218                    },
219                });
220            }
221            commands.insert(name.clone(), target.clone());
222        }
223
224        // Which entry already claimed a spelling, so a second claim is an error
225        // rather than a last-writer-wins surprise.
226        let mut claimed: BTreeMap<SmolStr, SmolStr> = BTreeMap::new();
227
228        for (name, entry) in &self.environments {
229            let error = |kind| DeclarationError {
230                key: dotted_key(["environments", name]),
231                kind,
232            };
233
234            // An entry that says nothing is the one shape resolution could
235            // otherwise wave through, and it is exactly the shape a typo takes:
236            // `deny_unknown_fields` catches a misspelled key, but a user who
237            // wrote the header and nothing under it gets an entry that parses,
238            // resolves, and does nothing.
239            if entry.like.is_none() && !entry.has_delimiters() {
240                return Err(error(DeclarationErrorKind::EmptyEntry));
241            }
242
243            // `like` first: it decides the behavior every later rule reads.
244            let declared = match &entry.like {
245                Some(target) => {
246                    let sig = builtin()
247                        .environment(target)
248                        .ok_or_else(|| DeclarationError {
249                            key: dotted_key(["environments", name, "like"]),
250                            kind: DeclarationErrorKind::UnknownLikeTarget {
251                                target: target.clone(),
252                            },
253                        })?;
254                    db.insert_declared_environment(name.clone(), sig.clone());
255                    Some(sig)
256                }
257                None => None,
258            };
259
260            if !entry.has_delimiters() {
261                continue;
262            }
263
264            // A delimiter command has to stand in for *something*: an entry with
265            // no `like` falls back to the built-in of the same name, and an
266            // environment that is neither is one nothing downstream could
267            // resolve.
268            let sig: &EnvironmentSig = declared
269                .or_else(|| builtin().environment(name))
270                .ok_or_else(|| error(DeclarationErrorKind::UndeclaredTarget))?;
271            if sig.verbatim_body {
272                return Err(error(DeclarationErrorKind::VerbatimTarget));
273            }
274            if !sig.args.is_empty() {
275                return Err(error(DeclarationErrorKind::TargetTakesArguments));
276            }
277
278            for (side, spellings) in [("begin", &entry.begin), ("end", &entry.end)] {
279                let error = |kind| DeclarationError {
280                    key: dotted_key(["environments", name, side]),
281                    kind,
282                };
283                for spelling in spellings {
284                    // Named apart from the general not-a-control-word rule
285                    // because it is a *different mistake with a different fix*,
286                    // and the one the issue-#117 reporter actually made: reaching
287                    // for `end = ['\end{split}']` to say "closed by the written
288                    // -out delimiter". That is the default now, so the fix is to
289                    // delete the key — advice the generic message cannot give.
290                    if let Some(env) = literal_delimiter_target(spelling.as_str()) {
291                        return Err(error(DeclarationErrorKind::SpellingIsALiteralDelimiter {
292                            name: spelling.clone(),
293                            environment: SmolStr::new(env),
294                        }));
295                    }
296                    if !is_control_word_name(spelling.as_str()) {
297                        return Err(error(DeclarationErrorKind::NotAControlWord {
298                            name: spelling.clone(),
299                        }));
300                    }
301                    // A spelling the curated database already knows as a command
302                    // is a mistake we can name: `begin = ['\emph']` would turn
303                    // every `\emph` in the project into an environment opener
304                    // wherever the shape gate let it pair. Curated tier only,
305                    // for the same reason `like` is: the CWL tier carries every
306                    // package's names, so rejecting against it would refuse a
307                    // spelling on the say-so of a package the project never
308                    // loads. That leaves the check partial by construction — it
309                    // catches the arity-bearing commands, where a wrong pairing
310                    // also mis-attaches arguments — and it is a backstop, not
311                    // the safety property. The shape gate is still what keeps a
312                    // wrong declaration from corrupting a tree.
313                    if builtin().command(spelling.as_str()).is_some() {
314                        return Err(error(DeclarationErrorKind::SpellingIsABuiltinCommand {
315                            name: spelling.clone(),
316                        }));
317                    }
318                    let key = SmolStr::new(spelling.as_str());
319                    if let Some(first) = claimed.get(&key) {
320                        // Repeating a spelling *within* one entry is a different
321                        // mistake from two entries fighting over it, and reading
322                        // "already declared as a delimiter of `eqnarray`" under
323                        // `environments.eqnarray.begin` helps nobody.
324                        return Err(error(if first == name {
325                            DeclarationErrorKind::RepeatedDelimiter {
326                                name: spelling.clone(),
327                            }
328                        } else {
329                            DeclarationErrorKind::DuplicateDelimiter {
330                                name: spelling.clone(),
331                                first: first.clone(),
332                            }
333                        }));
334                    }
335                    claimed.insert(key.clone(), name.clone());
336                    if side == "begin" {
337                        db.insert_env_begin_alias(key, name.clone());
338                    } else {
339                        db.insert_env_end_alias(key, name.clone());
340                    }
341                }
342            }
343        }
344        Ok(ResolvedDeclarations { db, commands })
345    }
346}
347
348/// A project's declarations, checked and projected into signature data by
349/// [`Declarations::resolve`].
350///
351/// The environment signature tier plus semantic-only command aliases. This is
352/// the only signature data the parser accepts: a value can only come from a
353/// declaration block, so `parse_with_declarations` cannot be handed a document's
354/// merged package/definition scope. The parser reads only `db`; semantic-model
355/// construction reads `commands`.
356#[derive(Debug, Clone, Default, PartialEq, Eq)]
357pub struct ResolvedDeclarations {
358    db: SignatureDb,
359    commands: BTreeMap<SmolStr, SmolStr>,
360}
361
362impl ResolvedDeclarations {
363    /// The declared tier as signature data, for merging into a document's scope
364    /// (where it is the top tier: a declaration is the user explicitly
365    /// correcting an inference).
366    pub fn as_db(&self) -> &SignatureDb {
367        &self.db
368    }
369
370    /// The built-in semantic target of a declared command alias.
371    pub fn command_like(&self, name: &str) -> Option<&str> {
372        self.commands.get(name).map(SmolStr::as_str)
373    }
374
375    /// The declared command names, in deterministic order.
376    pub fn command_names(&self) -> impl Iterator<Item = &str> {
377        self.commands.keys().map(SmolStr::as_str)
378    }
379
380    /// Whether nothing was declared — the common case, and the one that must
381    /// cost the parse nothing.
382    pub fn is_empty(&self) -> bool {
383        self.db == SignatureDb::default() && self.commands.is_empty()
384    }
385
386    /// The parse-facing half of this block: the environment signature tier, with
387    /// the semantic command aliases dropped.
388    ///
389    /// The two halves are split so a reader depends only on the one it uses. A
390    /// command alias provably cannot change a tree
391    /// (`command_declarations_do_not_change_the_parse_tree`), so an incremental
392    /// front end can hold this half behind its own firewall and leave every parse
393    /// standing when only `[commands]` changed. The environment tier is what
394    /// [`parse_with_declarations`](crate::parser::parse_with_declarations) and the
395    /// signature scope read.
396    pub fn parse_tier(&self) -> Self {
397        Self {
398            db: self.db.clone(),
399            commands: BTreeMap::new(),
400        }
401    }
402
403    /// The semantic-facing half: the command aliases alone.
404    ///
405    /// The counterpart of [`parse_tier`](Self::parse_tier), read by
406    /// [`SemanticModel::build_with_declarations`](crate::semantic::SemanticModel::build_with_declarations),
407    /// which never looks at the environment tier. Together the two halves
408    /// partition the block: nothing is in both, and nothing in neither.
409    pub fn semantic_tier(&self) -> Self {
410        Self {
411            db: SignatureDb::default(),
412            commands: self.commands.clone(),
413        }
414    }
415}
416
417/// A rule [`Declarations::resolve`] rejected, with the dotted key of the entry
418/// that broke it (`environments.myenv.like`) so the CLI can point at the line
419/// the user wrote.
420///
421/// The key is a `String` rather than a borrowed path because the error outlives
422/// the borrow of the config in every caller, and this crate is wasm-clean: it
423/// knows nothing about the file the key came from, which is the CLI's to add.
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct DeclarationError {
426    pub key: String,
427    pub kind: DeclarationErrorKind,
428}
429
430/// Why a declaration was rejected. Each variant is a rule from
431/// `AGENTS.md` decision #12 or its architecture section.
432#[derive(Debug, Clone, PartialEq, Eq)]
433pub enum DeclarationErrorKind {
434    /// A `[commands.<name>]` entry without its required `like` target.
435    EmptyCommandEntry,
436    /// A command map key that could never lex as one control word.
437    InvalidCommandName { name: SmolStr },
438    /// A declaration attempted to reclassify a curated built-in command.
439    BuiltinCommandName { name: SmolStr },
440    /// `like` did not name a curated reference or citation command.
441    UnknownCommandLikeTarget { target: SmolStr },
442    /// An entry with no keys at all. Nothing to reject it on rule grounds, and
443    /// nothing for it to do either — which is the outcome this module exists to
444    /// avoid.
445    EmptyEntry,
446    /// `like` named something the curated built-in database does not have.
447    /// Never resolved against the CWL tier or scanned definitions: behavior
448    /// comes from curated data only.
449    UnknownLikeTarget { target: SmolStr },
450    /// Delimiter spellings for a verbatim environment. Not conservatism but TeX
451    /// truth, which is why it is rejected rather than merely discouraged.
452    VerbatimTarget,
453    /// Delimiter spellings for an environment that takes arguments. A bare
454    /// control word carries none, and attaching them from the target's
455    /// signature would be arity-directed grouping from declaration data.
456    TargetTakesArguments,
457    /// Delimiter spellings for an environment whose behavior is unknown — no
458    /// `like`, and no built-in of that name.
459    UndeclaredTarget,
460    /// A spelling two entries both claim. Silently letting the last one win
461    /// would make the pairing depend on map order.
462    DuplicateDelimiter { name: CommandName, first: SmolStr },
463    /// A spelling one entry lists twice — across its two sides, or twice on
464    /// one. The [`DuplicateDelimiter`](Self::DuplicateDelimiter) mistake seen
465    /// from inside a single entry, where naming the "other" entry is no help.
466    RepeatedDelimiter { name: CommandName },
467    /// A spelling that *is* the written-out delimiter (`\end{split}`) rather
468    /// than a command standing in for one. A special case of
469    /// [`NotAControlWord`](Self::NotAControlWord) with its own fix: the literal
470    /// delimiter is already a spelling of both sides, so the key is redundant.
471    SpellingIsALiteralDelimiter {
472        name: CommandName,
473        environment: SmolStr,
474    },
475    /// A spelling the lexer could never produce as one control word, so it
476    /// could never match anything.
477    NotAControlWord { name: CommandName },
478    /// A spelling the curated database already knows as a command. Not a
479    /// no-op — it would take effect, on a command the project did not mean to
480    /// redefine.
481    SpellingIsABuiltinCommand { name: CommandName },
482}
483
484/// Whether the curated data already knows `name` as a command.
485///
486/// All three sources have to be asked, because `signatures.json` is not a
487/// superset of the ref/cite family tables: it carries layout data, and most of
488/// the families (`\cpageref`, `\supercite`, `\Textcite`, …) have no entry there.
489/// Asking it alone would let a declaration reclassify one of them: `like = "ref"`
490/// on `\cpageref` would demote a list-valued page reference to a single-key
491/// `\ref`, which is exactly what this gate exists to prevent.
492fn is_builtin_command(name: &str) -> bool {
493    builtin().command(name).is_some() || ref_command(name).is_some() || cite_command(name).is_some()
494}
495
496/// The environment named by `spelling` when it is the written-out delimiter
497/// (`end{split}`, `begin{split}` — the leading backslash is already stripped by
498/// [`CommandName`]), or `None` for an ordinary command name.
499///
500/// Deliberately shape-only, with no check that the name is one badness knows: a
501/// user who writes `end = ['\end{myenv}']` made this mistake whether or not
502/// `myenv` exists, and pointing at the wrong rule would send them looking for a
503/// missing `like`.
504fn literal_delimiter_target(spelling: &str) -> Option<&str> {
505    let rest = spelling
506        .strip_prefix("begin")
507        .or_else(|| spelling.strip_prefix("end"))?;
508    let name = rest.strip_prefix('{')?.strip_suffix('}')?.trim();
509    (!name.is_empty()).then_some(name)
510}
511
512/// Join `segments` into a TOML dotted key, quoting any segment that is not a
513/// bare key so the result can be pasted back into `badness.toml`.
514///
515/// An environment may be named anything, and `environments.my.env` would point
516/// at a key the user never wrote.
517fn dotted_key<'a>(segments: impl IntoIterator<Item = &'a str>) -> String {
518    let mut key = String::new();
519    for segment in segments {
520        if !key.is_empty() {
521            key.push('.');
522        }
523        let bare = !segment.is_empty()
524            && segment
525                .chars()
526                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
527        if bare {
528            key.push_str(segment);
529        } else {
530            key.push('"');
531            key.push_str(&segment.replace('\\', "\\\\").replace('"', "\\\""));
532            key.push('"');
533        }
534    }
535    key
536}
537
538impl fmt::Display for DeclarationError {
539    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
540        write!(f, "`{}`: {}", self.key, self.kind)
541    }
542}
543
544impl fmt::Display for DeclarationErrorKind {
545    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546        match self {
547            Self::EmptyCommandEntry => write!(
548                f,
549                "declares nothing; add `like` naming a reference or citation command"
550            ),
551            Self::InvalidCommandName { name } => write!(
552                f,
553                "`{name}` is not a control-word name; command names may contain letters, `@`, `_`, or `:`"
554            ),
555            Self::BuiltinCommandName { name } => write!(
556                f,
557                "`\\{name}` is already a curated LaTeX command; command declarations may only name project commands"
558            ),
559            Self::UnknownCommandLikeTarget { target } => write!(
560                f,
561                "unknown reference or citation command `{target}`; `like` must name a curated ref/cite command"
562            ),
563            Self::EmptyEntry => write!(
564                f,
565                "declares nothing; add `like` to say what the environment behaves like, or \
566                 `begin`/`end` to give it delimiter spellings"
567            ),
568            Self::UnknownLikeTarget { target } => write!(
569                f,
570                "unknown environment `{target}`; `like` must name an environment badness \
571                 knows about"
572            ),
573            Self::VerbatimTarget => write!(
574                f,
575                "a command cannot stand in for a verbatim environment's delimiters, because \
576                 TeX never expands the closer — the verbatim scanner has already swallowed \
577                 it. Declare the environment name on its own, without `begin`/`end`"
578            ),
579            Self::TargetTakesArguments => write!(
580                f,
581                "the environment takes arguments, which a delimiter command cannot carry; \
582                 declare the environment name on its own, without `begin`/`end`"
583            ),
584            Self::UndeclaredTarget => write!(
585                f,
586                "declares delimiters for an environment badness does not know; add `like` \
587                 to say what it behaves like"
588            ),
589            Self::DuplicateDelimiter { name, first } => write!(
590                f,
591                "`{name}` is already declared as a delimiter of `{first}`"
592            ),
593            Self::RepeatedDelimiter { name } => {
594                write!(
595                    f,
596                    "`{name}` is listed twice as a delimiter of this environment"
597                )
598            }
599            Self::SpellingIsALiteralDelimiter { name, environment } => write!(
600                f,
601                "`{name}` is the delimiter itself, not a command standing in for one — and \
602                 badness already pairs a declared spelling with the written-out \
603                 `\\begin{{{environment}}}`/`\\end{{{environment}}}`, so this key can be \
604                 removed"
605            ),
606            Self::NotAControlWord { name } => write!(
607                f,
608                "`{name}` is not a control word; a delimiter must be a name of letters"
609            ),
610            Self::SpellingIsABuiltinCommand { name } => write!(
611                f,
612                "`{name}` is already a LaTeX command badness knows; a delimiter spelling must \
613                 be a command of your own, or the declaration would change what `{name}` means \
614                 everywhere in the project"
615            ),
616        }
617    }
618}
619
620impl std::error::Error for DeclarationError {}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625
626    fn from_json(json: &str) -> Declarations {
627        serde_json::from_str(json).expect("deserializes")
628    }
629
630    #[test]
631    fn empty_declarations_are_the_default() {
632        assert!(Declarations::default().is_empty());
633        assert!(from_json("{}").is_empty());
634    }
635
636    #[test]
637    fn a_command_may_declare_a_reference_family() {
638        let decls = from_json(r#"{"commands": {"eqrefs": {"like": "cref"}}}"#);
639        assert_eq!(decls.commands["eqrefs"].like.as_deref(), Some("cref"));
640        assert!(!decls.is_empty());
641    }
642
643    #[test]
644    fn an_entry_may_declare_behavior_alone() {
645        let decls = from_json(r#"{"environments": {"myenv": {"like": "align"}}}"#);
646        let entry = &decls.environments["myenv"];
647        assert_eq!(entry.like.as_deref(), Some("align"));
648        assert!(!entry.has_delimiters());
649        assert!(!decls.is_empty());
650    }
651
652    #[test]
653    fn an_entry_may_declare_delimiters_alone() {
654        let decls =
655            from_json(r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#);
656        let entry = &decls.environments["eqnarray"];
657        assert_eq!(entry.like, None);
658        assert!(entry.has_delimiters());
659        assert_eq!(entry.begin, vec![CommandName::new("bea")]);
660        assert_eq!(entry.end, vec![CommandName::new("eea")]);
661    }
662
663    #[test]
664    fn an_entry_may_declare_both() {
665        let decls = from_json(
666            r#"{"environments": {"mytheorem": {
667                 "like": "theorem",
668                 "begin": ["\\startmyenv"],
669                 "end": ["\\endmyenv"]
670               }}}"#,
671        );
672        let entry = &decls.environments["mytheorem"];
673        assert_eq!(entry.like.as_deref(), Some("theorem"));
674        assert_eq!(entry.begin, vec![CommandName::new("startmyenv")]);
675    }
676
677    #[test]
678    fn a_leading_backslash_is_optional_and_normalized_away() {
679        assert_eq!(CommandName::new("\\bea"), CommandName::new("bea"));
680        assert_eq!(CommandName::new("\\bea").as_str(), "bea");
681        let decls = from_json(r#"{"environments": {"e": {"begin": ["bea", "\\bea"]}}}"#);
682        assert_eq!(
683            decls.environments["e"].begin,
684            vec![CommandName::new("bea"), CommandName::new("bea")]
685        );
686    }
687
688    #[test]
689    fn only_one_backslash_is_stripped() {
690        assert_eq!(CommandName::new("\\\\").as_str(), "\\");
691    }
692
693    #[test]
694    fn display_restores_the_backslash() {
695        assert_eq!(CommandName::new("bea").to_string(), "\\bea");
696    }
697
698    #[test]
699    fn a_misspelled_key_is_rejected_rather_than_ignored() {
700        let err = serde_json::from_str::<Declarations>(
701            r#"{"environments": {"myenv": {"liek": "align"}}}"#,
702        )
703        .expect_err("unknown field is rejected");
704        assert!(err.to_string().contains("liek"), "{err}");
705
706        let err = serde_json::from_str::<Declarations>(r#"{"enviroments": {}}"#)
707            .expect_err("unknown section is rejected");
708        assert!(err.to_string().contains("enviroments"), "{err}");
709
710        let err =
711            serde_json::from_str::<Declarations>(r#"{"commands": {"eqrefs": {"liek": "cref"}}}"#)
712                .expect_err("unknown command field is rejected");
713        assert!(err.to_string().contains("liek"), "{err}");
714    }
715
716    #[test]
717    fn wire_spellings_are_pinned() {
718        let decls = from_json(
719            r#"{"commands": {"eqrefs": {"like": "cref"}},
720                "environments": {"myenv": {"like": "align", "begin": ["\\b"], "end": ["\\e"]}}}"#,
721        );
722        let json = serde_json::to_value(&decls).expect("serializes");
723        assert_eq!(json["commands"]["eqrefs"]["like"], "cref");
724        let entry = &json["environments"]["myenv"];
725        assert_eq!(entry["like"], "align");
726        assert_eq!(entry["begin"][0], "b");
727        assert_eq!(entry["end"][0], "e");
728    }
729
730    #[test]
731    fn environments_iterate_in_name_order() {
732        let decls = from_json(r#"{"environments": {"zed": {}, "alpha": {}, "mid": {}}}"#);
733        let names: Vec<&str> = decls.environments.keys().map(SmolStr::as_str).collect();
734        assert_eq!(names, ["alpha", "mid", "zed"]);
735    }
736
737    fn resolve(json: &str) -> SignatureDb {
738        from_json(json).resolve().expect("resolves").as_db().clone()
739    }
740
741    fn resolve_err(json: &str) -> DeclarationError {
742        from_json(json).resolve().expect_err("is rejected")
743    }
744
745    #[test]
746    fn nothing_declared_resolves_to_nothing() {
747        assert!(from_json("{}").resolve().expect("resolves").is_empty());
748    }
749
750    #[test]
751    fn command_families_resolve_with_target_behavior() {
752        let declared = from_json(
753            r#"{"commands": {
754                 "one": {"like": "eqref"},
755                 "many": {"like": "cref"},
756                 "sources": {"like": "parencite"},
757                 "everything": {"like": "nocite"}
758               }}"#,
759        )
760        .resolve()
761        .expect("resolves");
762
763        assert_eq!(declared.command_like("one"), Some("eqref"));
764        assert_eq!(declared.command_like("many"), Some("cref"));
765        assert_eq!(declared.command_like("sources"), Some("parencite"));
766        assert_eq!(declared.command_like("everything"), Some("nocite"));
767        assert!(
768            declared.as_db().command("many").is_none(),
769            "semantic aliases must not become formatter/parser signatures"
770        );
771        assert_eq!(
772            declared.command_names().collect::<Vec<_>>(),
773            vec!["everything", "many", "one", "sources"]
774        );
775    }
776
777    #[test]
778    fn like_may_name_a_family_command_absent_from_the_signature_database() {
779        for target in ["cpageref", "supercite", "Textcite", "fnotecite"] {
780            assert!(
781                builtin().command(target).is_none(),
782                "{target} is in signatures.json; pick another for this test"
783            );
784            let json = format!(r#"{{"commands": {{"wrapper": {{"like": "{target}"}}}}}}"#);
785            let declared = from_json(&json).resolve().expect("resolves");
786            assert_eq!(declared.command_like("wrapper"), Some(target));
787        }
788    }
789
790    #[test]
791    fn a_family_command_absent_from_the_signature_database_may_not_be_redeclared() {
792        for name in ["cpageref", "supercite", "Textcite", "fnotecite"] {
793            let json = format!(r#"{{"commands": {{"{name}": {{"like": "ref"}}}}}}"#);
794            let err = resolve_err(&json);
795            assert_eq!(err.key, format!("commands.{name}"), "{err}");
796            assert!(matches!(
797                err.kind,
798                DeclarationErrorKind::BuiltinCommandName { .. }
799            ));
800        }
801    }
802
803    #[test]
804    fn the_two_tiers_partition_the_block() {
805        let declared = from_json(
806            r#"{
807                 "commands": {"myref": {"like": "cref"}},
808                 "environments": {"mycode": {"like": "lstlisting"}}
809               }"#,
810        )
811        .resolve()
812        .expect("resolves");
813
814        let parse = declared.parse_tier();
815        assert_eq!(parse.as_db(), declared.as_db());
816        assert_eq!(parse.command_names().count(), 0);
817
818        let semantic = declared.semantic_tier();
819        assert_eq!(semantic.as_db(), &SignatureDb::default());
820        assert_eq!(semantic.command_like("myref"), Some("cref"));
821
822        assert!(!parse.is_empty() && !semantic.is_empty());
823        let recommanded = from_json(
824            r#"{
825                 "commands": {"myref": {"like": "eqref"}},
826                 "environments": {"mycode": {"like": "lstlisting"}}
827               }"#,
828        )
829        .resolve()
830        .expect("resolves");
831        assert_ne!(recommanded, declared);
832        assert_eq!(recommanded.parse_tier(), parse);
833        assert_ne!(recommanded.semantic_tier(), semantic);
834    }
835
836    #[test]
837    fn invalid_command_declarations_are_rejected() {
838        for (json, key) in [
839            (r#"{"commands": {"empty": {}}}"#, "commands.empty"),
840            (
841                r#"{"commands": {"wrapper": {"like": "emph"}}}"#,
842                "commands.wrapper.like",
843            ),
844            (
845                r#"{"commands": {"bad-name": {"like": "ref"}}}"#,
846                "commands.bad-name",
847            ),
848            (
849                r#"{"commands": {"section": {"like": "ref"}}}"#,
850                "commands.section",
851            ),
852        ] {
853            let err = resolve_err(json);
854            assert_eq!(err.key, key, "{err}");
855        }
856    }
857
858    #[test]
859    fn command_declarations_do_not_change_the_parse_tree() {
860        use crate::parser::{LatexFlavor, parse_with_declarations, parse_with_flavor};
861
862        let src = "\\eqrefs{a,b}\n";
863        let declared = from_json(r#"{"commands": {"eqrefs": {"like": "cref"}}}"#)
864            .resolve()
865            .expect("resolves");
866        assert_eq!(
867            parse_with_declarations(src, LatexFlavor::Document, &declared).green,
868            parse_with_flavor(src, LatexFlavor::Document).green
869        );
870    }
871
872    #[test]
873    fn like_copies_the_builtin_entry() {
874        let db = resolve(r#"{"environments": {"myenv": {"like": "align"}}}"#);
875        let sig = db.environment("myenv").expect("declared");
876        assert_eq!(sig, builtin().environment("align").expect("builtin"));
877        assert!(sig.math && sig.align);
878    }
879
880    #[test]
881    fn like_may_name_a_verbatim_environment() {
882        let db = resolve(r#"{"environments": {"mycode": {"like": "lstlisting"}}}"#);
883        assert!(db.environment("mycode").expect("declared").verbatim_body);
884    }
885
886    #[test]
887    fn like_may_name_an_argument_taking_environment() {
888        let db = resolve(r#"{"environments": {"mytab": {"like": "tabular"}}}"#);
889        assert!(!db.environment("mytab").expect("declared").args.is_empty());
890    }
891
892    #[test]
893    fn a_mistyped_like_target_is_an_error_not_a_silent_no_op() {
894        let err = resolve_err(r#"{"environments": {"myenv": {"like": "algin"}}}"#);
895        assert_eq!(err.key, "environments.myenv.like");
896        assert!(matches!(
897            err.kind,
898            DeclarationErrorKind::UnknownLikeTarget { .. }
899        ));
900        assert!(err.to_string().contains("algin"), "{err}");
901    }
902
903    #[test]
904    fn like_does_not_resolve_against_the_cwl_tier() {
905        let cwl_only = crate::semantic::signature::cwl()
906            .environment_names()
907            .find(|name| builtin().environment(name).is_none())
908            .expect("the CWL tier has an environment the curated one does not")
909            .to_string();
910        let err = resolve_err(&format!(
911            r#"{{"environments": {{"myenv": {{"like": "{cwl_only}"}}}}}}"#
912        ));
913        assert!(matches!(
914            err.kind,
915            DeclarationErrorKind::UnknownLikeTarget { .. }
916        ));
917    }
918
919    #[test]
920    fn delimiters_for_a_builtin_environment_need_no_like() {
921        let db =
922            resolve(r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#);
923        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
924        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
925        assert!(db.environment("eqnarray").is_none());
926    }
927
928    #[test]
929    fn delimiters_and_like_resolve_together() {
930        let db = resolve(
931            r#"{"environments": {"mytheorem": {
932                 "like": "theorem",
933                 "begin": ["\\startmyenv"],
934                 "end": ["\\endmyenv"]
935               }}}"#,
936        );
937        assert_eq!(db.env_begin_alias("startmyenv"), Some("mytheorem"));
938        assert!(db.environment("mytheorem").is_some());
939    }
940
941    #[test]
942    fn an_environment_may_have_several_spellings_per_side() {
943        let db = resolve(
944            r#"{"environments": {"eqnarray": {
945                 "begin": ["\\bea", "\\beqa"], "end": ["\\eea"]
946               }}}"#,
947        );
948        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
949        assert_eq!(db.env_begin_alias("beqa"), Some("eqnarray"));
950    }
951
952    #[test]
953    fn one_side_alone_resolves() {
954        let db = resolve(r#"{"environments": {"eqnarray": {"begin": ["\\bea"]}}}"#);
955        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
956        assert_eq!(db.env_end_alias("bea"), None);
957
958        let db = resolve(r#"{"environments": {"eqnarray": {"end": ["\\eea"]}}}"#);
959        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
960        assert_eq!(db.env_begin_alias("eea"), None);
961    }
962
963    #[test]
964    fn one_side_alone_still_obeys_every_target_rule() {
965        for json in [
966            r#"{"environments": {"verbatim": {"begin": ["\\bv"]}}}"#,
967            r#"{"environments": {"verbatim": {"end": ["\\ev"]}}}"#,
968        ] {
969            assert_eq!(resolve_err(json).kind, DeclarationErrorKind::VerbatimTarget);
970        }
971        assert_eq!(
972            resolve_err(r#"{"environments": {"tabular": {"begin": ["\\bt"]}}}"#).kind,
973            DeclarationErrorKind::TargetTakesArguments
974        );
975        assert_eq!(
976            resolve_err(r#"{"environments": {"myenv": {"end": ["\\e"]}}}"#).kind,
977            DeclarationErrorKind::UndeclaredTarget
978        );
979    }
980
981    #[test]
982    fn delimiters_for_a_verbatim_environment_are_rejected() {
983        let err =
984            resolve_err(r#"{"environments": {"verbatim": {"begin": ["\\bv"], "end": ["\\ev"]}}}"#);
985        assert_eq!(err.kind, DeclarationErrorKind::VerbatimTarget);
986
987        let err = resolve_err(
988            r#"{"environments": {"mycode": {
989                 "like": "lstlisting", "begin": ["\\bc"], "end": ["\\ec"]
990               }}}"#,
991        );
992        assert_eq!(err.kind, DeclarationErrorKind::VerbatimTarget);
993    }
994
995    #[test]
996    fn delimiters_for_an_argument_taking_environment_are_rejected() {
997        let err =
998            resolve_err(r#"{"environments": {"tabular": {"begin": ["\\bt"], "end": ["\\et"]}}}"#);
999        assert_eq!(err.kind, DeclarationErrorKind::TargetTakesArguments);
1000    }
1001
1002    #[test]
1003    fn delimiters_for_an_unknown_environment_ask_for_like() {
1004        let err = resolve_err(r#"{"environments": {"myenv": {"begin": ["\\b"], "end": ["\\e"]}}}"#);
1005        assert_eq!(err.kind, DeclarationErrorKind::UndeclaredTarget);
1006        assert!(err.to_string().contains("like"), "{err}");
1007    }
1008
1009    #[test]
1010    fn an_entry_that_declares_nothing_is_an_error() {
1011        let err = resolve_err(r#"{"environments": {"myenv": {}}}"#);
1012        assert_eq!(err.key, "environments.myenv");
1013        assert_eq!(err.kind, DeclarationErrorKind::EmptyEntry);
1014        assert!(err.to_string().contains("like"), "{err}");
1015    }
1016
1017    #[test]
1018    fn a_spelling_that_is_already_a_builtin_command_is_rejected() {
1019        let err =
1020            resolve_err(r#"{"environments": {"center": {"begin": ["\\emph"], "end": ["\\ec"]}}}"#);
1021        assert_eq!(err.key, "environments.center.begin");
1022        assert!(matches!(
1023            err.kind,
1024            DeclarationErrorKind::SpellingIsABuiltinCommand { .. }
1025        ));
1026        assert!(err.to_string().contains("emph"), "{err}");
1027    }
1028
1029    #[test]
1030    fn a_cwl_only_command_name_is_still_available_as_a_spelling() {
1031        let cwl_only = crate::semantic::signature::cwl()
1032            .command_names()
1033            .find(|name| {
1034                builtin().command(name).is_none() && is_control_word_name(name) && name.len() > 2
1035            })
1036            .expect("the CWL tier has a command the curated one does not")
1037            .to_string();
1038        let db = resolve(&format!(
1039            r#"{{"environments": {{"center": {{"begin": ["{cwl_only}"], "end": ["\\ec"]}}}}}}"#
1040        ));
1041        assert_eq!(db.env_begin_alias(&cwl_only), Some("center"));
1042    }
1043
1044    #[test]
1045    fn the_error_key_quotes_a_name_that_is_not_a_bare_key() {
1046        let err = resolve_err(r#"{"environments": {"my.env": {}}}"#);
1047        assert_eq!(err.key, r#"environments."my.env""#);
1048        let err = resolve_err(r#"{"environments": {"my env": {"like": "algin"}}}"#);
1049        assert_eq!(err.key, r#"environments."my env".like"#);
1050    }
1051
1052    #[test]
1053    fn a_spelling_may_not_be_claimed_twice() {
1054        let err = resolve_err(
1055            r#"{"environments": {
1056                 "align": {"begin": ["\\bx"], "end": ["\\ex"]},
1057                 "equation": {"begin": ["\\bx"], "end": ["\\ey"]}
1058               }}"#,
1059        );
1060        assert_eq!(
1061            err.kind,
1062            DeclarationErrorKind::DuplicateDelimiter {
1063                name: CommandName::new("bx"),
1064                first: SmolStr::new("align"),
1065            }
1066        );
1067    }
1068
1069    #[test]
1070    fn a_spelling_may_not_be_both_opener_and_closer() {
1071        let err = resolve_err(r#"{"environments": {"align": {"begin": ["\\x"], "end": ["\\x"]}}}"#);
1072        assert_eq!(err.key, "environments.align.end");
1073        assert_eq!(
1074            err.kind,
1075            DeclarationErrorKind::RepeatedDelimiter {
1076                name: CommandName::new("x"),
1077            }
1078        );
1079    }
1080
1081    #[test]
1082    fn the_written_out_delimiter_is_rejected_with_its_own_advice() {
1083        let err = resolve_err(
1084            r#"{"environments": {"split": {"begin": ["\\bsplit"], "end": ["\\end{split}"]}}}"#,
1085        );
1086        assert_eq!(err.key, "environments.split.end");
1087        assert!(
1088            matches!(
1089                err.kind,
1090                DeclarationErrorKind::SpellingIsALiteralDelimiter { .. }
1091            ),
1092            "{err:?}"
1093        );
1094        let rendered = err.to_string();
1095        assert!(rendered.contains("\\end{split}"), "{rendered}");
1096        assert!(rendered.contains("removed"), "{rendered}");
1097
1098        for json in [
1099            r#"{"environments": {"split": {"begin": ["\\begin{split}"]}}}"#,
1100            r#"{"environments": {"split": {"end": ["\\end{myenv}"]}}}"#,
1101        ] {
1102            assert!(
1103                matches!(
1104                    resolve_err(json).kind,
1105                    DeclarationErrorKind::SpellingIsALiteralDelimiter { .. }
1106                ),
1107                "{json}"
1108            );
1109        }
1110
1111        let db = resolve(r#"{"environments": {"center": {"begin": ["\\beginning"]}}}"#);
1112        assert_eq!(db.env_begin_alias("beginning"), Some("center"));
1113    }
1114
1115    #[test]
1116    fn a_spelling_that_could_never_lex_as_one_control_word_is_rejected() {
1117        for bad in ["b ea", "bea2", "", "b-ea"] {
1118            let json = format!(
1119                r#"{{"environments": {{"align": {{"begin": ["{bad}"], "end": ["\\ex"]}}}}}}"#
1120            );
1121            let err = resolve_err(&json);
1122            assert!(
1123                matches!(err.kind, DeclarationErrorKind::NotAControlWord { .. }),
1124                "`{bad}` should be rejected, got {err:?}"
1125            );
1126        }
1127    }
1128
1129    #[test]
1130    fn a_spelling_may_use_letters_of_any_catcode_regime() {
1131        let db =
1132            resolve(r#"{"environments": {"align": {"begin": ["\\my@b"], "end": ["\\my_e:n"]}}}"#);
1133        assert_eq!(db.env_begin_alias("my@b"), Some("align"));
1134        assert_eq!(db.env_end_alias("my_e:n"), Some("align"));
1135    }
1136
1137    #[test]
1138    fn the_resolved_tier_merges_like_any_other() {
1139        let declared = resolve(
1140            r#"{"environments": {"myenv": {"like": "align"}, "eqnarray": {
1141                 "begin": ["\\bea"], "end": ["\\eea"]
1142               }}}"#,
1143        );
1144        let mut scope = SignatureDb::default();
1145        scope.merge_from(&declared, None);
1146        assert!(scope.environment("myenv").is_some());
1147        assert_eq!(scope.env_begin_alias("bea"), Some("eqnarray"));
1148    }
1149
1150    fn environment_sig_at(src: &str, json: &str) -> Option<EnvironmentSig> {
1151        scope_and_sig_at(src, json).1
1152    }
1153
1154    fn scope_and_sig_at(src: &str, json: &str) -> (Option<EnvironmentSig>, Option<EnvironmentSig>) {
1155        use crate::parser::{LatexFlavor, parse_with_declarations};
1156        use crate::semantic::define::scan_definitions;
1157        use crate::semantic::signature::Signatures;
1158        use crate::syntax::{SyntaxKind, SyntaxNode};
1159
1160        let decls = from_json(json).resolve().expect("resolves");
1161        let parsed = parse_with_declarations(src, LatexFlavor::Document, &decls);
1162        let root = SyntaxNode::new_root(parsed.green);
1163        let mut scope = scan_definitions(&root);
1164        scope.merge_declarations(&decls);
1165        let node = root
1166            .descendants()
1167            .find(|n| n.kind() == SyntaxKind::ENVIRONMENT)
1168            .expect("an environment");
1169        let sigs = Signatures::new(&scope);
1170        (
1171            sigs.environment("eqnarray").cloned(),
1172            sigs.environment_at(&node).cloned(),
1173        )
1174    }
1175
1176    #[test]
1177    fn a_declared_alias_resolves_to_a_declared_target() {
1178        let sig = environment_sig_at(
1179            "\\startmyenv x \\endmyenv\n",
1180            r#"{"environments": {"myenv": {
1181                 "like": "align", "begin": ["\\startmyenv"], "end": ["\\endmyenv"]
1182               }}}"#,
1183        )
1184        .expect("the alias resolves");
1185        assert_eq!(&sig, builtin().environment("align").expect("curated"));
1186    }
1187
1188    #[test]
1189    fn a_scanned_definition_still_lends_an_alias_nothing() {
1190        let (scanned, sig) = scope_and_sig_at(
1191            "\\newenvironment{eqnarray}{}{}\n\\bea x \\eea\n",
1192            r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#,
1193        );
1194        let sig = sig.expect("the alias resolves");
1195        assert_eq!(&sig, builtin().environment("eqnarray").expect("curated"));
1196        let scanned = scanned.expect("the scan records the redefinition");
1197        assert!(!scanned.math, "the scanned redefinition is not math");
1198        assert!(sig.math, "the alias resolves to the curated entry");
1199    }
1200}