use std::collections::HashSet;
use std::sync::{Arc, OnceLock};
use chumsky::prelude::*;
use logos::Logos;
use serde::Serialize;
pub use texform_argspec::ArgSpecParseError;
pub use texform_interface::syntax_node::ContentMode;
use texform_knowledge::builtin::PackageName;
pub use texform_knowledge::specs::{
ActiveCharacterRecord, ActiveCommandRecord, ActiveDelimiterRecord, ActiveEnvironmentRecord,
AllowedMode, CommandKind,
};
use crate::document::Document;
pub use crate::knowledge::KnowledgeBase;
pub use crate::knowledge::PackageLoadError;
use crate::knowledge::default_package_names;
use crate::lexer::Token;
use crate::parse::grammar::{self, TokenStream, TrackedNode, build_token_stream};
use crate::parse::{ParseConfig, ParserState};
type LexedSource = Vec<(Token, std::ops::Range<usize>)>;
const DIAGNOSTIC_KIND_CONTEXT_PREFIX: &str = "__texform_diagnostic_kind:";
const DIAGNOSTIC_KIND_MESSAGE_PREFIX: &str = "\x1etexform-kind:";
const DIAGNOSTIC_KIND_MESSAGE_SEPARATOR: char = '\x1e';
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "tsify", derive(tsify_next::Tsify))]
#[serde(rename_all = "kebab-case")]
pub enum ParseDiagnosticKind {
AmbiguousInfix,
ArgumentValidation,
CommandModeError,
CommentTruncatedArgument,
EnvironmentModeError,
EnvironmentNameMismatch,
LeftRightDelimiter,
MaxGroupDepthExceeded,
RawExpectedFound,
TextScriptError,
UnclosedInlineMath,
UnexpectedMathShift,
UnknownCommand,
UnknownEnvironment,
}
impl ParseDiagnosticKind {
pub const fn as_str(self) -> &'static str {
match self {
ParseDiagnosticKind::AmbiguousInfix => "ambiguous-infix",
ParseDiagnosticKind::ArgumentValidation => "argument-validation",
ParseDiagnosticKind::CommandModeError => "command-mode-error",
ParseDiagnosticKind::CommentTruncatedArgument => "comment-truncated-argument",
ParseDiagnosticKind::EnvironmentModeError => "environment-mode-error",
ParseDiagnosticKind::EnvironmentNameMismatch => "environment-name-mismatch",
ParseDiagnosticKind::LeftRightDelimiter => "left-right-delimiter",
ParseDiagnosticKind::MaxGroupDepthExceeded => "max-group-depth-exceeded",
ParseDiagnosticKind::RawExpectedFound => "raw-expected-found",
ParseDiagnosticKind::TextScriptError => "text-script-error",
ParseDiagnosticKind::UnclosedInlineMath => "unclosed-inline-math",
ParseDiagnosticKind::UnexpectedMathShift => "unexpected-math-shift",
ParseDiagnosticKind::UnknownCommand => "unknown-command",
ParseDiagnosticKind::UnknownEnvironment => "unknown-environment",
}
}
pub(crate) fn from_str(s: &str) -> Option<Self> {
match s {
"ambiguous-infix" => Some(Self::AmbiguousInfix),
"argument-validation" => Some(Self::ArgumentValidation),
"command-mode-error" => Some(Self::CommandModeError),
"comment-truncated-argument" => Some(Self::CommentTruncatedArgument),
"environment-mode-error" => Some(Self::EnvironmentModeError),
"environment-name-mismatch" => Some(Self::EnvironmentNameMismatch),
"left-right-delimiter" => Some(Self::LeftRightDelimiter),
"max-group-depth-exceeded" => Some(Self::MaxGroupDepthExceeded),
"raw-expected-found" => Some(Self::RawExpectedFound),
"text-script-error" => Some(Self::TextScriptError),
"unclosed-inline-math" => Some(Self::UnclosedInlineMath),
"unexpected-math-shift" => Some(Self::UnexpectedMathShift),
"unknown-command" => Some(Self::UnknownCommand),
"unknown-environment" => Some(Self::UnknownEnvironment),
_ => None,
}
}
pub(crate) fn context_label(self) -> String {
format!("{DIAGNOSTIC_KIND_CONTEXT_PREFIX}{}", self.as_str())
}
pub(crate) fn from_context_label(label: &str) -> Option<Self> {
Self::from_str(label.strip_prefix(DIAGNOSTIC_KIND_CONTEXT_PREFIX)?)
}
pub(crate) fn wrap_message(self, message: impl AsRef<str>) -> String {
format!(
"{DIAGNOSTIC_KIND_MESSAGE_PREFIX}{}{DIAGNOSTIC_KIND_MESSAGE_SEPARATOR}{}",
self.as_str(),
message.as_ref()
)
}
pub(crate) fn split_message(message: &str) -> (Option<Self>, &str) {
let Some(rest) = message.strip_prefix(DIAGNOSTIC_KIND_MESSAGE_PREFIX) else {
return (None, message);
};
let Some((kind, public_message)) = rest.split_once(DIAGNOSTIC_KIND_MESSAGE_SEPARATOR)
else {
return (None, message);
};
(Self::from_str(kind), public_message)
}
}
fn lex_source(src: &str) -> LexedSource {
Token::lexer(src)
.spanned()
.map(|(token, span)| {
let token = token.unwrap_or_else(|()| {
panic!("Lexer error at byte offset {}..{}", span.start, span.end)
});
(token, span)
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextItem {
Command(CommandItem),
Environment(EnvironmentItem),
DelimiterControl(DelimiterControlItem),
}
impl ContextItem {
pub fn name(&self) -> &str {
match self {
ContextItem::Command(item) => item.name.as_str(),
ContextItem::Environment(item) => item.name.as_str(),
ContextItem::DelimiterControl(item) => item.name.as_str(),
}
}
pub const fn target_tag(&self) -> &'static str {
match self {
ContextItem::Command(_) => "command",
ContextItem::Environment(_) => "environment",
ContextItem::DelimiterControl(_) => "delimiter control",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandItem {
pub name: String,
pub kind: CommandKind,
pub allowed_mode: AllowedMode,
pub spec: String,
pub tags: Vec<String>,
}
impl CommandItem {
pub fn new(
name: impl Into<String>,
kind: CommandKind,
allowed_mode: AllowedMode,
spec: impl Into<String>,
) -> Self {
Self {
name: name.into(),
kind,
allowed_mode,
spec: spec.into(),
tags: Vec::new(),
}
}
pub fn with_tags<I, T>(mut self, tags: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<String>,
{
self.tags = tags.into_iter().map(Into::into).collect();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvironmentItem {
pub name: String,
pub allowed_mode: AllowedMode,
pub body_mode: ContentMode,
pub spec: String,
pub tags: Vec<String>,
}
impl EnvironmentItem {
pub fn new(
name: impl Into<String>,
allowed_mode: AllowedMode,
body_mode: ContentMode,
spec: impl Into<String>,
) -> Self {
Self {
name: name.into(),
allowed_mode,
body_mode,
spec: spec.into(),
tags: Vec::new(),
}
}
pub fn with_tags<I, T>(mut self, tags: I) -> Self
where
I: IntoIterator<Item = T>,
T: Into<String>,
{
self.tags = tags.into_iter().map(Into::into).collect();
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DelimiterControlItem {
pub name: String,
}
impl DelimiterControlItem {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
}
impl From<CommandItem> for ContextItem {
fn from(item: CommandItem) -> Self {
ContextItem::Command(item)
}
}
impl From<EnvironmentItem> for ContextItem {
fn from(item: EnvironmentItem) -> Self {
ContextItem::Environment(item)
}
}
impl From<DelimiterControlItem> for ContextItem {
fn from(item: DelimiterControlItem) -> Self {
ContextItem::DelimiterControl(item)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MutationSummary {
pub touched_commands: HashSet<String>,
pub touched_environments: HashSet<String>,
}
enum BuilderOp {
Insert(ContextItem),
RemoveCommand(String),
RemoveEnvironment(String),
RemoveDelimiterControl(String),
}
fn record_insert(summary: &mut MutationSummary, item: &ContextItem) {
match item {
ContextItem::Command(command) => {
summary.touched_commands.insert(command.name.clone());
}
ContextItem::Environment(environment) => {
summary
.touched_environments
.insert(environment.name.clone());
}
ContextItem::DelimiterControl(_) => {}
}
}
#[derive(Debug)]
pub enum ParseContextBuildError {
PackageLoad(PackageLoadError),
InvalidContextItem {
name: String,
source: ArgSpecParseError,
},
}
enum KnowledgeBaseMode {
DefaultPackages,
Packages(Vec<String>),
Empty,
}
pub struct ParseContextBuilder {
mode: KnowledgeBaseMode,
ops: Vec<BuilderOp>,
}
impl ParseContextBuilder {
pub fn empty() -> Self {
Self {
mode: KnowledgeBaseMode::Empty,
ops: Vec::new(),
}
}
pub fn empty_knowledge(self) -> Self {
Self::empty()
}
pub fn packages(mut self, packages: &[&str]) -> Self {
self.mode =
KnowledgeBaseMode::Packages(packages.iter().map(|name| (*name).to_string()).collect());
self
}
pub fn insert_item(mut self, item: impl Into<ContextItem>) -> Self {
self.ops.push(BuilderOp::Insert(item.into()));
self
}
pub fn remove_command(mut self, name: impl Into<String>) -> Self {
self.ops.push(BuilderOp::RemoveCommand(name.into()));
self
}
pub fn remove_environment(mut self, name: impl Into<String>) -> Self {
self.ops.push(BuilderOp::RemoveEnvironment(name.into()));
self
}
pub fn remove_delimiter_control(mut self, name: impl Into<String>) -> Self {
self.ops
.push(BuilderOp::RemoveDelimiterControl(name.into()));
self
}
pub fn build(self) -> Result<ParseContext, ParseContextBuildError> {
let (mut math_kb, mut text_kb, enabled_packages) = match self.mode {
KnowledgeBaseMode::Empty => {
(KnowledgeBase::empty(), KnowledgeBase::empty(), Vec::new())
}
KnowledgeBaseMode::DefaultPackages => {
let refs = default_package_names().to_vec();
let enabled_packages = canonical_enabled_package_names(refs.as_slice())?;
let math_kb = KnowledgeBase::try_build_from_packages_for_mode(
refs.as_slice(),
ContentMode::Math,
)
.map_err(ParseContextBuildError::PackageLoad)?;
let text_kb = KnowledgeBase::try_build_from_packages_for_mode(
refs.as_slice(),
ContentMode::Text,
)
.map_err(ParseContextBuildError::PackageLoad)?;
(math_kb, text_kb, enabled_packages)
}
KnowledgeBaseMode::Packages(packages) => {
let refs = packages.iter().map(String::as_str).collect::<Vec<_>>();
let enabled_packages = canonical_enabled_package_names(refs.as_slice())?;
(
KnowledgeBase::try_build_from_packages_for_mode(
refs.as_slice(),
ContentMode::Math,
)
.map_err(ParseContextBuildError::PackageLoad)?,
KnowledgeBase::try_build_from_packages_for_mode(
refs.as_slice(),
ContentMode::Text,
)
.map_err(ParseContextBuildError::PackageLoad)?,
enabled_packages,
)
}
};
let mut mutation_summary = MutationSummary::default();
for op in self.ops {
match op {
BuilderOp::Insert(item) => {
record_insert(&mut mutation_summary, &item);
insert_item_into_lane(&mut math_kb, &item, ContentMode::Math).map_err(
|source| ParseContextBuildError::InvalidContextItem {
name: item.name().to_string(),
source,
},
)?;
insert_item_into_lane(&mut text_kb, &item, ContentMode::Text).map_err(
|source| ParseContextBuildError::InvalidContextItem {
name: item.name().to_string(),
source,
},
)?;
}
BuilderOp::RemoveCommand(name) => {
mutation_summary.touched_commands.insert(name.clone());
math_kb.remove_command_by_name(name.as_str());
text_kb.remove_command_by_name(name.as_str());
}
BuilderOp::RemoveEnvironment(name) => {
mutation_summary.touched_environments.insert(name.clone());
math_kb.remove_environment_by_name(name.as_str());
text_kb.remove_environment_by_name(name.as_str());
}
BuilderOp::RemoveDelimiterControl(name) => {
let item = DelimiterControlItem::new(name);
math_kb.remove_item(item.clone());
text_kb.remove_item(item);
}
}
}
Ok(ParseContext::from_parts(
math_kb,
text_kb,
mutation_summary,
enabled_packages,
))
}
}
fn canonical_enabled_package_names(
requested: &[&str],
) -> Result<Vec<PackageName>, ParseContextBuildError> {
let mut packages = Vec::new();
for package in texform_knowledge::builtin::MANAGED_PACKAGE_IMPORT_ORDER {
if requested.contains(&package.as_str()) {
packages.push(*package);
}
}
for requested_name in requested {
if PackageName::from_str(requested_name).is_none() {
return Err(ParseContextBuildError::PackageLoad(
PackageLoadError::UnknownPackage {
name: (*requested_name).to_string(),
},
));
}
}
Ok(packages)
}
fn insert_item_into_lane(
kb: &mut KnowledgeBase,
item: &ContextItem,
mode: ContentMode,
) -> Result<(), ArgSpecParseError> {
match item {
ContextItem::Command(command) => {
if command.allowed_mode.allows(mode) {
kb.insert_item(command.clone())?;
}
Ok(())
}
ContextItem::Environment(environment) => {
if environment.allowed_mode.allows(mode) {
kb.insert_item(environment.clone())?;
}
Ok(())
}
ContextItem::DelimiterControl(item) => kb.insert_item(item.clone()),
}
}
impl Default for ParseContextBuilder {
fn default() -> Self {
Self {
mode: KnowledgeBaseMode::DefaultPackages,
ops: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "tsify", derive(tsify_next::Tsify))]
pub struct Span {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "tsify", derive(tsify_next::Tsify))]
pub struct ParseDiagnosticContext {
pub label: String,
pub span: Span,
}
#[derive(Debug, Clone)]
pub struct ParseResult {
pub document: Option<Document>,
pub diagnostics: Vec<ParseDiagnostic>,
}
impl ParseResult {
pub fn document(&self) -> Option<&Document> {
self.document.as_ref()
}
pub fn diagnostics(&self) -> &[ParseDiagnostic] {
self.diagnostics.as_slice()
}
pub fn into_diagnostics(self) -> Vec<ParseDiagnostic> {
self.diagnostics
}
pub fn has_errors(&self) -> bool {
self.document.as_ref().is_some_and(Document::has_errors)
}
pub fn try_into_document(self) -> Result<(Document, Vec<ParseDiagnostic>), ParseError> {
match (self.document, self.diagnostics) {
(Some(document), diagnostics) if !document.has_errors() => Ok((document, diagnostics)),
(document, diagnostics) => Err(ParseError {
diagnostics,
document: document.map(Box::new),
}),
}
}
pub fn into_parts(self) -> (Option<Document>, Vec<ParseDiagnostic>) {
(self.document, self.diagnostics)
}
}
#[derive(Debug, Clone, Serialize)]
#[cfg_attr(feature = "tsify", derive(tsify_next::Tsify))]
#[non_exhaustive]
pub struct ParseDiagnostic {
pub kind: Option<ParseDiagnosticKind>,
pub message: String,
pub span: Span,
pub expected: Vec<String>,
pub found: Option<String>,
pub contexts: Vec<ParseDiagnosticContext>,
}
impl ParseDiagnostic {
pub fn new(
message: impl Into<String>,
span: Span,
expected: Vec<String>,
found: Option<String>,
contexts: Vec<ParseDiagnosticContext>,
) -> Self {
Self {
kind: None,
message: message.into(),
span,
expected,
found,
contexts,
}
}
}
#[derive(Debug, Clone)]
pub struct ParseError {
pub diagnostics: Vec<ParseDiagnostic>,
pub document: Option<Box<Document>>,
}
impl ParseError {
pub fn diagnostics(&self) -> &[ParseDiagnostic] {
self.diagnostics.as_slice()
}
pub fn document(&self) -> Option<&Document> {
self.document.as_deref()
}
pub fn into_diagnostics(self) -> Vec<ParseDiagnostic> {
self.diagnostics
}
pub fn into_parts(self) -> (Option<Document>, Vec<ParseDiagnostic>) {
(self.document.map(|document| *document), self.diagnostics)
}
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.document.is_some() {
f.write_str("parse produced an incomplete document")
} else {
f.write_str("parse produced no document")
}
}
}
impl std::error::Error for ParseError {}
#[derive(Clone)]
pub struct ParseContext {
math_kb: Arc<KnowledgeBase>,
text_kb: Arc<KnowledgeBase>,
mutation_summary: MutationSummary,
enabled_packages: Vec<PackageName>,
}
impl std::fmt::Debug for ParseContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ParseContext")
.field("math_kb", &self.math_kb)
.field("text_kb", &self.text_kb)
.field("enabled_packages", &self.enabled_packages)
.finish_non_exhaustive()
}
}
impl Default for ParseContext {
fn default() -> Self {
ParseContextBuilder::default()
.build()
.expect("default parse context should build")
}
}
impl ParseContext {
pub fn builder() -> ParseContextBuilder {
ParseContextBuilder::default()
}
pub(crate) fn from_parts(
math_kb: KnowledgeBase,
text_kb: KnowledgeBase,
mutation_summary: MutationSummary,
enabled_packages: Vec<PackageName>,
) -> Self {
ParseContext {
math_kb: Arc::new(math_kb),
text_kb: Arc::new(text_kb),
mutation_summary,
enabled_packages,
}
}
pub fn mutation_summary(&self) -> &MutationSummary {
&self.mutation_summary
}
pub fn enabled_packages(&self) -> &[PackageName] {
self.enabled_packages.as_slice()
}
pub fn has_enabled_package(&self, package: PackageName) -> bool {
self.enabled_packages.contains(&package)
}
pub fn empty() -> Self {
ParseContextBuilder::empty()
.build()
.expect("empty parse context should build")
}
pub fn from_packages(packages: &[&str]) -> Self {
ParseContextBuilder::empty()
.packages(packages)
.build()
.expect("package parse context should build")
}
pub fn try_from_packages(packages: &[&str]) -> Result<Self, PackageLoadError> {
ParseContextBuilder::empty()
.packages(packages)
.build()
.map_err(|error| match error {
ParseContextBuildError::PackageLoad(error) => error,
ParseContextBuildError::InvalidContextItem { .. } => {
panic!("try_from_packages should not hit invalid context item")
}
})
}
pub fn shared() -> &'static ParseContext {
shared_parser()
}
pub fn is_delimiter_control(&self, name: &str) -> bool {
self.math_kb.is_delimiter_control(name) || self.text_kb.is_delimiter_control(name)
}
pub fn lookup_delimiter_control(&self, name: &str) -> Option<&'static str> {
self.math_kb
.lookup_delimiter_control(name)
.or_else(|| self.text_kb.lookup_delimiter_control(name))
}
pub fn lookup_delimiter(
&self,
name: &str,
is_control_sequence: bool,
mode: ContentMode,
) -> Option<&ActiveDelimiterRecord> {
self.kb_for(mode)
.lookup_delimiter(name, is_control_sequence)
}
pub fn parse(&self, src: &str, config: &ParseConfig) -> ParseResult {
parse_with_context(self, src, config)
}
pub fn kb_for(&self, mode: ContentMode) -> &KnowledgeBase {
match mode {
ContentMode::Math => self.math_kb.as_ref(),
ContentMode::Text => self.text_kb.as_ref(),
}
}
pub fn math_kb(&self) -> &KnowledgeBase {
self.math_kb.as_ref()
}
pub fn text_kb(&self) -> &KnowledgeBase {
self.text_kb.as_ref()
}
pub fn lookup_command(&self, name: &str, mode: ContentMode) -> Option<&ActiveCommandRecord> {
self.kb_for(mode).lookup_command(name)
}
pub fn lookup_explicit_command(
&self,
name: &str,
mode: ContentMode,
) -> Option<&ActiveCommandRecord> {
self.kb_for(mode).lookup_explicit_command(name)
}
pub fn lookup_character(
&self,
name: &str,
mode: ContentMode,
) -> Option<&ActiveCharacterRecord> {
self.kb_for(mode).lookup_character(name)
}
pub fn lookup_env(&self, name: &str, mode: ContentMode) -> Option<&ActiveEnvironmentRecord> {
self.kb_for(mode).lookup_env(name)
}
pub fn knows_command_name(&self, name: &str) -> bool {
self.knows_command_name_in(name, ContentMode::Math)
|| self.knows_command_name_in(name, ContentMode::Text)
}
pub fn knows_env_name(&self, name: &str) -> bool {
self.knows_env_name_in(name, ContentMode::Math)
|| self.knows_env_name_in(name, ContentMode::Text)
}
pub fn knows_character_name(&self, name: &str) -> bool {
self.knows_character_name_in(name, ContentMode::Math)
|| self.knows_character_name_in(name, ContentMode::Text)
}
fn knows_command_name_in(&self, name: &str, mode: ContentMode) -> bool {
self.lookup_command(name, mode).is_some()
}
fn knows_env_name_in(&self, name: &str, mode: ContentMode) -> bool {
self.lookup_env(name, mode).is_some()
}
fn knows_character_name_in(&self, name: &str, mode: ContentMode) -> bool {
self.lookup_character(name, mode).is_some()
}
}
fn shared_parser() -> &'static ParseContext {
static DEFAULT: OnceLock<ParseContext> = OnceLock::new();
DEFAULT.get_or_init(ParseContext::default)
}
pub(crate) fn parse_with_context(
ctx: &ParseContext,
src: &str,
config: &ParseConfig,
) -> ParseResult {
let token_stream = build_token_stream(src);
let (output, mut errors) = parse_raw(ctx, src, token_stream, config);
let document = output.map(|tracked| {
let (node, _span, records, diagnostics) = tracked.finish_root();
errors.extend(diagnostics);
let path_spans: Vec<_> = records
.into_iter()
.map(|entry| {
(
entry.path,
Span {
start: entry.span.start,
end: entry.span.end,
},
)
})
.collect();
Document::from_syntax_with_spans(&node, &path_spans)
.expect("parser must produce a syntax root accepted by Document")
});
let mut diagnostics: Vec<_> = errors
.into_iter()
.map(|err| convert_diagnostic(ctx, src, err))
.collect();
diagnostics.sort_by_key(parse_diagnostic_priority);
ParseResult {
document,
diagnostics,
}
}
fn parse_raw(
ctx: &ParseContext,
src: &str,
token_stream: TokenStream<'_>,
config: &ParseConfig,
) -> (Option<TrackedNode>, Vec<Rich<'static, Token>>) {
let state = ParserState::new(ctx, config, src);
let (output, errors) = grammar::math_block_parser_with_source(&state, src)
.then_ignore(end())
.parse(token_stream)
.into_output_errors();
let mut collected_errors = state.take_recovery_diagnostics();
collected_errors.extend(errors.into_iter().map(|e| e.into_owned()));
(output, collected_errors)
}
fn convert_diagnostic(ctx: &ParseContext, src: &str, err: Rich<'static, Token>) -> ParseDiagnostic {
let span = {
let s = err.span();
Span {
start: s.start,
end: s.end,
}
};
let reason = err.reason();
let mut kind = None;
let contexts = err
.contexts()
.filter_map(|(label, span)| {
let label = format!("{label}");
if let Some(context_kind) = ParseDiagnosticKind::from_context_label(label.as_str()) {
kind.get_or_insert(context_kind);
return None;
}
Some(ParseDiagnosticContext {
label,
span: Span {
start: span.start,
end: span.end,
},
})
})
.collect();
let (message, expected, found) = match reason {
chumsky::error::RichReason::ExpectedFound {
expected: exp,
found: f,
} => {
let expected: Vec<String> = exp.iter().map(|p| format!("{p}")).collect();
let found = f.as_ref().map(|t| format!("{}", &**t));
let msg = format!("{reason}");
(msg, expected, found)
}
chumsky::error::RichReason::Custom(msg) => {
let (message_kind, public_message) = ParseDiagnosticKind::split_message(msg.as_str());
if let Some(message_kind) = message_kind {
kind.get_or_insert(message_kind);
}
(public_message.to_string(), Vec::new(), None)
}
};
let mut kind =
kind.or_else(|| infer_raw_diagnostic_kind(expected.as_slice(), found.as_deref()));
let mut diagnostic = ParseDiagnostic {
kind,
message,
span,
expected,
found,
contexts,
};
supplement_comment_truncated_argument(src, &mut kind, &mut diagnostic);
supplement_diagnostic_contexts(ctx, src, kind, &mut diagnostic);
diagnostic
}
fn parse_diagnostic_priority(diagnostic: &ParseDiagnostic) -> u8 {
match diagnostic.kind {
Some(
ParseDiagnosticKind::UnknownCommand
| ParseDiagnosticKind::UnknownEnvironment
| ParseDiagnosticKind::CommentTruncatedArgument
| ParseDiagnosticKind::UnexpectedMathShift
| ParseDiagnosticKind::LeftRightDelimiter
| ParseDiagnosticKind::AmbiguousInfix,
) => 1,
Some(ParseDiagnosticKind::ArgumentValidation) => 2,
Some(ParseDiagnosticKind::EnvironmentNameMismatch) => 2,
Some(ParseDiagnosticKind::RawExpectedFound)
if diagnostic
.message
.starts_with("found end of input expected ") =>
{
3
}
Some(ParseDiagnosticKind::RawExpectedFound) => 4,
Some(_) | None => 2,
}
}
fn infer_raw_diagnostic_kind(
expected: &[String],
found: Option<&str>,
) -> Option<ParseDiagnosticKind> {
if expected.iter().any(|pattern| pattern == "'$'")
&& matches!(found, None | Some("$") | Some("\\text"))
{
return Some(ParseDiagnosticKind::UnclosedInlineMath);
}
match found {
Some("$") => Some(ParseDiagnosticKind::UnexpectedMathShift),
Some("}") => Some(ParseDiagnosticKind::EnvironmentNameMismatch),
Some("\\begin") => Some(ParseDiagnosticKind::UnknownEnvironment),
Some(_) if !expected.is_empty() => Some(ParseDiagnosticKind::RawExpectedFound),
None if !expected.is_empty() => Some(ParseDiagnosticKind::RawExpectedFound),
Some(_) | None => None,
}
}
fn supplement_diagnostic_contexts(
ctx: &ParseContext,
src: &str,
kind: Option<ParseDiagnosticKind>,
diagnostic: &mut ParseDiagnostic,
) {
let mut lexed = None;
supplement_unclosed_inline_math_message(kind, src, diagnostic);
supplement_unexpected_math_shift_message(kind, src, diagnostic);
supplement_generic_unclosed_message(kind, src, diagnostic);
supplement_environment_mode_error_message(kind, ctx, src, &mut lexed, diagnostic);
supplement_environment_mismatch_message(kind, src, &mut lexed, diagnostic);
supplement_unknown_environment_message(kind, ctx, src, &mut lexed, diagnostic);
supplement_inner_content_error_span(kind, src, &mut lexed, diagnostic);
supplement_argument_validation_span(kind, src, &mut lexed, diagnostic);
let needs_left_context = kind == Some(ParseDiagnosticKind::LeftRightDelimiter);
if !needs_left_context {
return;
}
let Some((left_span, env_span)) =
find_invalid_left_context(ctx, lexed.get_or_insert_with(|| lex_source(src)))
else {
return;
};
if !diagnostic
.contexts
.iter()
.any(|context| context.label == "left-delimited group")
{
diagnostic.contexts.push(ParseDiagnosticContext {
label: "left-delimited group".to_string(),
span: left_span,
});
}
if let Some(env_span) = env_span
&& !diagnostic
.contexts
.iter()
.any(|context| context.label == "environment body")
{
diagnostic.contexts.push(ParseDiagnosticContext {
label: "environment body".to_string(),
span: env_span,
});
}
}
fn supplement_unclosed_inline_math_message(
kind: Option<ParseDiagnosticKind>,
src: &str,
diagnostic: &mut ParseDiagnostic,
) {
if kind != Some(ParseDiagnosticKind::UnclosedInlineMath) {
return;
}
diagnostic.message = "found '$' expected something else, or end of input".to_string();
if diagnostic.expected.iter().any(|value| value == "'$'") {
diagnostic.expected = vec!["something else".to_string(), "end of input".to_string()];
}
if diagnostic.found.as_deref() == Some("\\text")
&& let Some(span) = find_inline_math_shift_after_command(src, diagnostic.span.clone())
{
diagnostic.span = span;
diagnostic.found = Some("$".to_string());
}
}
fn supplement_comment_truncated_argument(
src: &str,
kind: &mut Option<ParseDiagnosticKind>,
diagnostic: &mut ParseDiagnostic,
) {
if !matches!(
*kind,
Some(ParseDiagnosticKind::ArgumentValidation | ParseDiagnosticKind::RawExpectedFound)
| None
) {
return;
}
if !matches!(
diagnostic.message.as_str(),
"unclosed brace argument" | "unclosed bracket argument" | "unclosed delimited argument"
) && !diagnostic
.message
.starts_with("found end of input expected ")
{
return;
}
let tail_span = Span {
start: diagnostic.span.start,
end: src.len(),
};
let candidate_spans = std::iter::once(diagnostic.span.clone())
.chain(std::iter::once(tail_span))
.chain(
diagnostic
.contexts
.iter()
.filter(|context| context.label.contains("argument"))
.map(|context| context.span.clone()),
);
if !candidate_spans
.filter_map(|span| src.get(span.start..span.end))
.any(has_unescaped_percent)
{
return;
}
*kind = Some(ParseDiagnosticKind::CommentTruncatedArgument);
diagnostic.kind = *kind;
diagnostic.message = "Unescaped % starts a comment inside this argument".to_string();
diagnostic.expected.clear();
diagnostic.found = None;
}
fn has_unescaped_percent(slice: &str) -> bool {
let mut escaped = false;
for ch in slice.chars() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '%' {
return true;
}
}
false
}
fn supplement_unexpected_math_shift_message(
kind: Option<ParseDiagnosticKind>,
src: &str,
diagnostic: &mut ParseDiagnostic,
) {
if kind != Some(ParseDiagnosticKind::UnexpectedMathShift) {
return;
}
diagnostic.message = if src
.as_bytes()
.get(diagnostic.span.end)
.is_some_and(u8::is_ascii_digit)
{
"Unexpected $ inside a math formula; it looks like a currency marker".to_string()
} else {
"Unexpected $ inside a math formula".to_string()
};
diagnostic.expected.clear();
diagnostic.found = Some("$".to_string());
}
fn supplement_generic_unclosed_message(
kind: Option<ParseDiagnosticKind>,
src: &str,
diagnostic: &mut ParseDiagnostic,
) {
if kind != Some(ParseDiagnosticKind::RawExpectedFound)
|| !diagnostic
.message
.starts_with("found end of input expected ")
{
return;
}
if let Some(argument_context) = diagnostic
.contexts
.iter()
.find(|context| context.label.contains("argument"))
&& let Some(command_name) = command_name_before(src, argument_context.span.start)
{
diagnostic.message = format!("Command \\{} has an unclosed argument", command_name);
return;
}
if let Some(env_name) = last_unclosed_environment_name(src) {
diagnostic.message = format!(
"Environment {} missing closing \\end{{{}}}",
env_name, env_name
);
return;
}
if diagnostic
.span
.start
.checked_sub(1)
.and_then(|index| src.as_bytes().get(index))
== Some(&b'{')
{
diagnostic.message = "Unclosed { ... } group".to_string();
}
}
fn command_name_before(src: &str, offset: usize) -> Option<&str> {
let prefix = src.get(..offset)?;
let slash = prefix.rfind('\\')?;
let rest = prefix.get(slash + 1..)?;
let end = rest
.char_indices()
.find_map(|(index, ch)| (!ch.is_ascii_alphabetic()).then_some(index))
.unwrap_or(rest.len());
(end > 0).then(|| &rest[..end])
}
fn last_unclosed_environment_name(src: &str) -> Option<String> {
let lexed = lex_source(src);
let mut stack = Vec::new();
let mut index = 0;
while index < lexed.len() {
let Token::ControlSeq(head) = &lexed[index].0 else {
index += 1;
continue;
};
if !matches!(head.as_str(), "begin" | "end") {
index += 1;
continue;
}
let mut next = index + 1;
while matches!(lexed.get(next), Some((Token::Whitespaces, _))) {
next += 1;
}
if !matches!(lexed.get(next), Some((Token::LBrace, _))) {
index += 1;
continue;
}
next += 1;
let mut env_name = String::new();
while let Some((token, _)) = lexed.get(next) {
match token {
Token::Char(ch) => env_name.push(*ch),
Token::Star => env_name.push('*'),
Token::RBrace => break,
_ => {
env_name.clear();
break;
}
}
next += 1;
}
if env_name.is_empty() {
index += 1;
continue;
}
if head == "begin" {
stack.push(env_name);
} else if let Some(pos) = stack.iter().rposition(|open| open == &env_name) {
stack.truncate(pos);
}
index += 1;
}
stack.pop()
}
fn find_inline_math_shift_after_command(src: &str, command_span: Span) -> Option<Span> {
let mut offset = command_span.end;
while matches!(src.as_bytes().get(offset), Some(b' ' | b'\t' | b'\n')) {
offset += 1;
}
if src.as_bytes().get(offset) != Some(&b'{') || src.as_bytes().get(offset + 1) != Some(&b'$') {
return None;
}
Some(Span {
start: offset + 1,
end: offset + 2,
})
}
fn supplement_environment_mode_error_message(
kind: Option<ParseDiagnosticKind>,
ctx: &ParseContext,
src: &str,
lexed: &mut Option<LexedSource>,
diagnostic: &mut ParseDiagnostic,
) {
if !matches!(
kind,
Some(ParseDiagnosticKind::RawExpectedFound | ParseDiagnosticKind::EnvironmentNameMismatch)
) {
return;
}
let Some((name, disallowed_mode, span)) = find_environment_mode_error_at_span(
ctx,
lexed.get_or_insert_with(|| lex_source(src)),
diagnostic.span.clone(),
)
.or_else(|| {
if diagnostic.span.start == 0 {
find_first_known_but_disallowed_environment(
ctx,
lexed.get_or_insert_with(|| lex_source(src)),
)
} else {
None
}
}) else {
return;
};
diagnostic.message = format!(
"Environment {} is not allowed in {} mode",
name, disallowed_mode
);
diagnostic.span = span;
diagnostic.expected.clear();
diagnostic.found = None;
}
fn supplement_environment_mismatch_message(
kind: Option<ParseDiagnosticKind>,
src: &str,
lexed: &mut Option<LexedSource>,
diagnostic: &mut ParseDiagnostic,
) {
if kind != Some(ParseDiagnosticKind::EnvironmentNameMismatch) {
return;
}
let Some((expected, found, span)) = find_environment_name_mismatch(
lexed.get_or_insert_with(|| lex_source(src)),
diagnostic.span.clone(),
) else {
return;
};
diagnostic.message = format!(
"Environment name mismatch: expected \\end{{{}}}, found \\end{{{}}}",
expected, found
);
diagnostic.span = span;
diagnostic.expected = vec![format!("\\end{{{}}}", expected)];
diagnostic.found = Some(format!("\\end{{{}}}", found));
}
fn supplement_unknown_environment_message(
kind: Option<ParseDiagnosticKind>,
ctx: &ParseContext,
src: &str,
lexed: &mut Option<LexedSource>,
diagnostic: &mut ParseDiagnostic,
) {
if kind != Some(ParseDiagnosticKind::UnknownEnvironment) {
return;
}
let Some((name, span)) = find_unknown_environment_at_span(
ctx,
lexed.get_or_insert_with(|| lex_source(src)),
diagnostic.span.clone(),
) else {
return;
};
diagnostic.message = format!("Unknown environment: {}", name);
diagnostic.span = span;
diagnostic.expected.clear();
diagnostic.found = None;
}
fn supplement_argument_validation_span(
kind: Option<ParseDiagnosticKind>,
src: &str,
lexed: &mut Option<LexedSource>,
diagnostic: &mut ParseDiagnostic,
) {
if kind != Some(ParseDiagnosticKind::ArgumentValidation) {
return;
}
let Some(span_text) = src.get(diagnostic.span.start..diagnostic.span.end) else {
return;
};
if !span_text.starts_with('\\') {
return;
}
let Some(argument_span) = find_argument_surface_span(
lexed.get_or_insert_with(|| lex_source(src)),
diagnostic.span.end,
) else {
return;
};
diagnostic.span = argument_span;
}
fn supplement_inner_content_error_span(
kind: Option<ParseDiagnosticKind>,
src: &str,
lexed: &mut Option<LexedSource>,
diagnostic: &mut ParseDiagnostic,
) {
if !matches!(
kind,
Some(ParseDiagnosticKind::CommandModeError | ParseDiagnosticKind::TextScriptError)
) {
return;
}
let Some(span_text) = src.get(diagnostic.span.start..diagnostic.span.end) else {
return;
};
if !span_text.starts_with('\\') {
return;
}
let Some(argument_span) = find_argument_surface_span(
lexed.get_or_insert_with(|| lex_source(src)),
diagnostic.span.end,
) else {
return;
};
if kind == Some(ParseDiagnosticKind::TextScriptError)
&& let Some(span) = find_first_script_marker_in_span(src, argument_span.clone())
{
diagnostic.span = span;
return;
}
let Some(command_name) = diagnostic
.message
.strip_prefix("Command ")
.and_then(|rest| rest.split(" is not allowed in ").next())
else {
return;
};
if span_text == command_name {
return;
}
if let Some(span) = find_command_name_in_span(src, argument_span, command_name) {
diagnostic.span = span;
}
}
fn find_first_script_marker_in_span(src: &str, span: Span) -> Option<Span> {
let slice = src.get(span.start..span.end)?;
let offset = slice.find(['^', '_'])?;
Some(Span {
start: span.start + offset,
end: span.start + offset + 1,
})
}
fn find_command_name_in_span(src: &str, span: Span, command_name: &str) -> Option<Span> {
let slice = src.get(span.start..span.end)?;
let offset = slice.find(command_name)?;
Some(Span {
start: span.start + offset,
end: span.start + offset + command_name.len(),
})
}
fn find_argument_surface_span(tokens: &LexedSource, after: usize) -> Option<Span> {
let mut index = 0;
while index < tokens.len() && tokens[index].1.end <= after {
index += 1;
}
while matches!(tokens.get(index), Some((Token::Whitespaces, _))) {
index += 1;
}
let (token, span) = tokens.get(index)?;
match token {
Token::LBracket => {
let mut brace_depth = 0usize;
let mut bracket_depth = 0usize;
let start = span.start;
for (token, span) in tokens.iter().skip(index + 1) {
match token {
Token::LBracket if brace_depth == 0 => bracket_depth += 1,
Token::RBracket if brace_depth == 0 => {
if bracket_depth == 0 {
return Some(Span {
start,
end: span.end,
});
}
bracket_depth -= 1;
}
Token::LBrace => brace_depth += 1,
Token::RBrace if brace_depth > 0 => brace_depth -= 1,
_ => {}
}
}
None
}
Token::LBrace => {
let mut depth = 0usize;
let start = span.start;
for (token, span) in tokens.iter().skip(index + 1) {
match token {
Token::LBrace => depth += 1,
Token::RBrace => {
if depth == 0 {
return Some(Span {
start,
end: span.end,
});
}
depth -= 1;
}
_ => {}
}
}
None
}
_ => None,
}
}
fn find_invalid_left_context(
ctx: &ParseContext,
tokens: &LexedSource,
) -> Option<(Span, Option<Span>)> {
let mut environment_stack = Vec::new();
let mut index = 0;
while index < tokens.len() {
match &tokens[index].0 {
Token::ControlSeq(name) if name == "begin" => {
environment_stack.push(environment_body_start(tokens, index));
}
Token::ControlSeq(name) if name == "end" => {
environment_stack.pop();
}
Token::ControlSeq(name) if name == "left" => {
let mut next = index + 1;
while matches!(tokens.get(next), Some((Token::Whitespaces, _))) {
next += 1;
}
let Some((token, token_span)) = tokens.get(next) else {
let left_span = Span {
start: tokens[index].1.start,
end: tokens[index].1.end,
};
let env_span = environment_stack.last().map(|start| Span {
start: *start,
end: left_span.end,
});
return Some((left_span, env_span));
};
let is_valid_delimiter = match token {
Token::Char(c) => ctx
.lookup_delimiter(c.to_string().as_str(), false, ContentMode::Math)
.is_some(),
Token::LBracket => ctx
.lookup_delimiter("[", false, ContentMode::Math)
.is_some(),
Token::RBracket => ctx
.lookup_delimiter("]", false, ContentMode::Math)
.is_some(),
Token::ControlSeq(name) => ctx
.lookup_delimiter(name.as_str(), true, ContentMode::Math)
.is_some(),
_ => false,
};
if !is_valid_delimiter {
let left_span = Span {
start: tokens[index].1.start,
end: token_span.end,
};
let env_span = environment_stack.last().map(|start| Span {
start: *start,
end: token_span.end,
});
return Some((left_span, env_span));
}
}
_ => {}
}
index += 1;
}
None
}
fn find_environment_name_mismatch(
tokens: &LexedSource,
target_span: Span,
) -> Option<(String, String, Span)> {
let mut stack = Vec::new();
let mut index = 0;
while index < tokens.len() {
let Some((Token::ControlSeq(head), _)) = tokens.get(index) else {
index += 1;
continue;
};
if !matches!(head.as_str(), "begin" | "end") {
index += 1;
continue;
}
let mut next = index + 1;
while matches!(tokens.get(next), Some((Token::Whitespaces, _))) {
next += 1;
}
if !matches!(tokens.get(next), Some((Token::LBrace, _))) {
index += 1;
continue;
}
next += 1;
let mut env_name = String::new();
while let Some((token, _)) = tokens.get(next) {
match token {
Token::Char(c) => env_name.push(*c),
Token::Star => env_name.push('*'),
Token::RBrace => break,
_ => {
env_name.clear();
break;
}
}
next += 1;
}
if env_name.is_empty() {
index += 1;
continue;
}
if head == "begin" {
stack.push(env_name);
} else if let Some(expected) = stack.last() {
if expected == &env_name {
stack.pop();
} else {
let mismatch_closer_span = Span {
start: tokens[next].1.start,
end: tokens[next].1.end,
};
if mismatch_closer_span.start != target_span.start
|| mismatch_closer_span.end != target_span.end
{
index += 1;
continue;
}
return Some((
expected.clone(),
env_name,
Span {
start: tokens[index].1.start,
end: tokens[next].1.end,
},
));
}
}
index += 1;
}
None
}
fn find_unknown_environment_at_span(
ctx: &ParseContext,
tokens: &LexedSource,
target_span: Span,
) -> Option<(String, Span)> {
let mut index = 0;
while index < tokens.len() {
let Some((Token::ControlSeq(name), begin_span)) = tokens.get(index) else {
index += 1;
continue;
};
if name != "begin"
|| begin_span.start != target_span.start
|| begin_span.end != target_span.end
{
index += 1;
continue;
}
index += 1;
while matches!(tokens.get(index), Some((Token::Whitespaces, _))) {
index += 1;
}
let Some((Token::LBrace, _)) = tokens.get(index) else {
return None;
};
index += 1;
let name_start = tokens.get(index)?.1.start;
let mut parsed_name = String::new();
let mut name_end = name_start;
while let Some((token, span)) = tokens.get(index) {
match token {
Token::Char(ch) => {
parsed_name.push(*ch);
name_end = span.end;
index += 1;
}
Token::Star => {
parsed_name.push('*');
name_end = span.end;
index += 1;
}
Token::RBrace => break,
_ => return None,
}
}
if parsed_name.is_empty() || ctx.knows_env_name(parsed_name.as_str()) {
return None;
}
return Some((
parsed_name,
Span {
start: name_start,
end: name_end,
},
));
}
None
}
fn find_first_known_but_disallowed_environment(
ctx: &ParseContext,
tokens: &LexedSource,
) -> Option<(String, ContentMode, Span)> {
let mut index = 0;
while index < tokens.len() {
let Some((Token::ControlSeq(name), head_span)) = tokens.get(index) else {
index += 1;
continue;
};
if name != "begin" {
index += 1;
continue;
}
let begin_start = head_span.start;
index += 1;
while matches!(tokens.get(index), Some((Token::Whitespaces, _))) {
index += 1;
}
if !matches!(tokens.get(index), Some((Token::LBrace, _))) {
continue;
}
index += 1;
let mut parsed_name = String::new();
while let Some((token, _)) = tokens.get(index) {
match token {
Token::Char(ch) => {
parsed_name.push(*ch);
index += 1;
}
Token::Star => {
parsed_name.push('*');
index += 1;
}
Token::RBrace => break,
_ => return None,
}
}
let Some((Token::RBrace, close_span)) = tokens.get(index) else {
return None;
};
if parsed_name.is_empty() {
index += 1;
continue;
}
let math_known = ctx
.lookup_env(parsed_name.as_str(), ContentMode::Math)
.is_some();
let text_known = ctx
.lookup_env(parsed_name.as_str(), ContentMode::Text)
.is_some();
let disallowed_mode = match (math_known, text_known) {
(false, true) => ContentMode::Math,
(true, false) => ContentMode::Text,
_ => {
index += 1;
continue;
}
};
return Some((
parsed_name,
disallowed_mode,
Span {
start: begin_start,
end: close_span.end,
},
));
}
None
}
fn find_environment_mode_error_at_span(
ctx: &ParseContext,
tokens: &LexedSource,
target_span: Span,
) -> Option<(String, ContentMode, Span)> {
let mut index = 0;
while index < tokens.len() {
let Some((Token::ControlSeq(name), _)) = tokens.get(index) else {
index += 1;
continue;
};
if name != "begin" {
index += 1;
continue;
}
let begin_start = tokens[index].1.start;
index += 1;
while matches!(tokens.get(index), Some((Token::Whitespaces, _))) {
index += 1;
}
if !matches!(tokens.get(index), Some((Token::LBrace, _))) {
continue;
}
index += 1;
let mut parsed_name = String::new();
while let Some((token, _)) = tokens.get(index) {
match token {
Token::Char(ch) => {
parsed_name.push(*ch);
index += 1;
}
Token::Star => {
parsed_name.push('*');
index += 1;
}
Token::RBrace => break,
_ => return None,
}
}
let Some((Token::RBrace, close_span)) = tokens.get(index) else {
return None;
};
let matches_target =
close_span.start == target_span.start || close_span.end == target_span.end;
if !matches_target || parsed_name.is_empty() {
index += 1;
continue;
}
let math_known = ctx
.lookup_env(parsed_name.as_str(), ContentMode::Math)
.is_some();
let text_known = ctx
.lookup_env(parsed_name.as_str(), ContentMode::Text)
.is_some();
let disallowed_mode = match (math_known, text_known) {
(false, true) => ContentMode::Math,
(true, false) => ContentMode::Text,
_ => return None,
};
return Some((
parsed_name,
disallowed_mode,
Span {
start: begin_start,
end: close_span.end,
},
));
}
None
}
fn environment_body_start(tokens: &[(Token, std::ops::Range<usize>)], begin_index: usize) -> usize {
let mut index = begin_index + 1;
while matches!(tokens.get(index), Some((Token::Whitespaces, _))) {
index += 1;
}
if !matches!(tokens.get(index), Some((Token::LBrace, _))) {
return tokens[begin_index].1.start;
}
index += 1;
while let Some((token, span)) = tokens.get(index) {
if matches!(token, Token::RBrace) {
return span.end;
}
index += 1;
}
tokens[begin_index].1.start
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn eof_unclosed_inline_math_is_normalized() {
let expected = vec!["something else".to_string(), "'$'".to_string()];
let mut diagnostic = ParseDiagnostic {
kind: Some(ParseDiagnosticKind::UnclosedInlineMath),
message: "found end of input expected something else, or '$'".to_string(),
span: Span { start: 0, end: 2 },
expected,
found: None,
contexts: Vec::new(),
};
supplement_diagnostic_contexts(
&ParseContext::empty(),
"$x",
Some(ParseDiagnosticKind::UnclosedInlineMath),
&mut diagnostic,
);
assert_eq!(
diagnostic.message,
"found '$' expected something else, or end of input"
);
assert_eq!(diagnostic.expected, ["something else", "end of input"]);
assert_eq!(diagnostic.found, None);
}
#[test]
fn argument_validation_span_uses_kind_not_message() {
let mut diagnostic = ParseDiagnostic {
kind: Some(ParseDiagnosticKind::ArgumentValidation),
message: "argument value was rejected".to_string(),
span: Span { start: 0, end: 7 },
expected: Vec::new(),
found: None,
contexts: Vec::new(),
};
supplement_diagnostic_contexts(
&ParseContext::empty(),
"\\hspace{bad}",
Some(ParseDiagnosticKind::ArgumentValidation),
&mut diagnostic,
);
assert_eq!(diagnostic.span, Span { start: 7, end: 12 });
}
}