1use std::collections::BTreeMap;
24use std::fmt;
25
26use serde::{Deserialize, Serialize};
27use smol_str::SmolStr;
28
29use crate::parser::lexer::is_control_word_name;
30use crate::semantic::builder::{cite_command, ref_command};
31use crate::semantic::signature::{EnvironmentSig, SignatureDb, builtin};
32
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
43#[serde(transparent)]
44pub struct CommandName(SmolStr);
45
46impl CommandName {
47 pub fn new(name: &str) -> Self {
49 Self(SmolStr::new(name.strip_prefix('\\').unwrap_or(name)))
50 }
51
52 pub fn as_str(&self) -> &str {
54 &self.0
55 }
56}
57
58impl From<&str> for CommandName {
59 fn from(name: &str) -> Self {
60 Self::new(name)
61 }
62}
63
64impl fmt::Display for CommandName {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68 write!(f, "\\{}", self.0)
69 }
70}
71
72impl<'de> Deserialize<'de> for CommandName {
73 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
74 where
75 D: serde::Deserializer<'de>,
76 {
77 let raw = String::deserialize(deserializer)?;
78 Ok(Self::new(&raw))
79 }
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
91#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
92pub struct EnvironmentDecl {
93 pub like: Option<SmolStr>,
103 pub begin: Vec<CommandName>,
108 pub end: Vec<CommandName>,
112}
113
114impl EnvironmentDecl {
115 pub fn has_delimiters(&self) -> bool {
118 !self.begin.is_empty() || !self.end.is_empty()
119 }
120}
121
122pub type EnvironmentDecls = BTreeMap<SmolStr, EnvironmentDecl>;
125
126#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
129#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
130pub struct CommandDecl {
131 pub like: Option<SmolStr>,
133}
134
135pub type CommandDecls = BTreeMap<SmolStr, CommandDecl>;
137
138#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
144#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
145pub struct Declarations {
146 pub commands: CommandDecls,
148 pub environments: EnvironmentDecls,
150}
151
152impl Declarations {
153 pub fn is_empty(&self) -> bool {
156 self.commands.is_empty() && self.environments.is_empty()
157 }
158
159 pub fn resolve(&self) -> Result<ResolvedDeclarations, DeclarationError> {
186 let mut db = SignatureDb::default();
187 let mut commands = BTreeMap::new();
188
189 for (name, entry) in &self.commands {
190 let error = |kind| DeclarationError {
191 key: dotted_key(["commands", name]),
192 kind,
193 };
194 if !is_control_word_name(name) {
195 return Err(error(DeclarationErrorKind::InvalidCommandName {
196 name: SmolStr::new(name),
197 }));
198 }
199 if is_builtin_command(name) {
200 return Err(error(DeclarationErrorKind::BuiltinCommandName {
201 name: SmolStr::new(name),
202 }));
203 }
204 let target = entry
205 .like
206 .as_ref()
207 .ok_or_else(|| error(DeclarationErrorKind::EmptyCommandEntry))?;
208 if ref_command(target).is_none() && cite_command(target).is_none() {
214 return Err(DeclarationError {
215 key: dotted_key(["commands", name, "like"]),
216 kind: DeclarationErrorKind::UnknownCommandLikeTarget {
217 target: target.clone(),
218 },
219 });
220 }
221 commands.insert(name.clone(), target.clone());
222 }
223
224 let mut claimed: BTreeMap<SmolStr, SmolStr> = BTreeMap::new();
227
228 for (name, entry) in &self.environments {
229 let error = |kind| DeclarationError {
230 key: dotted_key(["environments", name]),
231 kind,
232 };
233
234 if entry.like.is_none() && !entry.has_delimiters() {
240 return Err(error(DeclarationErrorKind::EmptyEntry));
241 }
242
243 let declared = match &entry.like {
245 Some(target) => {
246 let sig = builtin()
247 .environment(target)
248 .ok_or_else(|| DeclarationError {
249 key: dotted_key(["environments", name, "like"]),
250 kind: DeclarationErrorKind::UnknownLikeTarget {
251 target: target.clone(),
252 },
253 })?;
254 db.insert_declared_environment(name.clone(), sig.clone());
255 Some(sig)
256 }
257 None => None,
258 };
259
260 if !entry.has_delimiters() {
261 continue;
262 }
263
264 let sig: &EnvironmentSig = declared
269 .or_else(|| builtin().environment(name))
270 .ok_or_else(|| error(DeclarationErrorKind::UndeclaredTarget))?;
271 if sig.verbatim_body {
272 return Err(error(DeclarationErrorKind::VerbatimTarget));
273 }
274 if !sig.args.is_empty() {
275 return Err(error(DeclarationErrorKind::TargetTakesArguments));
276 }
277
278 for (side, spellings) in [("begin", &entry.begin), ("end", &entry.end)] {
279 let error = |kind| DeclarationError {
280 key: dotted_key(["environments", name, side]),
281 kind,
282 };
283 for spelling in spellings {
284 if let Some(env) = literal_delimiter_target(spelling.as_str()) {
291 return Err(error(DeclarationErrorKind::SpellingIsALiteralDelimiter {
292 name: spelling.clone(),
293 environment: SmolStr::new(env),
294 }));
295 }
296 if !is_control_word_name(spelling.as_str()) {
297 return Err(error(DeclarationErrorKind::NotAControlWord {
298 name: spelling.clone(),
299 }));
300 }
301 if builtin().command(spelling.as_str()).is_some() {
314 return Err(error(DeclarationErrorKind::SpellingIsABuiltinCommand {
315 name: spelling.clone(),
316 }));
317 }
318 let key = SmolStr::new(spelling.as_str());
319 if let Some(first) = claimed.get(&key) {
320 return Err(error(if first == name {
325 DeclarationErrorKind::RepeatedDelimiter {
326 name: spelling.clone(),
327 }
328 } else {
329 DeclarationErrorKind::DuplicateDelimiter {
330 name: spelling.clone(),
331 first: first.clone(),
332 }
333 }));
334 }
335 claimed.insert(key.clone(), name.clone());
336 if side == "begin" {
337 db.insert_env_begin_alias(key, name.clone());
338 } else {
339 db.insert_env_end_alias(key, name.clone());
340 }
341 }
342 }
343 }
344 Ok(ResolvedDeclarations { db, commands })
345 }
346}
347
348#[derive(Debug, Clone, Default, PartialEq, Eq)]
357pub struct ResolvedDeclarations {
358 db: SignatureDb,
359 commands: BTreeMap<SmolStr, SmolStr>,
360}
361
362impl ResolvedDeclarations {
363 pub fn as_db(&self) -> &SignatureDb {
367 &self.db
368 }
369
370 pub fn command_like(&self, name: &str) -> Option<&str> {
372 self.commands.get(name).map(SmolStr::as_str)
373 }
374
375 pub fn command_names(&self) -> impl Iterator<Item = &str> {
377 self.commands.keys().map(SmolStr::as_str)
378 }
379
380 pub fn is_empty(&self) -> bool {
383 self.db == SignatureDb::default() && self.commands.is_empty()
384 }
385
386 pub fn parse_tier(&self) -> Self {
397 Self {
398 db: self.db.clone(),
399 commands: BTreeMap::new(),
400 }
401 }
402
403 pub fn semantic_tier(&self) -> Self {
410 Self {
411 db: SignatureDb::default(),
412 commands: self.commands.clone(),
413 }
414 }
415}
416
417#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct DeclarationError {
426 pub key: String,
427 pub kind: DeclarationErrorKind,
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
433pub enum DeclarationErrorKind {
434 EmptyCommandEntry,
436 InvalidCommandName { name: SmolStr },
438 BuiltinCommandName { name: SmolStr },
440 UnknownCommandLikeTarget { target: SmolStr },
442 EmptyEntry,
446 UnknownLikeTarget { target: SmolStr },
450 VerbatimTarget,
453 TargetTakesArguments,
457 UndeclaredTarget,
460 DuplicateDelimiter { name: CommandName, first: SmolStr },
463 RepeatedDelimiter { name: CommandName },
467 SpellingIsALiteralDelimiter {
472 name: CommandName,
473 environment: SmolStr,
474 },
475 NotAControlWord { name: CommandName },
478 SpellingIsABuiltinCommand { name: CommandName },
482}
483
484fn is_builtin_command(name: &str) -> bool {
493 builtin().command(name).is_some() || ref_command(name).is_some() || cite_command(name).is_some()
494}
495
496fn literal_delimiter_target(spelling: &str) -> Option<&str> {
505 let rest = spelling
506 .strip_prefix("begin")
507 .or_else(|| spelling.strip_prefix("end"))?;
508 let name = rest.strip_prefix('{')?.strip_suffix('}')?.trim();
509 (!name.is_empty()).then_some(name)
510}
511
512fn dotted_key<'a>(segments: impl IntoIterator<Item = &'a str>) -> String {
518 let mut key = String::new();
519 for segment in segments {
520 if !key.is_empty() {
521 key.push('.');
522 }
523 let bare = !segment.is_empty()
524 && segment
525 .chars()
526 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
527 if bare {
528 key.push_str(segment);
529 } else {
530 key.push('"');
531 key.push_str(&segment.replace('\\', "\\\\").replace('"', "\\\""));
532 key.push('"');
533 }
534 }
535 key
536}
537
538impl fmt::Display for DeclarationError {
539 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
540 write!(f, "`{}`: {}", self.key, self.kind)
541 }
542}
543
544impl fmt::Display for DeclarationErrorKind {
545 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
546 match self {
547 Self::EmptyCommandEntry => write!(
548 f,
549 "declares nothing; add `like` naming a reference or citation command"
550 ),
551 Self::InvalidCommandName { name } => write!(
552 f,
553 "`{name}` is not a control-word name; command names may contain letters, `@`, `_`, or `:`"
554 ),
555 Self::BuiltinCommandName { name } => write!(
556 f,
557 "`\\{name}` is already a curated LaTeX command; command declarations may only name project commands"
558 ),
559 Self::UnknownCommandLikeTarget { target } => write!(
560 f,
561 "unknown reference or citation command `{target}`; `like` must name a curated ref/cite command"
562 ),
563 Self::EmptyEntry => write!(
564 f,
565 "declares nothing; add `like` to say what the environment behaves like, or \
566 `begin`/`end` to give it delimiter spellings"
567 ),
568 Self::UnknownLikeTarget { target } => write!(
569 f,
570 "unknown environment `{target}`; `like` must name an environment badness \
571 knows about"
572 ),
573 Self::VerbatimTarget => write!(
574 f,
575 "a command cannot stand in for a verbatim environment's delimiters, because \
576 TeX never expands the closer — the verbatim scanner has already swallowed \
577 it. Declare the environment name on its own, without `begin`/`end`"
578 ),
579 Self::TargetTakesArguments => write!(
580 f,
581 "the environment takes arguments, which a delimiter command cannot carry; \
582 declare the environment name on its own, without `begin`/`end`"
583 ),
584 Self::UndeclaredTarget => write!(
585 f,
586 "declares delimiters for an environment badness does not know; add `like` \
587 to say what it behaves like"
588 ),
589 Self::DuplicateDelimiter { name, first } => write!(
590 f,
591 "`{name}` is already declared as a delimiter of `{first}`"
592 ),
593 Self::RepeatedDelimiter { name } => {
594 write!(
595 f,
596 "`{name}` is listed twice as a delimiter of this environment"
597 )
598 }
599 Self::SpellingIsALiteralDelimiter { name, environment } => write!(
600 f,
601 "`{name}` is the delimiter itself, not a command standing in for one — and \
602 badness already pairs a declared spelling with the written-out \
603 `\\begin{{{environment}}}`/`\\end{{{environment}}}`, so this key can be \
604 removed"
605 ),
606 Self::NotAControlWord { name } => write!(
607 f,
608 "`{name}` is not a control word; a delimiter must be a name of letters"
609 ),
610 Self::SpellingIsABuiltinCommand { name } => write!(
611 f,
612 "`{name}` is already a LaTeX command badness knows; a delimiter spelling must \
613 be a command of your own, or the declaration would change what `{name}` means \
614 everywhere in the project"
615 ),
616 }
617 }
618}
619
620impl std::error::Error for DeclarationError {}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 fn from_json(json: &str) -> Declarations {
627 serde_json::from_str(json).expect("deserializes")
628 }
629
630 #[test]
631 fn empty_declarations_are_the_default() {
632 assert!(Declarations::default().is_empty());
633 assert!(from_json("{}").is_empty());
634 }
635
636 #[test]
637 fn a_command_may_declare_a_reference_family() {
638 let decls = from_json(r#"{"commands": {"eqrefs": {"like": "cref"}}}"#);
639 assert_eq!(decls.commands["eqrefs"].like.as_deref(), Some("cref"));
640 assert!(!decls.is_empty());
641 }
642
643 #[test]
644 fn an_entry_may_declare_behavior_alone() {
645 let decls = from_json(r#"{"environments": {"myenv": {"like": "align"}}}"#);
646 let entry = &decls.environments["myenv"];
647 assert_eq!(entry.like.as_deref(), Some("align"));
648 assert!(!entry.has_delimiters());
649 assert!(!decls.is_empty());
650 }
651
652 #[test]
653 fn an_entry_may_declare_delimiters_alone() {
654 let decls =
655 from_json(r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#);
656 let entry = &decls.environments["eqnarray"];
657 assert_eq!(entry.like, None);
658 assert!(entry.has_delimiters());
659 assert_eq!(entry.begin, vec![CommandName::new("bea")]);
660 assert_eq!(entry.end, vec![CommandName::new("eea")]);
661 }
662
663 #[test]
664 fn an_entry_may_declare_both() {
665 let decls = from_json(
666 r#"{"environments": {"mytheorem": {
667 "like": "theorem",
668 "begin": ["\\startmyenv"],
669 "end": ["\\endmyenv"]
670 }}}"#,
671 );
672 let entry = &decls.environments["mytheorem"];
673 assert_eq!(entry.like.as_deref(), Some("theorem"));
674 assert_eq!(entry.begin, vec![CommandName::new("startmyenv")]);
675 }
676
677 #[test]
678 fn a_leading_backslash_is_optional_and_normalized_away() {
679 assert_eq!(CommandName::new("\\bea"), CommandName::new("bea"));
680 assert_eq!(CommandName::new("\\bea").as_str(), "bea");
681 let decls = from_json(r#"{"environments": {"e": {"begin": ["bea", "\\bea"]}}}"#);
682 assert_eq!(
683 decls.environments["e"].begin,
684 vec![CommandName::new("bea"), CommandName::new("bea")]
685 );
686 }
687
688 #[test]
689 fn only_one_backslash_is_stripped() {
690 assert_eq!(CommandName::new("\\\\").as_str(), "\\");
691 }
692
693 #[test]
694 fn display_restores_the_backslash() {
695 assert_eq!(CommandName::new("bea").to_string(), "\\bea");
696 }
697
698 #[test]
699 fn a_misspelled_key_is_rejected_rather_than_ignored() {
700 let err = serde_json::from_str::<Declarations>(
701 r#"{"environments": {"myenv": {"liek": "align"}}}"#,
702 )
703 .expect_err("unknown field is rejected");
704 assert!(err.to_string().contains("liek"), "{err}");
705
706 let err = serde_json::from_str::<Declarations>(r#"{"enviroments": {}}"#)
707 .expect_err("unknown section is rejected");
708 assert!(err.to_string().contains("enviroments"), "{err}");
709
710 let err =
711 serde_json::from_str::<Declarations>(r#"{"commands": {"eqrefs": {"liek": "cref"}}}"#)
712 .expect_err("unknown command field is rejected");
713 assert!(err.to_string().contains("liek"), "{err}");
714 }
715
716 #[test]
717 fn wire_spellings_are_pinned() {
718 let decls = from_json(
719 r#"{"commands": {"eqrefs": {"like": "cref"}},
720 "environments": {"myenv": {"like": "align", "begin": ["\\b"], "end": ["\\e"]}}}"#,
721 );
722 let json = serde_json::to_value(&decls).expect("serializes");
723 assert_eq!(json["commands"]["eqrefs"]["like"], "cref");
724 let entry = &json["environments"]["myenv"];
725 assert_eq!(entry["like"], "align");
726 assert_eq!(entry["begin"][0], "b");
727 assert_eq!(entry["end"][0], "e");
728 }
729
730 #[test]
731 fn environments_iterate_in_name_order() {
732 let decls = from_json(r#"{"environments": {"zed": {}, "alpha": {}, "mid": {}}}"#);
733 let names: Vec<&str> = decls.environments.keys().map(SmolStr::as_str).collect();
734 assert_eq!(names, ["alpha", "mid", "zed"]);
735 }
736
737 fn resolve(json: &str) -> SignatureDb {
738 from_json(json).resolve().expect("resolves").as_db().clone()
739 }
740
741 fn resolve_err(json: &str) -> DeclarationError {
742 from_json(json).resolve().expect_err("is rejected")
743 }
744
745 #[test]
746 fn nothing_declared_resolves_to_nothing() {
747 assert!(from_json("{}").resolve().expect("resolves").is_empty());
748 }
749
750 #[test]
751 fn command_families_resolve_with_target_behavior() {
752 let declared = from_json(
753 r#"{"commands": {
754 "one": {"like": "eqref"},
755 "many": {"like": "cref"},
756 "sources": {"like": "parencite"},
757 "everything": {"like": "nocite"}
758 }}"#,
759 )
760 .resolve()
761 .expect("resolves");
762
763 assert_eq!(declared.command_like("one"), Some("eqref"));
764 assert_eq!(declared.command_like("many"), Some("cref"));
765 assert_eq!(declared.command_like("sources"), Some("parencite"));
766 assert_eq!(declared.command_like("everything"), Some("nocite"));
767 assert!(
768 declared.as_db().command("many").is_none(),
769 "semantic aliases must not become formatter/parser signatures"
770 );
771 assert_eq!(
772 declared.command_names().collect::<Vec<_>>(),
773 vec!["everything", "many", "one", "sources"]
774 );
775 }
776
777 #[test]
778 fn like_may_name_a_family_command_absent_from_the_signature_database() {
779 for target in ["cpageref", "supercite", "Textcite", "fnotecite"] {
780 assert!(
781 builtin().command(target).is_none(),
782 "{target} is in signatures.json; pick another for this test"
783 );
784 let json = format!(r#"{{"commands": {{"wrapper": {{"like": "{target}"}}}}}}"#);
785 let declared = from_json(&json).resolve().expect("resolves");
786 assert_eq!(declared.command_like("wrapper"), Some(target));
787 }
788 }
789
790 #[test]
791 fn a_family_command_absent_from_the_signature_database_may_not_be_redeclared() {
792 for name in ["cpageref", "supercite", "Textcite", "fnotecite"] {
793 let json = format!(r#"{{"commands": {{"{name}": {{"like": "ref"}}}}}}"#);
794 let err = resolve_err(&json);
795 assert_eq!(err.key, format!("commands.{name}"), "{err}");
796 assert!(matches!(
797 err.kind,
798 DeclarationErrorKind::BuiltinCommandName { .. }
799 ));
800 }
801 }
802
803 #[test]
804 fn the_two_tiers_partition_the_block() {
805 let declared = from_json(
806 r#"{
807 "commands": {"myref": {"like": "cref"}},
808 "environments": {"mycode": {"like": "lstlisting"}}
809 }"#,
810 )
811 .resolve()
812 .expect("resolves");
813
814 let parse = declared.parse_tier();
815 assert_eq!(parse.as_db(), declared.as_db());
816 assert_eq!(parse.command_names().count(), 0);
817
818 let semantic = declared.semantic_tier();
819 assert_eq!(semantic.as_db(), &SignatureDb::default());
820 assert_eq!(semantic.command_like("myref"), Some("cref"));
821
822 assert!(!parse.is_empty() && !semantic.is_empty());
823 let recommanded = from_json(
824 r#"{
825 "commands": {"myref": {"like": "eqref"}},
826 "environments": {"mycode": {"like": "lstlisting"}}
827 }"#,
828 )
829 .resolve()
830 .expect("resolves");
831 assert_ne!(recommanded, declared);
832 assert_eq!(recommanded.parse_tier(), parse);
833 assert_ne!(recommanded.semantic_tier(), semantic);
834 }
835
836 #[test]
837 fn invalid_command_declarations_are_rejected() {
838 for (json, key) in [
839 (r#"{"commands": {"empty": {}}}"#, "commands.empty"),
840 (
841 r#"{"commands": {"wrapper": {"like": "emph"}}}"#,
842 "commands.wrapper.like",
843 ),
844 (
845 r#"{"commands": {"bad-name": {"like": "ref"}}}"#,
846 "commands.bad-name",
847 ),
848 (
849 r#"{"commands": {"section": {"like": "ref"}}}"#,
850 "commands.section",
851 ),
852 ] {
853 let err = resolve_err(json);
854 assert_eq!(err.key, key, "{err}");
855 }
856 }
857
858 #[test]
859 fn command_declarations_do_not_change_the_parse_tree() {
860 use crate::parser::{LatexFlavor, parse_with_declarations, parse_with_flavor};
861
862 let src = "\\eqrefs{a,b}\n";
863 let declared = from_json(r#"{"commands": {"eqrefs": {"like": "cref"}}}"#)
864 .resolve()
865 .expect("resolves");
866 assert_eq!(
867 parse_with_declarations(src, LatexFlavor::Document, &declared).green,
868 parse_with_flavor(src, LatexFlavor::Document).green
869 );
870 }
871
872 #[test]
873 fn like_copies_the_builtin_entry() {
874 let db = resolve(r#"{"environments": {"myenv": {"like": "align"}}}"#);
875 let sig = db.environment("myenv").expect("declared");
876 assert_eq!(sig, builtin().environment("align").expect("builtin"));
877 assert!(sig.math && sig.align);
878 }
879
880 #[test]
881 fn like_may_name_a_verbatim_environment() {
882 let db = resolve(r#"{"environments": {"mycode": {"like": "lstlisting"}}}"#);
883 assert!(db.environment("mycode").expect("declared").verbatim_body);
884 }
885
886 #[test]
887 fn like_may_name_an_argument_taking_environment() {
888 let db = resolve(r#"{"environments": {"mytab": {"like": "tabular"}}}"#);
889 assert!(!db.environment("mytab").expect("declared").args.is_empty());
890 }
891
892 #[test]
893 fn a_mistyped_like_target_is_an_error_not_a_silent_no_op() {
894 let err = resolve_err(r#"{"environments": {"myenv": {"like": "algin"}}}"#);
895 assert_eq!(err.key, "environments.myenv.like");
896 assert!(matches!(
897 err.kind,
898 DeclarationErrorKind::UnknownLikeTarget { .. }
899 ));
900 assert!(err.to_string().contains("algin"), "{err}");
901 }
902
903 #[test]
904 fn like_does_not_resolve_against_the_cwl_tier() {
905 let cwl_only = crate::semantic::signature::cwl()
906 .environment_names()
907 .find(|name| builtin().environment(name).is_none())
908 .expect("the CWL tier has an environment the curated one does not")
909 .to_string();
910 let err = resolve_err(&format!(
911 r#"{{"environments": {{"myenv": {{"like": "{cwl_only}"}}}}}}"#
912 ));
913 assert!(matches!(
914 err.kind,
915 DeclarationErrorKind::UnknownLikeTarget { .. }
916 ));
917 }
918
919 #[test]
920 fn delimiters_for_a_builtin_environment_need_no_like() {
921 let db =
922 resolve(r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#);
923 assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
924 assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
925 assert!(db.environment("eqnarray").is_none());
926 }
927
928 #[test]
929 fn delimiters_and_like_resolve_together() {
930 let db = resolve(
931 r#"{"environments": {"mytheorem": {
932 "like": "theorem",
933 "begin": ["\\startmyenv"],
934 "end": ["\\endmyenv"]
935 }}}"#,
936 );
937 assert_eq!(db.env_begin_alias("startmyenv"), Some("mytheorem"));
938 assert!(db.environment("mytheorem").is_some());
939 }
940
941 #[test]
942 fn an_environment_may_have_several_spellings_per_side() {
943 let db = resolve(
944 r#"{"environments": {"eqnarray": {
945 "begin": ["\\bea", "\\beqa"], "end": ["\\eea"]
946 }}}"#,
947 );
948 assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
949 assert_eq!(db.env_begin_alias("beqa"), Some("eqnarray"));
950 }
951
952 #[test]
953 fn one_side_alone_resolves() {
954 let db = resolve(r#"{"environments": {"eqnarray": {"begin": ["\\bea"]}}}"#);
955 assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
956 assert_eq!(db.env_end_alias("bea"), None);
957
958 let db = resolve(r#"{"environments": {"eqnarray": {"end": ["\\eea"]}}}"#);
959 assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
960 assert_eq!(db.env_begin_alias("eea"), None);
961 }
962
963 #[test]
964 fn one_side_alone_still_obeys_every_target_rule() {
965 for json in [
966 r#"{"environments": {"verbatim": {"begin": ["\\bv"]}}}"#,
967 r#"{"environments": {"verbatim": {"end": ["\\ev"]}}}"#,
968 ] {
969 assert_eq!(resolve_err(json).kind, DeclarationErrorKind::VerbatimTarget);
970 }
971 assert_eq!(
972 resolve_err(r#"{"environments": {"tabular": {"begin": ["\\bt"]}}}"#).kind,
973 DeclarationErrorKind::TargetTakesArguments
974 );
975 assert_eq!(
976 resolve_err(r#"{"environments": {"myenv": {"end": ["\\e"]}}}"#).kind,
977 DeclarationErrorKind::UndeclaredTarget
978 );
979 }
980
981 #[test]
982 fn delimiters_for_a_verbatim_environment_are_rejected() {
983 let err =
984 resolve_err(r#"{"environments": {"verbatim": {"begin": ["\\bv"], "end": ["\\ev"]}}}"#);
985 assert_eq!(err.kind, DeclarationErrorKind::VerbatimTarget);
986
987 let err = resolve_err(
988 r#"{"environments": {"mycode": {
989 "like": "lstlisting", "begin": ["\\bc"], "end": ["\\ec"]
990 }}}"#,
991 );
992 assert_eq!(err.kind, DeclarationErrorKind::VerbatimTarget);
993 }
994
995 #[test]
996 fn delimiters_for_an_argument_taking_environment_are_rejected() {
997 let err =
998 resolve_err(r#"{"environments": {"tabular": {"begin": ["\\bt"], "end": ["\\et"]}}}"#);
999 assert_eq!(err.kind, DeclarationErrorKind::TargetTakesArguments);
1000 }
1001
1002 #[test]
1003 fn delimiters_for_an_unknown_environment_ask_for_like() {
1004 let err = resolve_err(r#"{"environments": {"myenv": {"begin": ["\\b"], "end": ["\\e"]}}}"#);
1005 assert_eq!(err.kind, DeclarationErrorKind::UndeclaredTarget);
1006 assert!(err.to_string().contains("like"), "{err}");
1007 }
1008
1009 #[test]
1010 fn an_entry_that_declares_nothing_is_an_error() {
1011 let err = resolve_err(r#"{"environments": {"myenv": {}}}"#);
1012 assert_eq!(err.key, "environments.myenv");
1013 assert_eq!(err.kind, DeclarationErrorKind::EmptyEntry);
1014 assert!(err.to_string().contains("like"), "{err}");
1015 }
1016
1017 #[test]
1018 fn a_spelling_that_is_already_a_builtin_command_is_rejected() {
1019 let err =
1020 resolve_err(r#"{"environments": {"center": {"begin": ["\\emph"], "end": ["\\ec"]}}}"#);
1021 assert_eq!(err.key, "environments.center.begin");
1022 assert!(matches!(
1023 err.kind,
1024 DeclarationErrorKind::SpellingIsABuiltinCommand { .. }
1025 ));
1026 assert!(err.to_string().contains("emph"), "{err}");
1027 }
1028
1029 #[test]
1030 fn a_cwl_only_command_name_is_still_available_as_a_spelling() {
1031 let cwl_only = crate::semantic::signature::cwl()
1032 .command_names()
1033 .find(|name| {
1034 builtin().command(name).is_none() && is_control_word_name(name) && name.len() > 2
1035 })
1036 .expect("the CWL tier has a command the curated one does not")
1037 .to_string();
1038 let db = resolve(&format!(
1039 r#"{{"environments": {{"center": {{"begin": ["{cwl_only}"], "end": ["\\ec"]}}}}}}"#
1040 ));
1041 assert_eq!(db.env_begin_alias(&cwl_only), Some("center"));
1042 }
1043
1044 #[test]
1045 fn the_error_key_quotes_a_name_that_is_not_a_bare_key() {
1046 let err = resolve_err(r#"{"environments": {"my.env": {}}}"#);
1047 assert_eq!(err.key, r#"environments."my.env""#);
1048 let err = resolve_err(r#"{"environments": {"my env": {"like": "algin"}}}"#);
1049 assert_eq!(err.key, r#"environments."my env".like"#);
1050 }
1051
1052 #[test]
1053 fn a_spelling_may_not_be_claimed_twice() {
1054 let err = resolve_err(
1055 r#"{"environments": {
1056 "align": {"begin": ["\\bx"], "end": ["\\ex"]},
1057 "equation": {"begin": ["\\bx"], "end": ["\\ey"]}
1058 }}"#,
1059 );
1060 assert_eq!(
1061 err.kind,
1062 DeclarationErrorKind::DuplicateDelimiter {
1063 name: CommandName::new("bx"),
1064 first: SmolStr::new("align"),
1065 }
1066 );
1067 }
1068
1069 #[test]
1070 fn a_spelling_may_not_be_both_opener_and_closer() {
1071 let err = resolve_err(r#"{"environments": {"align": {"begin": ["\\x"], "end": ["\\x"]}}}"#);
1072 assert_eq!(err.key, "environments.align.end");
1073 assert_eq!(
1074 err.kind,
1075 DeclarationErrorKind::RepeatedDelimiter {
1076 name: CommandName::new("x"),
1077 }
1078 );
1079 }
1080
1081 #[test]
1082 fn the_written_out_delimiter_is_rejected_with_its_own_advice() {
1083 let err = resolve_err(
1084 r#"{"environments": {"split": {"begin": ["\\bsplit"], "end": ["\\end{split}"]}}}"#,
1085 );
1086 assert_eq!(err.key, "environments.split.end");
1087 assert!(
1088 matches!(
1089 err.kind,
1090 DeclarationErrorKind::SpellingIsALiteralDelimiter { .. }
1091 ),
1092 "{err:?}"
1093 );
1094 let rendered = err.to_string();
1095 assert!(rendered.contains("\\end{split}"), "{rendered}");
1096 assert!(rendered.contains("removed"), "{rendered}");
1097
1098 for json in [
1099 r#"{"environments": {"split": {"begin": ["\\begin{split}"]}}}"#,
1100 r#"{"environments": {"split": {"end": ["\\end{myenv}"]}}}"#,
1101 ] {
1102 assert!(
1103 matches!(
1104 resolve_err(json).kind,
1105 DeclarationErrorKind::SpellingIsALiteralDelimiter { .. }
1106 ),
1107 "{json}"
1108 );
1109 }
1110
1111 let db = resolve(r#"{"environments": {"center": {"begin": ["\\beginning"]}}}"#);
1112 assert_eq!(db.env_begin_alias("beginning"), Some("center"));
1113 }
1114
1115 #[test]
1116 fn a_spelling_that_could_never_lex_as_one_control_word_is_rejected() {
1117 for bad in ["b ea", "bea2", "", "b-ea"] {
1118 let json = format!(
1119 r#"{{"environments": {{"align": {{"begin": ["{bad}"], "end": ["\\ex"]}}}}}}"#
1120 );
1121 let err = resolve_err(&json);
1122 assert!(
1123 matches!(err.kind, DeclarationErrorKind::NotAControlWord { .. }),
1124 "`{bad}` should be rejected, got {err:?}"
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn a_spelling_may_use_letters_of_any_catcode_regime() {
1131 let db =
1132 resolve(r#"{"environments": {"align": {"begin": ["\\my@b"], "end": ["\\my_e:n"]}}}"#);
1133 assert_eq!(db.env_begin_alias("my@b"), Some("align"));
1134 assert_eq!(db.env_end_alias("my_e:n"), Some("align"));
1135 }
1136
1137 #[test]
1138 fn the_resolved_tier_merges_like_any_other() {
1139 let declared = resolve(
1140 r#"{"environments": {"myenv": {"like": "align"}, "eqnarray": {
1141 "begin": ["\\bea"], "end": ["\\eea"]
1142 }}}"#,
1143 );
1144 let mut scope = SignatureDb::default();
1145 scope.merge_from(&declared, None);
1146 assert!(scope.environment("myenv").is_some());
1147 assert_eq!(scope.env_begin_alias("bea"), Some("eqnarray"));
1148 }
1149
1150 fn environment_sig_at(src: &str, json: &str) -> Option<EnvironmentSig> {
1151 scope_and_sig_at(src, json).1
1152 }
1153
1154 fn scope_and_sig_at(src: &str, json: &str) -> (Option<EnvironmentSig>, Option<EnvironmentSig>) {
1155 use crate::parser::{LatexFlavor, parse_with_declarations};
1156 use crate::semantic::define::scan_definitions;
1157 use crate::semantic::signature::Signatures;
1158 use crate::syntax::{SyntaxKind, SyntaxNode};
1159
1160 let decls = from_json(json).resolve().expect("resolves");
1161 let parsed = parse_with_declarations(src, LatexFlavor::Document, &decls);
1162 let root = SyntaxNode::new_root(parsed.green);
1163 let mut scope = scan_definitions(&root);
1164 scope.merge_declarations(&decls);
1165 let node = root
1166 .descendants()
1167 .find(|n| n.kind() == SyntaxKind::ENVIRONMENT)
1168 .expect("an environment");
1169 let sigs = Signatures::new(&scope);
1170 (
1171 sigs.environment("eqnarray").cloned(),
1172 sigs.environment_at(&node).cloned(),
1173 )
1174 }
1175
1176 #[test]
1177 fn a_declared_alias_resolves_to_a_declared_target() {
1178 let sig = environment_sig_at(
1179 "\\startmyenv x \\endmyenv\n",
1180 r#"{"environments": {"myenv": {
1181 "like": "align", "begin": ["\\startmyenv"], "end": ["\\endmyenv"]
1182 }}}"#,
1183 )
1184 .expect("the alias resolves");
1185 assert_eq!(&sig, builtin().environment("align").expect("curated"));
1186 }
1187
1188 #[test]
1189 fn a_scanned_definition_still_lends_an_alias_nothing() {
1190 let (scanned, sig) = scope_and_sig_at(
1191 "\\newenvironment{eqnarray}{}{}\n\\bea x \\eea\n",
1192 r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#,
1193 );
1194 let sig = sig.expect("the alias resolves");
1195 assert_eq!(&sig, builtin().environment("eqnarray").expect("curated"));
1196 let scanned = scanned.expect("the scan records the redefinition");
1197 assert!(!scanned.math, "the scanned redefinition is not math");
1198 assert!(sig.math, "the alias resolves to the curated entry");
1199 }
1200}