Skip to main content

badness_parser/
declarations.rs

1//! Project **declarations**: the closed vocabulary a project uses to name
2//! constructs the parser cannot see.
3//!
4//! A `\bea`/`\eea` delimiter pair defined in a sibling `.sty`, an environment
5//! that behaves like `align` but has no built-in counterpart, a verbatim
6//! environment built by machinery no definition scan can follow — these are
7//! facts about the document that its text does not carry, and the inferred
8//! environment-alias scan ([`crate::semantic::define`]) cannot reach them
9//! (issue #109). This module is the type those facts arrive in.
10//!
11//! It is the *one* sanctioned input to the parse that is not the text
12//! (`AGENTS.md` decision #12). What keeps that admissible is the safety
13//! property that **a declaration names a spelling, never a pairing**: every
14//! shape gate still runs, so a declared `\bea` whose `\eea` is unreachable
15//! demotes to a plain command exactly as an inferred one does. Config widens
16//! what is *recognized* and can never force a tree the text does not support,
17//! which is what makes a wrong declaration a no-op rather than a corruption.
18//!
19//! Three shape rules, recorded here because they are what keep the vocabulary
20//! from growing into a query language:
21//!
22//! 1. **Keyed by category, then name.** One dedicated map per syntactic
23//!    category ([`Declarations::environments`] today; commands and, if the
24//!    shortverb case is ever taken, characters later), and never a scalar knob
25//!    inside a name map — a category-wide switch would collide with a construct
26//!    of that name, so it belongs in a sibling section.
27//! 2. **`like` never crosses categories.** It means "copy the curated built-in
28//!    entry of the same kind", and a genuinely cross-category relation gets its
29//!    own key instead ([`EnvironmentDecl::begin`]/[`EnvironmentDecl::end`], the
30//!    command spellings that stand in for an environment's delimiters).
31//! 3. **Deserialization validates nothing.** Every rule is checked in one later
32//!    pass, [`Declarations::resolve`], so that a failure can be reported against
33//!    the key the user wrote (`environments.myenv.like`) rather than swallowed
34//!    by a deserializer that only knows it was handed a string.
35//!
36//! The type lives in this crate, not in the CLI, because the parse is what
37//! consumes it and because three front ends must be able to produce the same
38//! value: `badness.toml`, the dprint plugin's own config (sandboxed, no
39//! filesystem), and eventually a `% badness-env` comment directive. Serde is a
40//! hard dependency here (the signature database is JSON), so — unlike
41//! `badness-formatter`'s `FormatStyle` — the derives need no feature gate and
42//! the CLI can deserialize straight into these types instead of maintaining a
43//! mirror that could drift. **The wire spellings are therefore public API**,
44//! pinned by the tests at the bottom of this file.
45
46use std::collections::BTreeMap;
47use std::fmt;
48
49use serde::{Deserialize, Serialize};
50use smol_str::SmolStr;
51
52use crate::parser::lexer::is_control_word_name;
53use crate::semantic::signature::{EnvironmentSig, SignatureDb, builtin};
54
55/// A control-word name as written in a declaration, stored **without** the
56/// leading backslash — the spelling every signature and `ParseCtx` map is keyed
57/// by.
58///
59/// Users write `\bea`, which in TOML wants a literal string (`'\bea'`) to avoid
60/// escaping. Both spellings are accepted and normalize to the same value: a
61/// control word can never itself contain a backslash, so there is nothing to
62/// disambiguate. Normalization lives in the type rather than at one call site so
63/// every front end gets it.
64#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
65#[serde(transparent)]
66pub struct CommandName(SmolStr);
67
68impl CommandName {
69    /// Normalize `name` by stripping one leading backslash, if present.
70    pub fn new(name: &str) -> Self {
71        Self(SmolStr::new(name.strip_prefix('\\').unwrap_or(name)))
72    }
73
74    /// The name without its leading backslash.
75    pub fn as_str(&self) -> &str {
76        &self.0
77    }
78}
79
80impl From<&str> for CommandName {
81    fn from(name: &str) -> Self {
82        Self::new(name)
83    }
84}
85
86impl fmt::Display for CommandName {
87    /// Renders *with* the backslash, since that is how a diagnostic should spell
88    /// it back to the user.
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(f, "\\{}", self.0)
91    }
92}
93
94impl<'de> Deserialize<'de> for CommandName {
95    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
96    where
97        D: serde::Deserializer<'de>,
98    {
99        let raw = String::deserialize(deserializer)?;
100        Ok(Self::new(&raw))
101    }
102}
103
104/// One `[environments.<name>]` entry: what the environment named by the key
105/// behaves like, and which command spellings stand in for its delimiters.
106///
107/// The key is the environment's *own* name, whether or not it is one the
108/// built-in database knows. That is what lets a single entry serve both shapes
109/// the issue asked for — `\begin{myenv} … \end{myenv}` needing only behavior,
110/// and `\startmyenv … \endmyenv` needing behavior *and* spellings — without a
111/// union-typed entry.
112#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
113#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
114pub struct EnvironmentDecl {
115    /// The curated built-in environment whose behavior this one copies — math,
116    /// alignment, list-ness, verbatim-ness, and every flag added later.
117    ///
118    /// Resolved against the built-in database alone, never the CWL tier or
119    /// scanned definitions, for the same reason the alias arm of
120    /// `Signatures::environment_at` is: a declaration supplies a *spelling*, and
121    /// behavior always comes from curated data. An unknown target is an error
122    /// rather than a silent no-op, because a mistyped `like = "algin"` is
123    /// otherwise invisible.
124    pub like: Option<SmolStr>,
125    /// Command spellings that stand in for this environment's `\begin{…}`
126    /// (`\bea`, `\startmyenv`). Any of them opens the environment; the closers
127    /// in [`end`](Self::end) close it.
128    pub begin: Vec<CommandName>,
129    /// Command spellings that stand in for this environment's `\end{…}`. Kept a
130    /// separate list rather than begin/end tuples because pairing is by *kind*,
131    /// not by index: `\bea … \eea` pairs whichever spellings the author used.
132    pub end: Vec<CommandName>,
133}
134
135impl EnvironmentDecl {
136    /// Whether this entry declares delimiter spellings (as opposed to behavior
137    /// alone).
138    pub fn has_delimiters(&self) -> bool {
139        !self.begin.is_empty() || !self.end.is_empty()
140    }
141}
142
143/// The name-keyed `[environments]` map. A type alias so the CLI's `Config` can
144/// name the field's type without restating the key type.
145pub type EnvironmentDecls = BTreeMap<SmolStr, EnvironmentDecl>;
146
147/// Every declaration a project makes, as authored — unresolved and unvalidated.
148///
149/// `BTreeMap` rather than `HashMap` so iteration order is deterministic:
150/// resolution reports errors in the order the user reads them, and the value
151/// ends up on a salsa input whose equality must not depend on hash order.
152#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
153#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
154pub struct Declarations {
155    /// The `[environments.<name>]` entries.
156    pub environments: EnvironmentDecls,
157}
158
159impl Declarations {
160    /// Whether the project declares nothing at all — the overwhelmingly common
161    /// case, and the one the parse must not pay anything for.
162    pub fn is_empty(&self) -> bool {
163        self.environments.is_empty()
164    }
165
166    /// Check every rule and project the declarations into a
167    /// [`ResolvedDeclarations`]: an environment signature per `like`, and the
168    /// delimiter spellings as opener and closer alias entries.
169    ///
170    /// Internally a [`SignatureDb`], because that is already the shape holding
171    /// exactly these three maps: the declared tier folds into a document's scope
172    /// with the existing [`SignatureDb::merge_from`], and the `ParseCtx` seed
173    /// reads it the same way it already reads the per-file scan's — no new
174    /// plumbing, and `[commands.*]` slots in later without changing the
175    /// signature of this function.
176    ///
177    /// **Every failure is an error, never a silent no-op.** A declaration that
178    /// quietly does nothing is the worst outcome available here: the user sees
179    /// unchanged output and has no way to tell a typo from an unimplemented
180    /// feature. Errors surface in key order (the map is a `BTreeMap`), so the
181    /// message is stable across runs.
182    ///
183    /// An entry that declares behavior alone is unrestricted — `like =
184    /// "lstlisting"` is exactly how a project names a verbatim environment the
185    /// definition scan cannot find. The extra restrictions below apply only to
186    /// an entry that declares *delimiter spellings*, since those are the ones a
187    /// command has to stand in for. An entry that declares **nothing** is the
188    /// one shape rejected for saying too little rather than too much.
189    pub fn resolve(&self) -> Result<ResolvedDeclarations, DeclarationError> {
190        let mut db = SignatureDb::default();
191        // Which entry already claimed a spelling, so a second claim is an error
192        // rather than a last-writer-wins surprise.
193        let mut claimed: BTreeMap<SmolStr, SmolStr> = BTreeMap::new();
194
195        for (name, entry) in &self.environments {
196            let error = |kind| DeclarationError {
197                key: dotted_key(["environments", name]),
198                kind,
199            };
200
201            // An entry that says nothing is the one shape resolution could
202            // otherwise wave through, and it is exactly the shape a typo takes:
203            // `deny_unknown_fields` catches a misspelled key, but a user who
204            // wrote the header and nothing under it gets an entry that parses,
205            // resolves, and does nothing.
206            if entry.like.is_none() && !entry.has_delimiters() {
207                return Err(error(DeclarationErrorKind::EmptyEntry));
208            }
209
210            // `like` first: it decides the behavior every later rule reads.
211            let declared = match &entry.like {
212                Some(target) => {
213                    let sig = builtin()
214                        .environment(target)
215                        .ok_or_else(|| DeclarationError {
216                            key: dotted_key(["environments", name, "like"]),
217                            kind: DeclarationErrorKind::UnknownLikeTarget {
218                                target: target.clone(),
219                            },
220                        })?;
221                    db.insert_declared_environment(name.clone(), sig.clone());
222                    Some(sig)
223                }
224                None => None,
225            };
226
227            if !entry.has_delimiters() {
228                continue;
229            }
230            if entry.end.is_empty() {
231                return Err(error(DeclarationErrorKind::MissingCloser));
232            }
233            if entry.begin.is_empty() {
234                return Err(error(DeclarationErrorKind::MissingOpener));
235            }
236
237            // A delimiter command has to stand in for *something*: an entry with
238            // no `like` falls back to the built-in of the same name, and an
239            // environment that is neither is one nothing downstream could
240            // resolve.
241            let sig: &EnvironmentSig = declared
242                .or_else(|| builtin().environment(name))
243                .ok_or_else(|| error(DeclarationErrorKind::UndeclaredTarget))?;
244            if sig.verbatim_body {
245                return Err(error(DeclarationErrorKind::VerbatimTarget));
246            }
247            if !sig.args.is_empty() {
248                return Err(error(DeclarationErrorKind::TargetTakesArguments));
249            }
250
251            for (side, spellings) in [("begin", &entry.begin), ("end", &entry.end)] {
252                let error = |kind| DeclarationError {
253                    key: dotted_key(["environments", name, side]),
254                    kind,
255                };
256                for spelling in spellings {
257                    if !is_control_word_name(spelling.as_str()) {
258                        return Err(error(DeclarationErrorKind::NotAControlWord {
259                            name: spelling.clone(),
260                        }));
261                    }
262                    // A spelling the curated database already knows as a command
263                    // is a mistake we can name: `begin = ['\emph']` would turn
264                    // every `\emph` in the project into an environment opener
265                    // wherever the shape gate let it pair. Curated tier only,
266                    // for the same reason `like` is: the CWL tier carries every
267                    // package's names, so rejecting against it would refuse a
268                    // spelling on the say-so of a package the project never
269                    // loads. That leaves the check partial by construction — it
270                    // catches the arity-bearing commands, where a wrong pairing
271                    // also mis-attaches arguments — and it is a backstop, not
272                    // the safety property. The shape gate is still what keeps a
273                    // wrong declaration from corrupting a tree.
274                    if builtin().command(spelling.as_str()).is_some() {
275                        return Err(error(DeclarationErrorKind::SpellingIsABuiltinCommand {
276                            name: spelling.clone(),
277                        }));
278                    }
279                    let key = SmolStr::new(spelling.as_str());
280                    if let Some(first) = claimed.get(&key) {
281                        // Repeating a spelling *within* one entry is a different
282                        // mistake from two entries fighting over it, and reading
283                        // "already declared as a delimiter of `eqnarray`" under
284                        // `environments.eqnarray.begin` helps nobody.
285                        return Err(error(if first == name {
286                            DeclarationErrorKind::RepeatedDelimiter {
287                                name: spelling.clone(),
288                            }
289                        } else {
290                            DeclarationErrorKind::DuplicateDelimiter {
291                                name: spelling.clone(),
292                                first: first.clone(),
293                            }
294                        }));
295                    }
296                    claimed.insert(key.clone(), name.clone());
297                    if side == "begin" {
298                        db.insert_env_begin_alias(key, name.clone());
299                    } else {
300                        db.insert_env_end_alias(key, name.clone());
301                    }
302                }
303            }
304        }
305        Ok(ResolvedDeclarations(db))
306    }
307}
308
309/// A project's declarations, checked and projected into signature data by
310/// [`Declarations::resolve`].
311///
312/// A newtype over [`SignatureDb`] rather than the bare database, and the
313/// distinction is load-bearing at exactly one boundary: this is the only
314/// signature data the *parser* accepts. A value of this type can only have come
315/// from a declaration block, so `parse_with_declarations` cannot be handed a
316/// document's merged scope — which would make the tree a function of package
317/// scans and scanned definitions, the thing `AGENTS.md` decision #8 holds the
318/// line on. Keeping the invariant in the type rather than in review is the same
319/// move the formatter's `Gap` makes for trivia.
320#[derive(Debug, Clone, Default, PartialEq, Eq)]
321pub struct ResolvedDeclarations(SignatureDb);
322
323impl ResolvedDeclarations {
324    /// The declared tier as signature data, for merging into a document's scope
325    /// (where it is the top tier: a declaration is the user explicitly
326    /// correcting an inference).
327    pub fn as_db(&self) -> &SignatureDb {
328        &self.0
329    }
330
331    /// Whether nothing was declared — the common case, and the one that must
332    /// cost the parse nothing.
333    pub fn is_empty(&self) -> bool {
334        self.0 == SignatureDb::default()
335    }
336}
337
338/// A rule [`Declarations::resolve`] rejected, with the dotted key of the entry
339/// that broke it (`environments.myenv.like`) so the CLI can point at the line
340/// the user wrote.
341///
342/// The key is a `String` rather than a borrowed path because the error outlives
343/// the borrow of the config in every caller, and this crate is wasm-clean: it
344/// knows nothing about the file the key came from, which is the CLI's to add.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct DeclarationError {
347    pub key: String,
348    pub kind: DeclarationErrorKind,
349}
350
351/// Why a declaration was rejected. Each variant is a rule from
352/// `AGENTS.md` decision #12 or its architecture section.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub enum DeclarationErrorKind {
355    /// An entry with no keys at all. Nothing to reject it on rule grounds, and
356    /// nothing for it to do either — which is the outcome this module exists to
357    /// avoid.
358    EmptyEntry,
359    /// `like` named something the curated built-in database does not have.
360    /// Never resolved against the CWL tier or scanned definitions: behavior
361    /// comes from curated data only.
362    UnknownLikeTarget { target: SmolStr },
363    /// `begin` without `end`. An opener with no closer can never pair, so the
364    /// declaration would do nothing at all.
365    MissingCloser,
366    /// `end` without `begin`, the mirror.
367    MissingOpener,
368    /// Delimiter spellings for a verbatim environment. Not conservatism but TeX
369    /// truth, which is why it is rejected rather than merely discouraged.
370    VerbatimTarget,
371    /// Delimiter spellings for an environment that takes arguments. A bare
372    /// control word carries none, and attaching them from the target's
373    /// signature would be arity-directed grouping from declaration data.
374    TargetTakesArguments,
375    /// Delimiter spellings for an environment whose behavior is unknown — no
376    /// `like`, and no built-in of that name.
377    UndeclaredTarget,
378    /// A spelling two entries both claim. Silently letting the last one win
379    /// would make the pairing depend on map order.
380    DuplicateDelimiter { name: CommandName, first: SmolStr },
381    /// A spelling one entry lists twice — across its two sides, or twice on
382    /// one. The [`DuplicateDelimiter`](Self::DuplicateDelimiter) mistake seen
383    /// from inside a single entry, where naming the "other" entry is no help.
384    RepeatedDelimiter { name: CommandName },
385    /// A spelling the lexer could never produce as one control word, so it
386    /// could never match anything.
387    NotAControlWord { name: CommandName },
388    /// A spelling the curated database already knows as a command. Not a
389    /// no-op — it would take effect, on a command the project did not mean to
390    /// redefine.
391    SpellingIsABuiltinCommand { name: CommandName },
392}
393
394/// Join `segments` into a TOML dotted key, quoting any segment that is not a
395/// bare key so the result can be pasted back into `badness.toml`.
396///
397/// An environment may be named anything, and `environments.my.env` would point
398/// at a key the user never wrote.
399fn dotted_key<'a>(segments: impl IntoIterator<Item = &'a str>) -> String {
400    let mut key = String::new();
401    for segment in segments {
402        if !key.is_empty() {
403            key.push('.');
404        }
405        let bare = !segment.is_empty()
406            && segment
407                .chars()
408                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
409        if bare {
410            key.push_str(segment);
411        } else {
412            key.push('"');
413            key.push_str(&segment.replace('\\', "\\\\").replace('"', "\\\""));
414            key.push('"');
415        }
416    }
417    key
418}
419
420impl fmt::Display for DeclarationError {
421    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
422        write!(f, "`{}`: {}", self.key, self.kind)
423    }
424}
425
426impl fmt::Display for DeclarationErrorKind {
427    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        match self {
429            Self::EmptyEntry => write!(
430                f,
431                "declares nothing; add `like` to say what the environment behaves like, or \
432                 `begin`/`end` to give it delimiter spellings"
433            ),
434            Self::UnknownLikeTarget { target } => write!(
435                f,
436                "unknown environment `{target}`; `like` must name an environment badness \
437                 knows about"
438            ),
439            Self::MissingCloser => write!(
440                f,
441                "declares `begin` but no `end`; an opener that cannot be closed never pairs"
442            ),
443            Self::MissingOpener => write!(
444                f,
445                "declares `end` but no `begin`; a closer with nothing to close never pairs"
446            ),
447            Self::VerbatimTarget => write!(
448                f,
449                "a command cannot stand in for a verbatim environment's delimiters, because \
450                 TeX never expands the closer — the verbatim scanner has already swallowed \
451                 it. Declare the environment name on its own, without `begin`/`end`"
452            ),
453            Self::TargetTakesArguments => write!(
454                f,
455                "the environment takes arguments, which a delimiter command cannot carry; \
456                 declare the environment name on its own, without `begin`/`end`"
457            ),
458            Self::UndeclaredTarget => write!(
459                f,
460                "declares delimiters for an environment badness does not know; add `like` \
461                 to say what it behaves like"
462            ),
463            Self::DuplicateDelimiter { name, first } => write!(
464                f,
465                "`{name}` is already declared as a delimiter of `{first}`"
466            ),
467            Self::RepeatedDelimiter { name } => {
468                write!(
469                    f,
470                    "`{name}` is listed twice as a delimiter of this environment"
471                )
472            }
473            Self::NotAControlWord { name } => write!(
474                f,
475                "`{name}` is not a control word; a delimiter must be a name of letters"
476            ),
477            Self::SpellingIsABuiltinCommand { name } => write!(
478                f,
479                "`{name}` is already a LaTeX command badness knows; a delimiter spelling must \
480                 be a command of your own, or the declaration would change what `{name}` means \
481                 everywhere in the project"
482            ),
483        }
484    }
485}
486
487impl std::error::Error for DeclarationError {}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    fn from_json(json: &str) -> Declarations {
494        serde_json::from_str(json).expect("deserializes")
495    }
496
497    #[test]
498    fn empty_declarations_are_the_default() {
499        assert!(Declarations::default().is_empty());
500        assert!(from_json("{}").is_empty());
501    }
502
503    #[test]
504    fn an_entry_may_declare_behavior_alone() {
505        let decls = from_json(r#"{"environments": {"myenv": {"like": "align"}}}"#);
506        let entry = &decls.environments["myenv"];
507        assert_eq!(entry.like.as_deref(), Some("align"));
508        assert!(!entry.has_delimiters());
509        assert!(!decls.is_empty());
510    }
511
512    #[test]
513    fn an_entry_may_declare_delimiters_alone() {
514        let decls =
515            from_json(r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#);
516        let entry = &decls.environments["eqnarray"];
517        assert_eq!(entry.like, None);
518        assert!(entry.has_delimiters());
519        assert_eq!(entry.begin, vec![CommandName::new("bea")]);
520        assert_eq!(entry.end, vec![CommandName::new("eea")]);
521    }
522
523    /// The `\startmyenv … \endmyenv` shape from the issue: an environment with no
524    /// built-in counterpart, reached only through commands. One entry covers it.
525    #[test]
526    fn an_entry_may_declare_both() {
527        let decls = from_json(
528            r#"{"environments": {"mytheorem": {
529                 "like": "theorem",
530                 "begin": ["\\startmyenv"],
531                 "end": ["\\endmyenv"]
532               }}}"#,
533        );
534        let entry = &decls.environments["mytheorem"];
535        assert_eq!(entry.like.as_deref(), Some("theorem"));
536        assert_eq!(entry.begin, vec![CommandName::new("startmyenv")]);
537    }
538
539    /// TOML users write `'\bea'`; a leading backslash is optional and both
540    /// spellings must reach the same key, since a control word can never
541    /// contain one.
542    #[test]
543    fn a_leading_backslash_is_optional_and_normalized_away() {
544        assert_eq!(CommandName::new("\\bea"), CommandName::new("bea"));
545        assert_eq!(CommandName::new("\\bea").as_str(), "bea");
546        let decls = from_json(r#"{"environments": {"e": {"begin": ["bea", "\\bea"]}}}"#);
547        assert_eq!(
548            decls.environments["e"].begin,
549            vec![CommandName::new("bea"), CommandName::new("bea")]
550        );
551    }
552
553    /// Only *one* backslash is stripped, so a control symbol keeps its shape and
554    /// resolution can reject it by name rather than silently seeing a word.
555    #[test]
556    fn only_one_backslash_is_stripped() {
557        assert_eq!(CommandName::new("\\\\").as_str(), "\\");
558    }
559
560    /// A diagnostic should spell the name back the way the user wrote it.
561    #[test]
562    fn display_restores_the_backslash() {
563        assert_eq!(CommandName::new("bea").to_string(), "\\bea");
564    }
565
566    #[test]
567    fn a_misspelled_key_is_rejected_rather_than_ignored() {
568        let err = serde_json::from_str::<Declarations>(
569            r#"{"environments": {"myenv": {"liek": "align"}}}"#,
570        )
571        .expect_err("unknown field is rejected");
572        assert!(err.to_string().contains("liek"), "{err}");
573
574        let err = serde_json::from_str::<Declarations>(r#"{"enviroments": {}}"#)
575            .expect_err("unknown section is rejected");
576        assert!(err.to_string().contains("enviroments"), "{err}");
577    }
578
579    /// The wire spellings are public API (module docs), so a field rename must
580    /// fail a test rather than silently break every user's config.
581    #[test]
582    fn wire_spellings_are_pinned() {
583        let decls = from_json(
584            r#"{"environments": {"myenv": {"like": "align", "begin": ["\\b"], "end": ["\\e"]}}}"#,
585        );
586        let json = serde_json::to_value(&decls).expect("serializes");
587        let entry = &json["environments"]["myenv"];
588        assert_eq!(entry["like"], "align");
589        assert_eq!(entry["begin"][0], "b");
590        assert_eq!(entry["end"][0], "e");
591    }
592
593    /// Deterministic iteration: resolution reports errors in the order the user
594    /// reads them, and the value lands on a salsa input.
595    #[test]
596    fn environments_iterate_in_name_order() {
597        let decls = from_json(r#"{"environments": {"zed": {}, "alpha": {}, "mid": {}}}"#);
598        let names: Vec<&str> = decls.environments.keys().map(SmolStr::as_str).collect();
599        assert_eq!(names, ["alpha", "mid", "zed"]);
600    }
601
602    // --- resolution
603
604    fn resolve(json: &str) -> SignatureDb {
605        from_json(json).resolve().expect("resolves").as_db().clone()
606    }
607
608    fn resolve_err(json: &str) -> DeclarationError {
609        from_json(json).resolve().expect_err("is rejected")
610    }
611
612    #[test]
613    fn nothing_declared_resolves_to_nothing() {
614        assert!(from_json("{}").resolve().expect("resolves").is_empty());
615    }
616
617    /// `like` copies the curated entry wholesale, so every behavior flag —
618    /// including ones added later — comes along without being named in config.
619    #[test]
620    fn like_copies_the_builtin_entry() {
621        let db = resolve(r#"{"environments": {"myenv": {"like": "align"}}}"#);
622        let sig = db.environment("myenv").expect("declared");
623        assert_eq!(sig, builtin().environment("align").expect("builtin"));
624        assert!(sig.math && sig.align);
625    }
626
627    /// The parked `codeexample` knob: naming a verbatim environment is exactly
628    /// what an entry with no delimiters is for.
629    #[test]
630    fn like_may_name_a_verbatim_environment() {
631        let db = resolve(r#"{"environments": {"mycode": {"like": "lstlisting"}}}"#);
632        assert!(db.environment("mycode").expect("declared").verbatim_body);
633    }
634
635    /// An argument-taking target is fine too, as long as no command has to stand
636    /// in for the delimiters: `\begin{mytab}{ll}` carries its own arguments.
637    #[test]
638    fn like_may_name_an_argument_taking_environment() {
639        let db = resolve(r#"{"environments": {"mytab": {"like": "tabular"}}}"#);
640        assert!(!db.environment("mytab").expect("declared").args.is_empty());
641    }
642
643    #[test]
644    fn a_mistyped_like_target_is_an_error_not_a_silent_no_op() {
645        let err = resolve_err(r#"{"environments": {"myenv": {"like": "algin"}}}"#);
646        assert_eq!(err.key, "environments.myenv.like");
647        assert!(matches!(
648            err.kind,
649            DeclarationErrorKind::UnknownLikeTarget { .. }
650        ));
651        assert!(err.to_string().contains("algin"), "{err}");
652    }
653
654    /// `like` resolves against the curated tier alone. The CWL tier carries
655    /// names and arity with every behavior flag left at its default, so copying
656    /// from it would hand back a signature that says nothing.
657    #[test]
658    fn like_does_not_resolve_against_the_cwl_tier() {
659        let cwl_only = crate::semantic::signature::cwl()
660            .environment_names()
661            .find(|name| builtin().environment(name).is_none())
662            .expect("the CWL tier has an environment the curated one does not")
663            .to_string();
664        let err = resolve_err(&format!(
665            r#"{{"environments": {{"myenv": {{"like": "{cwl_only}"}}}}}}"#
666        ));
667        assert!(matches!(
668            err.kind,
669            DeclarationErrorKind::UnknownLikeTarget { .. }
670        ));
671    }
672
673    /// The issue's own case: spellings for an environment badness already knows,
674    /// needing no `like` at all.
675    #[test]
676    fn delimiters_for_a_builtin_environment_need_no_like() {
677        let db =
678            resolve(r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#);
679        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
680        assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
681        // Behavior still comes from the built-in entry, so nothing is cloned in
682        // under the environment's own name.
683        assert!(db.environment("eqnarray").is_none());
684    }
685
686    /// The `\startmyenv … \endmyenv` shape: behavior *and* spellings, one entry.
687    #[test]
688    fn delimiters_and_like_resolve_together() {
689        let db = resolve(
690            r#"{"environments": {"mytheorem": {
691                 "like": "theorem",
692                 "begin": ["\\startmyenv"],
693                 "end": ["\\endmyenv"]
694               }}}"#,
695        );
696        assert_eq!(db.env_begin_alias("startmyenv"), Some("mytheorem"));
697        assert!(db.environment("mytheorem").is_some());
698    }
699
700    /// Several spellings may open the same environment; pairing is by kind, not
701    /// by index, so the lists need not be the same length.
702    #[test]
703    fn an_environment_may_have_several_spellings_per_side() {
704        let db = resolve(
705            r#"{"environments": {"eqnarray": {
706                 "begin": ["\\bea", "\\beqa"], "end": ["\\eea"]
707               }}}"#,
708        );
709        assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
710        assert_eq!(db.env_begin_alias("beqa"), Some("eqnarray"));
711    }
712
713    #[test]
714    fn an_opener_without_a_closer_is_an_error() {
715        let err = resolve_err(r#"{"environments": {"eqnarray": {"begin": ["\\bea"]}}}"#);
716        assert_eq!(err.key, "environments.eqnarray");
717        assert_eq!(err.kind, DeclarationErrorKind::MissingCloser);
718    }
719
720    #[test]
721    fn a_closer_without_an_opener_is_an_error() {
722        let err = resolve_err(r#"{"environments": {"eqnarray": {"end": ["\\eea"]}}}"#);
723        assert_eq!(err.kind, DeclarationErrorKind::MissingOpener);
724    }
725
726    /// TeX truth, not conservatism: the closer alias is never expanded, because
727    /// the verbatim scanner has already swallowed it.
728    #[test]
729    fn delimiters_for_a_verbatim_environment_are_rejected() {
730        let err =
731            resolve_err(r#"{"environments": {"verbatim": {"begin": ["\\bv"], "end": ["\\ev"]}}}"#);
732        assert_eq!(err.kind, DeclarationErrorKind::VerbatimTarget);
733
734        // Reached through `like` as well as by name.
735        let err = resolve_err(
736            r#"{"environments": {"mycode": {
737                 "like": "lstlisting", "begin": ["\\bc"], "end": ["\\ec"]
738               }}}"#,
739        );
740        assert_eq!(err.kind, DeclarationErrorKind::VerbatimTarget);
741    }
742
743    #[test]
744    fn delimiters_for_an_argument_taking_environment_are_rejected() {
745        let err =
746            resolve_err(r#"{"environments": {"tabular": {"begin": ["\\bt"], "end": ["\\et"]}}}"#);
747        assert_eq!(err.kind, DeclarationErrorKind::TargetTakesArguments);
748    }
749
750    #[test]
751    fn delimiters_for_an_unknown_environment_ask_for_like() {
752        let err = resolve_err(r#"{"environments": {"myenv": {"begin": ["\\b"], "end": ["\\e"]}}}"#);
753        assert_eq!(err.kind, DeclarationErrorKind::UndeclaredTarget);
754        assert!(err.to_string().contains("like"), "{err}");
755    }
756
757    /// The one shape that says too little. A header with nothing under it
758    /// parses, breaks no rule, and does nothing — the outcome every other rule
759    /// here exists to prevent.
760    #[test]
761    fn an_entry_that_declares_nothing_is_an_error() {
762        let err = resolve_err(r#"{"environments": {"myenv": {}}}"#);
763        assert_eq!(err.key, "environments.myenv");
764        assert_eq!(err.kind, DeclarationErrorKind::EmptyEntry);
765        assert!(err.to_string().contains("like"), "{err}");
766    }
767
768    /// A spelling badness already knows as a command would *take effect* rather
769    /// than do nothing, on a command the project never meant to touch.
770    #[test]
771    fn a_spelling_that_is_already_a_builtin_command_is_rejected() {
772        let err =
773            resolve_err(r#"{"environments": {"center": {"begin": ["\\emph"], "end": ["\\ec"]}}}"#);
774        assert_eq!(err.key, "environments.center.begin");
775        assert!(matches!(
776            err.kind,
777            DeclarationErrorKind::SpellingIsABuiltinCommand { .. }
778        ));
779        assert!(err.to_string().contains("emph"), "{err}");
780    }
781
782    /// The check reads the curated tier alone, so a name only the bulk CWL tier
783    /// carries is still a project's to spell — the same scoping `like` has, and
784    /// for the same reason: CWL knows every package, including ones the project
785    /// never loads.
786    #[test]
787    fn a_cwl_only_command_name_is_still_available_as_a_spelling() {
788        let cwl_only = crate::semantic::signature::cwl()
789            .command_names()
790            .find(|name| {
791                builtin().command(name).is_none() && is_control_word_name(name) && name.len() > 2
792            })
793            .expect("the CWL tier has a command the curated one does not")
794            .to_string();
795        let db = resolve(&format!(
796            r#"{{"environments": {{"center": {{"begin": ["{cwl_only}"], "end": ["\\ec"]}}}}}}"#
797        ));
798        assert_eq!(db.env_begin_alias(&cwl_only), Some("center"));
799    }
800
801    /// The error key is a dotted key the user can paste back, so a name that is
802    /// not a bare TOML key is quoted the way they had to write it.
803    #[test]
804    fn the_error_key_quotes_a_name_that_is_not_a_bare_key() {
805        let err = resolve_err(r#"{"environments": {"my.env": {}}}"#);
806        assert_eq!(err.key, r#"environments."my.env""#);
807        let err = resolve_err(r#"{"environments": {"my env": {"like": "algin"}}}"#);
808        assert_eq!(err.key, r#"environments."my env".like"#);
809    }
810
811    /// Two entries claiming one spelling would otherwise resolve by map order.
812    #[test]
813    fn a_spelling_may_not_be_claimed_twice() {
814        let err = resolve_err(
815            r#"{"environments": {
816                 "align": {"begin": ["\\bx"], "end": ["\\ex"]},
817                 "equation": {"begin": ["\\bx"], "end": ["\\ey"]}
818               }}"#,
819        );
820        assert_eq!(
821            err.kind,
822            DeclarationErrorKind::DuplicateDelimiter {
823                name: CommandName::new("bx"),
824                first: SmolStr::new("align"),
825            }
826        );
827    }
828
829    /// Including across the two sides, where the two maps would each claim it —
830    /// reported as the *repeat* it is, since naming the owning entry would just
831    /// name the entry the error is already keyed to.
832    #[test]
833    fn a_spelling_may_not_be_both_opener_and_closer() {
834        let err = resolve_err(r#"{"environments": {"align": {"begin": ["\\x"], "end": ["\\x"]}}}"#);
835        assert_eq!(err.key, "environments.align.end");
836        assert_eq!(
837            err.kind,
838            DeclarationErrorKind::RepeatedDelimiter {
839                name: CommandName::new("x"),
840            }
841        );
842    }
843
844    /// A spelling the lexer would split into two tokens can never match, so
845    /// accepting it would be a silent no-op.
846    #[test]
847    fn a_spelling_that_could_never_lex_as_one_control_word_is_rejected() {
848        for bad in ["b ea", "bea2", "", "b-ea"] {
849            let json = format!(
850                r#"{{"environments": {{"align": {{"begin": ["{bad}"], "end": ["\\ex"]}}}}}}"#
851            );
852            let err = resolve_err(&json);
853            assert!(
854                matches!(err.kind, DeclarationErrorKind::NotAControlWord { .. }),
855                "`{bad}` should be rejected, got {err:?}"
856            );
857        }
858    }
859
860    /// `@` and expl3's `_`/`:` are letters in the regimes a `.sty` is read
861    /// under, and a declaration does not say which file it will apply to.
862    #[test]
863    fn a_spelling_may_use_letters_of_any_catcode_regime() {
864        let db =
865            resolve(r#"{"environments": {"align": {"begin": ["\\my@b"], "end": ["\\my_e:n"]}}}"#);
866        assert_eq!(db.env_begin_alias("my@b"), Some("align"));
867        assert_eq!(db.env_end_alias("my_e:n"), Some("align"));
868    }
869
870    /// The resolved tier is a `SignatureDb`, so it folds into a document's scope
871    /// with the merge the scanned tier already uses — which is what step 5 of
872    /// the plan needs and why the return type is not bespoke.
873    #[test]
874    fn the_resolved_tier_merges_like_any_other() {
875        let declared = resolve(
876            r#"{"environments": {"myenv": {"like": "align"}, "eqnarray": {
877                 "begin": ["\\bea"], "end": ["\\eea"]
878               }}}"#,
879        );
880        let mut scope = SignatureDb::default();
881        scope.merge_from(&declared);
882        assert!(scope.environment("myenv").is_some());
883        assert_eq!(scope.env_begin_alias("bea"), Some("eqnarray"));
884    }
885
886    // --- resolution reaching the semantic layer
887
888    /// Parse `src` under `json`'s declarations and resolve the signature
889    /// governing its first `ENVIRONMENT` node, through the scope a document
890    /// would build: scanned definitions first, declarations overlaid on top.
891    fn environment_sig_at(src: &str, json: &str) -> Option<EnvironmentSig> {
892        scope_and_sig_at(src, json).1
893    }
894
895    /// [`environment_sig_at`], also returning the name-keyed answer for the
896    /// alias's target, so a test can show the two lookups diverge.
897    fn scope_and_sig_at(src: &str, json: &str) -> (Option<EnvironmentSig>, Option<EnvironmentSig>) {
898        use crate::parser::{LatexFlavor, parse_with_declarations};
899        use crate::semantic::define::scan_definitions;
900        use crate::semantic::signature::Signatures;
901        use crate::syntax::{SyntaxKind, SyntaxNode};
902
903        let decls = from_json(json).resolve().expect("resolves");
904        let parsed = parse_with_declarations(src, LatexFlavor::Document, &decls);
905        let root = SyntaxNode::new_root(parsed.green);
906        let mut scope = scan_definitions(&root);
907        scope.merge_declarations(&decls);
908        let node = root
909            .descendants()
910            .find(|n| n.kind() == SyntaxKind::ENVIRONMENT)
911            .expect("an environment");
912        let sigs = Signatures::new(&scope);
913        (
914            sigs.environment("eqnarray").cloned(),
915            sigs.environment_at(&node).cloned(),
916        )
917    }
918
919    /// The sharp edge: an alias whose target is *itself* declared. Resolving the
920    /// target against `builtin()` alone would find nothing, so `\startmyenv`
921    /// would pair and then inherit no behavior at all.
922    #[test]
923    fn a_declared_alias_resolves_to_a_declared_target() {
924        let sig = environment_sig_at(
925            "\\startmyenv x \\endmyenv\n",
926            r#"{"environments": {"myenv": {
927                 "like": "align", "begin": ["\\startmyenv"], "end": ["\\endmyenv"]
928               }}}"#,
929        )
930        .expect("the alias resolves");
931        assert_eq!(&sig, builtin().environment("align").expect("curated"));
932    }
933
934    /// And the rule that edge must not break: a *scanned* definition still lends
935    /// an alias nothing. Here `eqnarray` is redefined in the file, but the alias
936    /// resolves to the curated entry, because only curated data may reach it.
937    #[test]
938    fn a_scanned_definition_still_lends_an_alias_nothing() {
939        let (scanned, sig) = scope_and_sig_at(
940            "\\newenvironment{eqnarray}{}{}\n\\bea x \\eea\n",
941            r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#,
942        );
943        let sig = sig.expect("the alias resolves");
944        assert_eq!(&sig, builtin().environment("eqnarray").expect("curated"));
945        // The scan really did land a shadowing entry, and the *name*-keyed
946        // lookup sees it — so the two answers genuinely diverge here, and the
947        // alias took the curated one.
948        let scanned = scanned.expect("the scan records the redefinition");
949        assert!(!scanned.math, "the scanned redefinition is not math");
950        assert!(sig.math, "the alias resolves to the curated entry");
951    }
952}