use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use smol_str::SmolStr;
use crate::parser::lexer::is_control_word_name;
use crate::semantic::signature::{EnvironmentSig, SignatureDb, builtin};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
#[serde(transparent)]
pub struct CommandName(SmolStr);
impl CommandName {
pub fn new(name: &str) -> Self {
Self(SmolStr::new(name.strip_prefix('\\').unwrap_or(name)))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for CommandName {
fn from(name: &str) -> Self {
Self::new(name)
}
}
impl fmt::Display for CommandName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "\\{}", self.0)
}
}
impl<'de> Deserialize<'de> for CommandName {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Ok(Self::new(&raw))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct EnvironmentDecl {
pub like: Option<SmolStr>,
pub begin: Vec<CommandName>,
pub end: Vec<CommandName>,
}
impl EnvironmentDecl {
pub fn has_delimiters(&self) -> bool {
!self.begin.is_empty() || !self.end.is_empty()
}
}
pub type EnvironmentDecls = BTreeMap<SmolStr, EnvironmentDecl>;
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "kebab-case")]
pub struct Declarations {
pub environments: EnvironmentDecls,
}
impl Declarations {
pub fn is_empty(&self) -> bool {
self.environments.is_empty()
}
pub fn resolve(&self) -> Result<ResolvedDeclarations, DeclarationError> {
let mut db = SignatureDb::default();
let mut claimed: BTreeMap<SmolStr, SmolStr> = BTreeMap::new();
for (name, entry) in &self.environments {
let error = |kind| DeclarationError {
key: dotted_key(["environments", name]),
kind,
};
if entry.like.is_none() && !entry.has_delimiters() {
return Err(error(DeclarationErrorKind::EmptyEntry));
}
let declared = match &entry.like {
Some(target) => {
let sig = builtin()
.environment(target)
.ok_or_else(|| DeclarationError {
key: dotted_key(["environments", name, "like"]),
kind: DeclarationErrorKind::UnknownLikeTarget {
target: target.clone(),
},
})?;
db.insert_declared_environment(name.clone(), sig.clone());
Some(sig)
}
None => None,
};
if !entry.has_delimiters() {
continue;
}
if entry.end.is_empty() {
return Err(error(DeclarationErrorKind::MissingCloser));
}
if entry.begin.is_empty() {
return Err(error(DeclarationErrorKind::MissingOpener));
}
let sig: &EnvironmentSig = declared
.or_else(|| builtin().environment(name))
.ok_or_else(|| error(DeclarationErrorKind::UndeclaredTarget))?;
if sig.verbatim_body {
return Err(error(DeclarationErrorKind::VerbatimTarget));
}
if !sig.args.is_empty() {
return Err(error(DeclarationErrorKind::TargetTakesArguments));
}
for (side, spellings) in [("begin", &entry.begin), ("end", &entry.end)] {
let error = |kind| DeclarationError {
key: dotted_key(["environments", name, side]),
kind,
};
for spelling in spellings {
if !is_control_word_name(spelling.as_str()) {
return Err(error(DeclarationErrorKind::NotAControlWord {
name: spelling.clone(),
}));
}
if builtin().command(spelling.as_str()).is_some() {
return Err(error(DeclarationErrorKind::SpellingIsABuiltinCommand {
name: spelling.clone(),
}));
}
let key = SmolStr::new(spelling.as_str());
if let Some(first) = claimed.get(&key) {
return Err(error(if first == name {
DeclarationErrorKind::RepeatedDelimiter {
name: spelling.clone(),
}
} else {
DeclarationErrorKind::DuplicateDelimiter {
name: spelling.clone(),
first: first.clone(),
}
}));
}
claimed.insert(key.clone(), name.clone());
if side == "begin" {
db.insert_env_begin_alias(key, name.clone());
} else {
db.insert_env_end_alias(key, name.clone());
}
}
}
}
Ok(ResolvedDeclarations(db))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ResolvedDeclarations(SignatureDb);
impl ResolvedDeclarations {
pub fn as_db(&self) -> &SignatureDb {
&self.0
}
pub fn is_empty(&self) -> bool {
self.0 == SignatureDb::default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclarationError {
pub key: String,
pub kind: DeclarationErrorKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DeclarationErrorKind {
EmptyEntry,
UnknownLikeTarget { target: SmolStr },
MissingCloser,
MissingOpener,
VerbatimTarget,
TargetTakesArguments,
UndeclaredTarget,
DuplicateDelimiter { name: CommandName, first: SmolStr },
RepeatedDelimiter { name: CommandName },
NotAControlWord { name: CommandName },
SpellingIsABuiltinCommand { name: CommandName },
}
fn dotted_key<'a>(segments: impl IntoIterator<Item = &'a str>) -> String {
let mut key = String::new();
for segment in segments {
if !key.is_empty() {
key.push('.');
}
let bare = !segment.is_empty()
&& segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if bare {
key.push_str(segment);
} else {
key.push('"');
key.push_str(&segment.replace('\\', "\\\\").replace('"', "\\\""));
key.push('"');
}
}
key
}
impl fmt::Display for DeclarationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "`{}`: {}", self.key, self.kind)
}
}
impl fmt::Display for DeclarationErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyEntry => write!(
f,
"declares nothing; add `like` to say what the environment behaves like, or \
`begin`/`end` to give it delimiter spellings"
),
Self::UnknownLikeTarget { target } => write!(
f,
"unknown environment `{target}`; `like` must name an environment badness \
knows about"
),
Self::MissingCloser => write!(
f,
"declares `begin` but no `end`; an opener that cannot be closed never pairs"
),
Self::MissingOpener => write!(
f,
"declares `end` but no `begin`; a closer with nothing to close never pairs"
),
Self::VerbatimTarget => write!(
f,
"a command cannot stand in for a verbatim environment's delimiters, because \
TeX never expands the closer — the verbatim scanner has already swallowed \
it. Declare the environment name on its own, without `begin`/`end`"
),
Self::TargetTakesArguments => write!(
f,
"the environment takes arguments, which a delimiter command cannot carry; \
declare the environment name on its own, without `begin`/`end`"
),
Self::UndeclaredTarget => write!(
f,
"declares delimiters for an environment badness does not know; add `like` \
to say what it behaves like"
),
Self::DuplicateDelimiter { name, first } => write!(
f,
"`{name}` is already declared as a delimiter of `{first}`"
),
Self::RepeatedDelimiter { name } => {
write!(
f,
"`{name}` is listed twice as a delimiter of this environment"
)
}
Self::NotAControlWord { name } => write!(
f,
"`{name}` is not a control word; a delimiter must be a name of letters"
),
Self::SpellingIsABuiltinCommand { name } => write!(
f,
"`{name}` is already a LaTeX command badness knows; a delimiter spelling must \
be a command of your own, or the declaration would change what `{name}` means \
everywhere in the project"
),
}
}
}
impl std::error::Error for DeclarationError {}
#[cfg(test)]
mod tests {
use super::*;
fn from_json(json: &str) -> Declarations {
serde_json::from_str(json).expect("deserializes")
}
#[test]
fn empty_declarations_are_the_default() {
assert!(Declarations::default().is_empty());
assert!(from_json("{}").is_empty());
}
#[test]
fn an_entry_may_declare_behavior_alone() {
let decls = from_json(r#"{"environments": {"myenv": {"like": "align"}}}"#);
let entry = &decls.environments["myenv"];
assert_eq!(entry.like.as_deref(), Some("align"));
assert!(!entry.has_delimiters());
assert!(!decls.is_empty());
}
#[test]
fn an_entry_may_declare_delimiters_alone() {
let decls =
from_json(r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#);
let entry = &decls.environments["eqnarray"];
assert_eq!(entry.like, None);
assert!(entry.has_delimiters());
assert_eq!(entry.begin, vec![CommandName::new("bea")]);
assert_eq!(entry.end, vec![CommandName::new("eea")]);
}
#[test]
fn an_entry_may_declare_both() {
let decls = from_json(
r#"{"environments": {"mytheorem": {
"like": "theorem",
"begin": ["\\startmyenv"],
"end": ["\\endmyenv"]
}}}"#,
);
let entry = &decls.environments["mytheorem"];
assert_eq!(entry.like.as_deref(), Some("theorem"));
assert_eq!(entry.begin, vec![CommandName::new("startmyenv")]);
}
#[test]
fn a_leading_backslash_is_optional_and_normalized_away() {
assert_eq!(CommandName::new("\\bea"), CommandName::new("bea"));
assert_eq!(CommandName::new("\\bea").as_str(), "bea");
let decls = from_json(r#"{"environments": {"e": {"begin": ["bea", "\\bea"]}}}"#);
assert_eq!(
decls.environments["e"].begin,
vec![CommandName::new("bea"), CommandName::new("bea")]
);
}
#[test]
fn only_one_backslash_is_stripped() {
assert_eq!(CommandName::new("\\\\").as_str(), "\\");
}
#[test]
fn display_restores_the_backslash() {
assert_eq!(CommandName::new("bea").to_string(), "\\bea");
}
#[test]
fn a_misspelled_key_is_rejected_rather_than_ignored() {
let err = serde_json::from_str::<Declarations>(
r#"{"environments": {"myenv": {"liek": "align"}}}"#,
)
.expect_err("unknown field is rejected");
assert!(err.to_string().contains("liek"), "{err}");
let err = serde_json::from_str::<Declarations>(r#"{"enviroments": {}}"#)
.expect_err("unknown section is rejected");
assert!(err.to_string().contains("enviroments"), "{err}");
}
#[test]
fn wire_spellings_are_pinned() {
let decls = from_json(
r#"{"environments": {"myenv": {"like": "align", "begin": ["\\b"], "end": ["\\e"]}}}"#,
);
let json = serde_json::to_value(&decls).expect("serializes");
let entry = &json["environments"]["myenv"];
assert_eq!(entry["like"], "align");
assert_eq!(entry["begin"][0], "b");
assert_eq!(entry["end"][0], "e");
}
#[test]
fn environments_iterate_in_name_order() {
let decls = from_json(r#"{"environments": {"zed": {}, "alpha": {}, "mid": {}}}"#);
let names: Vec<&str> = decls.environments.keys().map(SmolStr::as_str).collect();
assert_eq!(names, ["alpha", "mid", "zed"]);
}
fn resolve(json: &str) -> SignatureDb {
from_json(json).resolve().expect("resolves").as_db().clone()
}
fn resolve_err(json: &str) -> DeclarationError {
from_json(json).resolve().expect_err("is rejected")
}
#[test]
fn nothing_declared_resolves_to_nothing() {
assert!(from_json("{}").resolve().expect("resolves").is_empty());
}
#[test]
fn like_copies_the_builtin_entry() {
let db = resolve(r#"{"environments": {"myenv": {"like": "align"}}}"#);
let sig = db.environment("myenv").expect("declared");
assert_eq!(sig, builtin().environment("align").expect("builtin"));
assert!(sig.math && sig.align);
}
#[test]
fn like_may_name_a_verbatim_environment() {
let db = resolve(r#"{"environments": {"mycode": {"like": "lstlisting"}}}"#);
assert!(db.environment("mycode").expect("declared").verbatim_body);
}
#[test]
fn like_may_name_an_argument_taking_environment() {
let db = resolve(r#"{"environments": {"mytab": {"like": "tabular"}}}"#);
assert!(!db.environment("mytab").expect("declared").args.is_empty());
}
#[test]
fn a_mistyped_like_target_is_an_error_not_a_silent_no_op() {
let err = resolve_err(r#"{"environments": {"myenv": {"like": "algin"}}}"#);
assert_eq!(err.key, "environments.myenv.like");
assert!(matches!(
err.kind,
DeclarationErrorKind::UnknownLikeTarget { .. }
));
assert!(err.to_string().contains("algin"), "{err}");
}
#[test]
fn like_does_not_resolve_against_the_cwl_tier() {
let cwl_only = crate::semantic::signature::cwl()
.environment_names()
.find(|name| builtin().environment(name).is_none())
.expect("the CWL tier has an environment the curated one does not")
.to_string();
let err = resolve_err(&format!(
r#"{{"environments": {{"myenv": {{"like": "{cwl_only}"}}}}}}"#
));
assert!(matches!(
err.kind,
DeclarationErrorKind::UnknownLikeTarget { .. }
));
}
#[test]
fn delimiters_for_a_builtin_environment_need_no_like() {
let db =
resolve(r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#);
assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
assert_eq!(db.env_end_alias("eea"), Some("eqnarray"));
assert!(db.environment("eqnarray").is_none());
}
#[test]
fn delimiters_and_like_resolve_together() {
let db = resolve(
r#"{"environments": {"mytheorem": {
"like": "theorem",
"begin": ["\\startmyenv"],
"end": ["\\endmyenv"]
}}}"#,
);
assert_eq!(db.env_begin_alias("startmyenv"), Some("mytheorem"));
assert!(db.environment("mytheorem").is_some());
}
#[test]
fn an_environment_may_have_several_spellings_per_side() {
let db = resolve(
r#"{"environments": {"eqnarray": {
"begin": ["\\bea", "\\beqa"], "end": ["\\eea"]
}}}"#,
);
assert_eq!(db.env_begin_alias("bea"), Some("eqnarray"));
assert_eq!(db.env_begin_alias("beqa"), Some("eqnarray"));
}
#[test]
fn an_opener_without_a_closer_is_an_error() {
let err = resolve_err(r#"{"environments": {"eqnarray": {"begin": ["\\bea"]}}}"#);
assert_eq!(err.key, "environments.eqnarray");
assert_eq!(err.kind, DeclarationErrorKind::MissingCloser);
}
#[test]
fn a_closer_without_an_opener_is_an_error() {
let err = resolve_err(r#"{"environments": {"eqnarray": {"end": ["\\eea"]}}}"#);
assert_eq!(err.kind, DeclarationErrorKind::MissingOpener);
}
#[test]
fn delimiters_for_a_verbatim_environment_are_rejected() {
let err =
resolve_err(r#"{"environments": {"verbatim": {"begin": ["\\bv"], "end": ["\\ev"]}}}"#);
assert_eq!(err.kind, DeclarationErrorKind::VerbatimTarget);
let err = resolve_err(
r#"{"environments": {"mycode": {
"like": "lstlisting", "begin": ["\\bc"], "end": ["\\ec"]
}}}"#,
);
assert_eq!(err.kind, DeclarationErrorKind::VerbatimTarget);
}
#[test]
fn delimiters_for_an_argument_taking_environment_are_rejected() {
let err =
resolve_err(r#"{"environments": {"tabular": {"begin": ["\\bt"], "end": ["\\et"]}}}"#);
assert_eq!(err.kind, DeclarationErrorKind::TargetTakesArguments);
}
#[test]
fn delimiters_for_an_unknown_environment_ask_for_like() {
let err = resolve_err(r#"{"environments": {"myenv": {"begin": ["\\b"], "end": ["\\e"]}}}"#);
assert_eq!(err.kind, DeclarationErrorKind::UndeclaredTarget);
assert!(err.to_string().contains("like"), "{err}");
}
#[test]
fn an_entry_that_declares_nothing_is_an_error() {
let err = resolve_err(r#"{"environments": {"myenv": {}}}"#);
assert_eq!(err.key, "environments.myenv");
assert_eq!(err.kind, DeclarationErrorKind::EmptyEntry);
assert!(err.to_string().contains("like"), "{err}");
}
#[test]
fn a_spelling_that_is_already_a_builtin_command_is_rejected() {
let err =
resolve_err(r#"{"environments": {"center": {"begin": ["\\emph"], "end": ["\\ec"]}}}"#);
assert_eq!(err.key, "environments.center.begin");
assert!(matches!(
err.kind,
DeclarationErrorKind::SpellingIsABuiltinCommand { .. }
));
assert!(err.to_string().contains("emph"), "{err}");
}
#[test]
fn a_cwl_only_command_name_is_still_available_as_a_spelling() {
let cwl_only = crate::semantic::signature::cwl()
.command_names()
.find(|name| {
builtin().command(name).is_none() && is_control_word_name(name) && name.len() > 2
})
.expect("the CWL tier has a command the curated one does not")
.to_string();
let db = resolve(&format!(
r#"{{"environments": {{"center": {{"begin": ["{cwl_only}"], "end": ["\\ec"]}}}}}}"#
));
assert_eq!(db.env_begin_alias(&cwl_only), Some("center"));
}
#[test]
fn the_error_key_quotes_a_name_that_is_not_a_bare_key() {
let err = resolve_err(r#"{"environments": {"my.env": {}}}"#);
assert_eq!(err.key, r#"environments."my.env""#);
let err = resolve_err(r#"{"environments": {"my env": {"like": "algin"}}}"#);
assert_eq!(err.key, r#"environments."my env".like"#);
}
#[test]
fn a_spelling_may_not_be_claimed_twice() {
let err = resolve_err(
r#"{"environments": {
"align": {"begin": ["\\bx"], "end": ["\\ex"]},
"equation": {"begin": ["\\bx"], "end": ["\\ey"]}
}}"#,
);
assert_eq!(
err.kind,
DeclarationErrorKind::DuplicateDelimiter {
name: CommandName::new("bx"),
first: SmolStr::new("align"),
}
);
}
#[test]
fn a_spelling_may_not_be_both_opener_and_closer() {
let err = resolve_err(r#"{"environments": {"align": {"begin": ["\\x"], "end": ["\\x"]}}}"#);
assert_eq!(err.key, "environments.align.end");
assert_eq!(
err.kind,
DeclarationErrorKind::RepeatedDelimiter {
name: CommandName::new("x"),
}
);
}
#[test]
fn a_spelling_that_could_never_lex_as_one_control_word_is_rejected() {
for bad in ["b ea", "bea2", "", "b-ea"] {
let json = format!(
r#"{{"environments": {{"align": {{"begin": ["{bad}"], "end": ["\\ex"]}}}}}}"#
);
let err = resolve_err(&json);
assert!(
matches!(err.kind, DeclarationErrorKind::NotAControlWord { .. }),
"`{bad}` should be rejected, got {err:?}"
);
}
}
#[test]
fn a_spelling_may_use_letters_of_any_catcode_regime() {
let db =
resolve(r#"{"environments": {"align": {"begin": ["\\my@b"], "end": ["\\my_e:n"]}}}"#);
assert_eq!(db.env_begin_alias("my@b"), Some("align"));
assert_eq!(db.env_end_alias("my_e:n"), Some("align"));
}
#[test]
fn the_resolved_tier_merges_like_any_other() {
let declared = resolve(
r#"{"environments": {"myenv": {"like": "align"}, "eqnarray": {
"begin": ["\\bea"], "end": ["\\eea"]
}}}"#,
);
let mut scope = SignatureDb::default();
scope.merge_from(&declared);
assert!(scope.environment("myenv").is_some());
assert_eq!(scope.env_begin_alias("bea"), Some("eqnarray"));
}
fn environment_sig_at(src: &str, json: &str) -> Option<EnvironmentSig> {
scope_and_sig_at(src, json).1
}
fn scope_and_sig_at(src: &str, json: &str) -> (Option<EnvironmentSig>, Option<EnvironmentSig>) {
use crate::parser::{LatexFlavor, parse_with_declarations};
use crate::semantic::define::scan_definitions;
use crate::semantic::signature::Signatures;
use crate::syntax::{SyntaxKind, SyntaxNode};
let decls = from_json(json).resolve().expect("resolves");
let parsed = parse_with_declarations(src, LatexFlavor::Document, &decls);
let root = SyntaxNode::new_root(parsed.green);
let mut scope = scan_definitions(&root);
scope.merge_declarations(&decls);
let node = root
.descendants()
.find(|n| n.kind() == SyntaxKind::ENVIRONMENT)
.expect("an environment");
let sigs = Signatures::new(&scope);
(
sigs.environment("eqnarray").cloned(),
sigs.environment_at(&node).cloned(),
)
}
#[test]
fn a_declared_alias_resolves_to_a_declared_target() {
let sig = environment_sig_at(
"\\startmyenv x \\endmyenv\n",
r#"{"environments": {"myenv": {
"like": "align", "begin": ["\\startmyenv"], "end": ["\\endmyenv"]
}}}"#,
)
.expect("the alias resolves");
assert_eq!(&sig, builtin().environment("align").expect("curated"));
}
#[test]
fn a_scanned_definition_still_lends_an_alias_nothing() {
let (scanned, sig) = scope_and_sig_at(
"\\newenvironment{eqnarray}{}{}\n\\bea x \\eea\n",
r#"{"environments": {"eqnarray": {"begin": ["\\bea"], "end": ["\\eea"]}}}"#,
);
let sig = sig.expect("the alias resolves");
assert_eq!(&sig, builtin().environment("eqnarray").expect("curated"));
let scanned = scanned.expect("the scan records the redefinition");
assert!(!scanned.math, "the scanned redefinition is not math");
assert!(sig.math, "the alias resolves to the curated entry");
}
}