Skip to main content

badness_parser/
declarations.rs

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