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}