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}