use std::{
borrow::Cow,
collections::HashSet,
fmt::{self, Display, Formatter},
iter
};
use crate::{
CompilationError, Parser, Validator,
ast::{Event, Expression, Function, Node, Parameter, Walk},
parser::{
FailureSite, Recovery, Repair, Site, canonical_name,
is_bare_word_continue, is_bare_word_start, is_token_space,
unclosed_name_ends
},
span::SourceSpan
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiagnosticKind
{
UnclosedDelimiter
{
opener: char,
expected_closer: char
},
MissingExpression
{
opener: char
},
UnopenedDelimiter
{
closer: char
},
MissingRightOperand
{
operator: char
},
MissingLeftOperand
{
operator: char
},
BareIdentifier,
MissingDiceFaces,
IncompleteDropClause,
MisplacedDropClause,
IncompleteParameterDefinition,
MissingParameter,
TrailingInput,
EmptyExpression,
UnexpectedToken,
UnexpectedEof,
DuplicateParameter
{
name: String
},
BindingCollidesWithParameter
{
name: String
},
DuplicateBinding
{
name: String
},
UseBeforeBind
{
name: String
}
}
impl Display for DiagnosticKind
{
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result
{
match self
{
Self::UnclosedDelimiter { opener, .. } =>
{
write!(f, "unclosed `{}`", opener)
},
Self::MissingExpression { opener } =>
{
write!(f, "missing expression in `{}`", opener)
},
Self::UnopenedDelimiter { closer } =>
{
write!(f, "unexpected `{}`", closer)
},
Self::MissingRightOperand { operator } =>
{
write!(f, "missing right operand of `{}`", operator)
},
Self::MissingLeftOperand { operator } =>
{
write!(f, "missing left operand of `{}`", operator)
},
Self::BareIdentifier => write!(f, "bare identifier"),
Self::MissingDiceFaces => write!(f, "missing dice faces"),
Self::IncompleteDropClause =>
{
write!(f, "incomplete drop clause")
},
Self::MisplacedDropClause => write!(f, "misplaced drop clause"),
Self::IncompleteParameterDefinition =>
{
write!(f, "incomplete parameter definition")
},
Self::MissingParameter => write!(f, "missing parameter"),
Self::TrailingInput => write!(f, "trailing input"),
Self::EmptyExpression => write!(f, "empty expression"),
Self::UnexpectedToken => write!(f, "unexpected token"),
Self::UnexpectedEof => write!(f, "unexpected end of input"),
Self::DuplicateParameter { name } =>
{
write!(f, "duplicate parameter `{}`", name)
},
Self::BindingCollidesWithParameter { name } =>
{
write!(
f,
"local binding `{}` collides with formal parameter",
name
)
},
Self::DuplicateBinding { name } =>
{
write!(f, "duplicate local binding `{}`", name)
},
Self::UseBeforeBind { name } =>
{
write!(f, "reference to `{}` precedes its binding", name)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Diagnostic
{
pub kind: DiagnosticKind,
pub span: SourceSpan,
pub message: String,
pub related: Vec<RelatedLabel>,
pub suggestions: Vec<Suggestion>
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelatedLabel
{
pub span: SourceSpan,
pub message: String
}
impl Display for Diagnostic
{
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result
{
write!(f, "{} ({}): {}", self.kind, self.span, self.message)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Suggestion
{
pub description: String,
pub edits: Vec<Edit>
}
impl Suggestion
{
pub fn apply(&self, source: &str) -> String
{
let mut corrected = String::with_capacity(source.len());
let mut copied = 0;
for edit in &self.edits
{
corrected.push_str(&source[copied..edit.span.start]);
corrected.push_str(&edit.replacement);
copied = edit.span.end;
}
corrected.push_str(&source[copied..]);
corrected
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Edit
{
pub span: SourceSpan,
pub replacement: String,
pub placeholders: Vec<Placeholder>
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Placeholder
{
pub span: SourceSpan,
pub description: &'static str,
pub valid_kinds: &'static [&'static str]
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnoseResult
{
pub diagnostics: Vec<Diagnostic>,
pub corrected_source: Option<String>
}
const OPERAND_KINDS: &[&str] =
&["integer", "{variable}", "(expression)", "dice expression"];
const FACE_COUNT_KINDS: &[&str] = &["integer", "{variable}", "(expression)"];
const DICE_KINDS: &[&str] = &["dice expression"];
const DROP_DIRECTION_KINDS: &[&str] = &["lowest", "highest"];
#[derive(Debug)]
struct Doctor<'src>
{
source: &'src str,
original: &'src str,
strays: Vec<(usize, usize)>,
content_end: usize,
diagnostics: Vec<Diagnostic>,
fixes: Vec<Edit>,
compound: Option<Compound>,
state: State,
preceding: Option<(usize, Option<(char, usize)>)>,
bare_word: Option<(usize, usize)>,
trailing: bool,
reread: Option<usize>,
skipped: Option<(usize, usize)>
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum State
{
Fixing,
Supplanted,
Unfixable
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Place
{
Start,
Parameters,
Expression
}
#[derive(Debug)]
enum Analysis
{
Diagnosed(Diagnostic, Repair),
Supplanting(Diagnostic),
Compound(Compound)
}
impl<'src> Doctor<'src>
{
fn new(
source: &'src str,
original: &'src str,
strays: Vec<(usize, usize)>
) -> Self
{
Self {
source,
original,
strays,
content_end: source.trim_end_matches(is_token_space).len(),
diagnostics: Vec::new(),
fixes: Vec::new(),
compound: None,
state: State::Fixing,
preceding: None,
bare_word: None,
trailing: false,
reread: None,
skipped: None
}
}
fn finish(mut self, recovered: bool) -> DiagnoseResult
{
self.finish_compound();
let fixed = match self.state
{
State::Fixing => recovered,
State::Supplanted => true,
State::Unfixable => false
};
self.diagnose_strays();
let corrected_source = fixed.then(|| {
self.fixes
.sort_by_key(|edit| (edit.span.start, edit.span.end));
let mut corrected = apply(self.original, &self.fixes);
if self.trailing
{
corrected
.truncate(corrected.trim_end_matches(is_token_space).len());
}
corrected
});
DiagnoseResult {
diagnostics: self.diagnostics,
corrected_source
}
}
fn diagnose_strays(&mut self)
{
let mut replaced = self
.fixes
.iter()
.filter(|edit| edit.span.start < edit.span.end)
.map(|edit| (edit.span.start, edit.span.end))
.collect::<Vec<_>>();
replaced.sort_unstable();
let mut replaced = replaced.into_iter().peekable();
let mut reach = 0;
let mut strays = Vec::new();
for &(start, end) in &self.strays
{
while let Some((_, region_end)) =
replaced.next_if(|&(region_start, _)| region_start <= start)
{
reach = reach.max(region_end);
}
if reach < end
{
let diagnostic =
make_stray_whitespace(self.original, start, end);
self.fixes.extend(diagnostic.suggestions[0].edits.clone());
strays.push(diagnostic);
}
}
let mut strays = strays.into_iter().peekable();
let others = std::mem::take(&mut self.diagnostics);
for diagnostic in others
{
self.diagnostics.extend(iter::from_fn(|| {
strays.next_if(|stray| stray.span.start < diagnostic.span.start)
}));
self.diagnostics.push(diagnostic);
}
self.diagnostics.extend(strays);
}
fn finish_compound(&mut self)
{
if let Some(compound) = self.compound.take()
{
self.record(compound.diagnostic(self.source));
}
}
fn record(&mut self, diagnostic: Diagnostic)
{
if let Some(first) = diagnostic.suggestions.first()
{
self.fixes.extend(first.edits.iter().cloned());
}
self.diagnostics.push(diagnostic);
}
fn preceding(&mut self, pos: usize) -> Option<(char, usize)>
{
if let Some((at, preceding)) = self.preceding
&& at == pos
{
return preceding;
}
let before = |end: usize| {
self.source[..end]
.char_indices()
.rev()
.find(|(_, c)| !is_token_space(*c))
.map(|(i, c)| (c, i))
};
let preceding = match (before(pos), self.skipped)
{
(Some((_, i)), Some((start, end))) if start <= i && i < end =>
{
before(start)
},
(preceding, _) => preceding
};
self.preceding = Some((pos, preceding));
preceding
}
fn skip(&mut self, start: usize, end: usize)
{
self.skipped = match self.skipped
{
Some((first, last))
if self.source[last..start]
.trim_matches(is_token_space)
.is_empty() =>
{
Some((first, end))
},
_ => Some((start, end))
};
}
fn bare_word_end(&mut self, pos: usize) -> Option<usize>
{
if !self.source[pos..].starts_with(is_bare_word_start)
{
return None;
}
if let Some((start, end)) = self.bare_word
&& start <= pos
&& pos < end
{
return Some(end);
}
let remaining = &self.source[pos..];
let end = pos
+ remaining
.find(|c: char| !is_bare_word_continue(c))
.unwrap_or(remaining.len());
self.bare_word = Some((pos, end));
Some(end)
}
fn analyze(&mut self, site: &FailureSite<'src>) -> Analysis
{
let source = self.source;
let pos = site.position();
let at_eof = pos >= self.content_end;
let is_goal = matches!(site.site, Site::Goal { .. });
let expectations = site
.error
.errors
.iter()
.flat_map(|(_, kind)| kind.expectations())
.collect::<Vec<_>>();
let expects_expression = expectations.iter().any(|e| {
let s = e.as_ref();
s == "integer"
|| s == "dice expression"
|| s == "`(`"
|| s == "`{`"
|| s == "`[`"
|| s == "`-`"
});
let expects_delimiter =
expectations.iter().find_map(|e| match e.as_ref()
{
"`)`" => Some(('(', ')')),
"`]`" => Some(('[', ']')),
"`}`" => Some(('{', '}')),
_ => None
});
if let Site::Closer {
opener: opener_pos,
closer: '}'
} = site.site
{
let name = source[opener_pos + 1..pos].trim_start();
let start = pos - name.len();
let ends = unclosed_name_ends(name)
.into_iter()
.flatten()
.map(|length| start + length)
.collect::<Vec<_>>();
let repair = ends
.first()
.map_or(Repair::Fix, |&end| Repair::Break { end });
return Analysis::Diagnosed(
make_unclosed_brace(source, opener_pos, &ends, pos, at_eof),
repair
);
}
if at_eof
&& let Some((opener, closer)) = expects_delimiter
&& let Site::Closer {
opener: opener_pos, ..
} = site.site
{
return Analysis::Diagnosed(
make_unclosed_delimiter(opener, closer, opener_pos, pos),
Repair::Fix
);
}
let misplaced_drop = match site.site
{
Site::Faces => self
.preceding(pos)
.filter(|&(c, d_pos)| c == 'd' && begins_drop(&source[d_pos..]))
.map(|(_, d_pos)| d_pos),
Site::Goal { .. }
if expects_expression && begins_drop(&source[pos..]) =>
{
Some(pos)
},
_ => None
};
if let Some(drop_pos) = misplaced_drop
{
let after_operand = site.site == Site::Faces;
let repair = if after_operand
{
Repair::Reread
}
else
{
self.reread = Some(drop_pos);
Repair::Fix
};
return Analysis::Diagnosed(
make_misplaced_drop(source, drop_pos, after_operand),
repair
);
}
let reads_variable = match site.site
{
Site::Goal { .. } => true,
Site::Faces => self.preceding(pos).is_none_or(|(c, _)| c != '-'),
_ => false
};
if expects_expression && !at_eof && reads_variable
{
let place = if is_goal && self.preceding(pos).is_none()
{
Place::Start
}
else
{
Place::Expression
};
if let Some((diagnostic, end)) = self.bare_identifier_at(pos, place)
{
return Analysis::Diagnosed(
diagnostic,
Repair::Variable { end }
);
}
}
if site.site == Site::ParameterName
{
let start = source.len()
- source[pos..].trim_start_matches(is_token_space).len();
if let Some((diagnostic, end)) =
self.bare_identifier_at(start, Place::Parameters)
{
return Analysis::Diagnosed(
diagnostic,
Repair::Variable { end }
);
}
if source[start..].starts_with([',', ':'])
&& source[..pos].ends_with(',')
{
return Analysis::Diagnosed(
make_missing_parameter(pos - 1, start),
Repair::Retract
);
}
}
let expects_identifier =
expectations.iter().any(|e| e.as_ref() == "identifier");
if (expects_expression || expects_identifier)
&& let Some((prev, prev_pos)) = self.preceding(pos)
{
if (prev == 'd' || prev == 'D')
&& (is_goal || site.site == Site::Faces)
{
return Analysis::Diagnosed(
make_missing_dice_faces(source, prev_pos),
Repair::Fix
);
}
if "+-*×/÷%^".contains(prev) && is_goal
{
return Analysis::Diagnosed(
make_missing_right_operand(source, prev, prev_pos, pos),
Repair::Fix
);
}
let shape = match (prev, site.site)
{
('(', Site::Goal { .. }) => Some(Shape::Group),
('[', Site::Goal { .. }) => Some(Shape::Range),
('[', Site::FaceValue { .. }) => Some(Shape::CustomFaces),
('{', Site::VariableName { .. }) => Some(Shape::Variable),
_ => None
};
if let Some(shape) = shape
{
return Analysis::Compound(Compound::new(shape, prev_pos, pos));
}
if prev == ':'
&& let Site::Goal {
range: Some(bracket),
..
} = site.site
{
return Analysis::Compound(Compound::new(
Shape::RangeEnd,
bracket,
pos
));
}
}
if site.site == Site::Direction
&& expectations
.iter()
.any(|e| e.as_ref() == "`lowest`" || e.as_ref() == "`highest`")
&& let Some((last, last_pos)) = self.preceding(pos)
{
let drop_end = last_pos + last.len_utf8();
return Analysis::Diagnosed(
make_incomplete_drop(drop_end.saturating_sub(4), drop_end),
Repair::Fix
);
}
if !at_eof && (pos == 0 || self.skipped == Some((0, pos))) && is_goal
{
let ch = source[pos..].chars().next().unwrap_or('\0');
if "+-*×/÷%^".contains(ch)
{
return Analysis::Diagnosed(
make_missing_left_operand(source, ch, pos),
Repair::Fix
);
}
}
if at_eof && is_goal && self.preceding(pos).is_none()
{
let start = self.skipped.map_or(0, |(_, end)| end);
return Analysis::Diagnosed(
make_empty_expression(start, source.len()),
Repair::Fix
);
}
if site.site == Site::ParameterColon
&& expectations
.iter()
.any(|e| e.as_ref() == "`,`" || e.as_ref() == "`:`")
&& let Some(analysis) = self.incomplete_parameters(pos)
{
return analysis;
}
if expects_expression && !at_eof && is_goal
{
let ch = source[pos..].chars().next().unwrap_or('\0');
if ch == ')' || ch == ']' || ch == '}'
{
let end = pos + ch.len_utf8();
self.skip(pos, end);
return Analysis::Diagnosed(
make_unopened_delimiter(ch, pos),
Repair::Skip { end }
);
}
}
if site.site == Site::TrailingInput
&& expectations.iter().any(|e| e.as_ref() == "end of input")
&& !at_eof
{
self.trailing = true;
return Analysis::Diagnosed(self.trailing_input(pos), Repair::Fix);
}
let diagnostic = if at_eof
{
Diagnostic {
kind: DiagnosticKind::UnexpectedEof,
span: SourceSpan {
start: pos,
end: pos
},
message: "unexpected end of input".into(),
related: vec![],
suggestions: vec![]
}
}
else
{
let (start, end, token) = unexpected_token(source, pos);
Diagnostic {
kind: DiagnosticKind::UnexpectedToken,
span: SourceSpan { start, end },
message: format!("unexpected `{}`", token),
related: vec![],
suggestions: vec![]
}
};
Analysis::Diagnosed(diagnostic, Repair::Stop)
}
fn bare_identifier_at(
&mut self,
pos: usize,
place: Place
) -> Option<(Diagnostic, usize)>
{
let ident_end = self.bare_word_end(pos)?;
let name = self.source[pos..ident_end].trim_end_matches(is_token_space);
if name.is_empty()
{
return None;
}
let name_end = pos + name.len();
let mut suggestions = Vec::new();
let follows =
self.source[name_end..].trim_start_matches(is_token_space);
let what = match place
{
Place::Parameters => "parameter",
Place::Start if follows.starts_with([',', ':']) => "parameter",
_ if follows.starts_with('@') => "binding",
_ => "variable"
};
let split_pos = name.char_indices().find(|&(i, c)| {
what == "variable"
&& (c == 'd' || c == 'D')
&& i > 0
&& name[..i].starts_with(is_bare_word_start)
});
let prefix =
split_pos.map(|(i, _)| name[..i].trim_end_matches(is_token_space));
if let Some(prefix) = prefix
{
suggestions.push(Suggestion {
description: format!(
"wrap `{}` in braces",
canonical_name(prefix)
),
edits: vec![
insertion(pos, "{"),
insertion(pos + prefix.len(), "}"),
]
});
}
suggestions.push(Suggestion {
description: format!(
"use `{}` as a {} name",
canonical_name(name),
what
),
edits: vec![insertion(pos, "{"), insertion(name_end, "}")]
});
let bare_name = prefix.unwrap_or(name);
let end = pos + bare_name.len();
Some((
Diagnostic {
kind: DiagnosticKind::BareIdentifier,
span: SourceSpan {
start: pos,
end: name_end
},
message: format!(
"bare identifier `{}` is not valid here; \
{}s must be wrapped in `{{}}`",
canonical_name(bare_name),
what
),
related: vec![],
suggestions
},
end
))
}
fn incomplete_parameters(&self, pos: usize) -> Option<Analysis>
{
let source = self.source;
let start = self.skipped.map_or(0, |(_, end)| end);
let mut edits = self
.fixes
.iter()
.filter(|edit| start <= edit.span.start && edit.span.end <= pos)
.map(|edit| {
replacement(
edit.span.start - start,
edit.span.end - start,
&edit.replacement
)
})
.collect::<Vec<_>>();
edits.sort_by_key(|edit| (edit.span.start, edit.span.end));
let params = apply(&source[start..pos], &edits);
if params.trim_matches(is_token_space).is_empty()
{
return None;
}
let end =
start + source[start..pos].trim_end_matches(is_token_space).len();
let trailing = source[pos..].trim_start_matches(is_token_space);
let trailing_start = source.len() - trailing.len();
let trailing_works = !trailing.is_empty() && {
let mut candidate = params;
candidate.push_str(": ");
candidate.push_str(trailing);
Parser::parse(&candidate).is_ok()
};
let suggestion = if trailing_works
{
Suggestion {
description: "complete parameter definition".into(),
edits: vec![replacement(end, trailing_start, ": ")]
}
}
else
{
Suggestion {
description: "complete parameter definition".into(),
edits: vec![Edit {
span: SourceSpan {
start: end,
end: source.len()
},
replacement: ": 0".into(),
placeholders: vec![Placeholder {
span: SourceSpan { start: 2, end: 3 },
description: "expression",
valid_kinds: OPERAND_KINDS
}]
}]
}
};
let diagnostic = Diagnostic {
kind: DiagnosticKind::IncompleteParameterDefinition,
span: SourceSpan { start, end },
message: "expected `:` and expression body after parameters".into(),
related: vec![],
suggestions: vec![suggestion]
};
Some(
if trailing_works
{
Analysis::Diagnosed(diagnostic, Repair::Fix)
}
else
{
Analysis::Supplanting(diagnostic)
}
)
}
fn trailing_input(&self, pos: usize) -> Diagnostic
{
let source = self.source;
let kept = self.fixes.iter().map(|edit| edit.span.end).fold(
source[..pos].trim_end_matches(is_token_space).len(),
usize::max
);
Diagnostic {
kind: DiagnosticKind::TrailingInput,
span: SourceSpan {
start: pos,
end: source.len()
},
message: format!(
"unexpected `{}` after expression",
unexpected_token(source, pos).2
),
related: vec![],
suggestions: vec![Suggestion {
description: "remove trailing input".into(),
edits: vec![replacement(kept, source.len(), "")]
}]
}
}
}
impl<'src> Recovery<'src> for Doctor<'src>
{
fn repair(&mut self, site: &FailureSite<'src>) -> Repair
{
if self.state == State::Supplanted
{
return Repair::Stop;
}
if let Some(compound) = &mut self.compound
{
if compound.absorbs(site)
{
return Repair::Fix;
}
self.finish_compound();
}
if let Some(drop_pos) = self.reread.take()
&& site.site == Site::Faces
&& self
.preceding(site.position())
.is_some_and(|(_, operator)| operator == drop_pos)
{
return Repair::Reread;
}
match self.analyze(site)
{
Analysis::Diagnosed(diagnostic, repair) =>
{
if repair == Repair::Stop
{
self.state = State::Unfixable;
}
self.record(diagnostic);
repair
},
Analysis::Supplanting(diagnostic) =>
{
self.state = State::Supplanted;
self.record(diagnostic);
Repair::Fix
},
Analysis::Compound(compound) =>
{
self.compound = Some(compound);
Repair::Fix
}
}
}
}
fn apply(source: &str, edits: &[Edit]) -> String
{
let mut corrected = String::with_capacity(source.len());
let mut copied = 0;
for edit in edits
{
corrected.push_str(&source[copied..edit.span.start]);
corrected.push_str(&edit.replacement);
copied = edit.span.end;
}
corrected.push_str(&source[copied..]);
corrected
}
fn insertion(pos: usize, text: &str) -> Edit { replacement(pos, pos, text) }
fn replacement(start: usize, end: usize, text: &str) -> Edit
{
Edit {
span: SourceSpan { start, end },
replacement: text.to_string(),
placeholders: vec![]
}
}
fn unexpected_token(source: &str, pos: usize) -> (usize, usize, Cow<'_, str>)
{
let rest = source[pos..].trim_start_matches(is_token_space);
let start = source.len() - rest.len();
match rest.chars().next()
{
None => (start, start, Cow::Borrowed("")),
Some(c) if c.is_whitespace() =>
{
let token = format!("U+{:04X}", u32::from(c));
(start, start + c.len_utf8(), Cow::Owned(token))
},
Some(c) =>
{
let len = rest[c.len_utf8()..]
.find(char::is_whitespace)
.map_or(rest.len(), |i| c.len_utf8() + i);
(start, start + len, Cow::Borrowed(&rest[..len]))
}
}
}
fn stray_whitespace(source: &str) -> Vec<(usize, usize)>
{
let mut strays = Vec::new();
let mut at = 0;
while let Some(c) = source[at..].chars().next()
{
if c == '{'
{
let opened = at + 1;
let end = source[opened..]
.find(['{', '}'])
.map_or(source.len(), |i| opened + i);
at = if source[end..].starts_with('}')
{
end + 1
}
else
{
let name = source[opened..end].trim_start();
match unclosed_name_ends(name)
{
[Some(length), _] => end - name.len() + length,
_ => end
}
};
continue;
}
if c.is_whitespace() && !is_token_space(c)
{
strays.push((at, at + c.len_utf8()));
}
at += c.len_utf8();
}
strays
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum Shape
{
Group,
Range,
CustomFaces,
Variable,
RangeEnd
}
#[derive(Debug)]
struct Token
{
text: &'static str,
needed_at: fn(Site) -> bool,
placeholder: Option<(&'static str, &'static [&'static str])>
}
impl Token
{
const fn operand(description: &'static str) -> Self
{
Self {
text: "0",
needed_at: |site| matches!(site, Site::Goal { .. }),
placeholder: Some((description, OPERAND_KINDS))
}
}
const fn closer(text: &'static str) -> Self
{
Self {
text,
needed_at: |site| matches!(site, Site::Closer { .. }),
placeholder: None
}
}
}
impl Shape
{
fn tokens(self) -> &'static [Token]
{
const GROUP: &[Token] =
&[Token::operand("expression"), Token::closer(")")];
const RANGE: &[Token] = &[
Token::operand("range start"),
Token {
text: ":",
needed_at: |site| matches!(site, Site::Colon { .. }),
placeholder: None
},
Token::operand("range end"),
Token::closer("]")
];
const CUSTOM_FACES: &[Token] = &[
Token {
text: "0",
needed_at: |site| matches!(site, Site::FaceValue { .. }),
placeholder: Some(("face value", &["integer"]))
},
Token::closer("]")
];
const VARIABLE: &[Token] = &[
Token {
text: "x",
needed_at: |site| matches!(site, Site::VariableName { .. }),
placeholder: Some(("identifier", &["identifier"]))
},
Token::closer("}")
];
const RANGE_END: &[Token] =
&[Token::operand("range end"), Token::closer("]")];
match self
{
Shape::Group => GROUP,
Shape::Range => RANGE,
Shape::CustomFaces => CUSTOM_FACES,
Shape::Variable => VARIABLE,
Shape::RangeEnd => RANGE_END
}
}
fn what(self, supplied: usize) -> &'static str
{
match (self, supplied)
{
(Shape::Group, _) => "expression",
(Shape::Range, 1) => "range start",
(Shape::Range, _) => "range start and end",
(Shape::CustomFaces, _) => "face value",
(Shape::Variable, _) => "identifier",
(Shape::RangeEnd, _) => "range end"
}
}
}
#[derive(Debug)]
struct Compound
{
shape: Shape,
opener_pos: usize,
at: usize,
supplied: usize
}
impl Compound
{
fn new(shape: Shape, opener_pos: usize, at: usize) -> Self
{
Self {
shape,
opener_pos,
at,
supplied: 1
}
}
fn absorbs(&mut self, site: &FailureSite<'_>) -> bool
{
let absorbs = site.position() == self.at
&& self
.shape
.tokens()
.get(self.supplied)
.is_some_and(|token| (token.needed_at)(site.site));
if absorbs
{
self.supplied += 1;
}
absorbs
}
fn diagnostic(&self, source: &str) -> Diagnostic
{
let tokens = self.shape.tokens();
let mut replacement = String::new();
let mut placeholders = Vec::new();
for token in &tokens[..self.supplied]
{
if let Some((description, valid_kinds)) = token.placeholder
{
placeholders.push(Placeholder {
span: SourceSpan {
start: replacement.len(),
end: replacement.len() + token.text.len()
},
description,
valid_kinds
});
}
replacement.push_str(token.text);
}
let edits = vec![Edit {
span: SourceSpan {
start: self.at,
end: self.at
},
replacement,
placeholders
}];
let opener = source[self.opener_pos..].chars().next().unwrap_or('(');
let span = SourceSpan {
start: self.opener_pos,
end: self.opener_pos + opener.len_utf8()
};
if self.supplied == tokens.len()
{
let closer = tokens[tokens.len() - 1].text;
let description = match self.shape
{
Shape::RangeEnd => "insert range end and `]`".to_string(),
_ => format!("insert expression and `{}`", closer)
};
Diagnostic {
kind: DiagnosticKind::UnclosedDelimiter {
opener,
expected_closer: closer.chars().next().unwrap_or(')')
},
span,
message: format!("expected `{}` to close `{}`", closer, opener),
related: vec![],
suggestions: vec![Suggestion { description, edits }]
}
}
else
{
let what = self.shape.what(self.supplied);
let next = source[self.at..].chars().next().unwrap_or(' ');
Diagnostic {
kind: DiagnosticKind::MissingExpression { opener },
span,
message: format!("expected {} before `{}`", what, next),
related: vec![],
suggestions: vec![Suggestion {
description: format!("insert {}", what),
edits
}]
}
}
}
}
fn make_unclosed_delimiter(
opener: char,
closer: char,
opener_pos: usize,
error_pos: usize
) -> Diagnostic
{
Diagnostic {
kind: DiagnosticKind::UnclosedDelimiter {
opener,
expected_closer: closer
},
span: SourceSpan {
start: opener_pos,
end: opener_pos + opener.len_utf8()
},
message: format!("expected `{}` to close `{}`", closer, opener),
related: vec![],
suggestions: vec![Suggestion {
description: format!("insert `{}`", closer),
edits: vec![insertion(error_pos, &closer.to_string())]
}]
}
}
fn make_unclosed_brace(
source: &str,
opener_pos: usize,
ends: &[usize],
error_pos: usize,
at_eof: bool
) -> Diagnostic
{
let mut diagnostic =
make_unclosed_delimiter('{', '}', opener_pos, error_pos);
let whole = diagnostic.suggestions.pop();
diagnostic.suggestions = ends
.iter()
.enumerate()
.filter_map(|(i, &end)| {
let mut after =
source[end..].trim_start_matches(is_token_space).chars();
let before = after.next().unwrap_or(' ');
let bare = after
.as_str()
.trim_start_matches(is_token_space)
.starts_with(is_bare_word_start);
(i == 0 || !bare).then(|| Suggestion {
description: format!("insert `}}` before `{}`", before),
edits: vec![insertion(end, "}")]
})
})
.collect();
if ends.is_empty()
|| at_eof && !swallows_closer(&source[opener_pos + 1..error_pos])
{
diagnostic.suggestions.extend(whole);
}
diagnostic
}
fn swallows_closer(name: &str) -> bool
{
let (mut parentheses, mut brackets) = (0usize, 0usize);
for c in name.chars()
{
let depth = match c
{
'(' | ')' => &mut parentheses,
'[' | ']' => &mut brackets,
_ => continue
};
if matches!(c, '(' | '[')
{
*depth += 1;
}
else if *depth == 0
{
return true;
}
else
{
*depth -= 1;
}
}
false
}
fn make_missing_right_operand(
source: &str,
operator: char,
op_pos: usize,
error_pos: usize
) -> Diagnostic
{
let before = &source[..error_pos];
let after = &source[error_pos..];
let mut operand = String::new();
if !before.is_empty() && !before.ends_with(is_token_space)
{
operand.push(' ');
}
let placeholder_start = operand.len();
operand.push('0');
let placeholder_end = operand.len();
if !after.is_empty()
&& !after.starts_with(is_token_space)
&& !after.starts_with([')', ']', '}', ',', ':'])
{
operand.push(' ');
}
let before_op = source[..op_pos].trim_end_matches(is_token_space);
let rest = after.trim_start_matches(is_token_space);
let separator = if !after.is_empty()
&& !before_op.is_empty()
&& !rest.starts_with([')', ']', '}', ',', ':'])
{
" "
}
else
{
""
};
Diagnostic {
kind: DiagnosticKind::MissingRightOperand { operator },
span: SourceSpan {
start: op_pos,
end: error_pos
},
message: format!("expected operand after `{}`", operator),
related: vec![],
suggestions: vec![
Suggestion {
description: format!("insert operand after `{}`", operator),
edits: vec![Edit {
span: SourceSpan {
start: error_pos,
end: error_pos
},
replacement: operand,
placeholders: vec![Placeholder {
span: SourceSpan {
start: placeholder_start,
end: placeholder_end
},
description: "operand",
valid_kinds: OPERAND_KINDS
}]
}]
},
Suggestion {
description: format!("remove `{}`", operator),
edits: vec![replacement(
before_op.len(),
source.len() - rest.len(),
separator
)]
},
]
}
}
fn make_missing_left_operand(
source: &str,
operator: char,
op_pos: usize
) -> Diagnostic
{
let op_end = op_pos + operator.len_utf8();
let after_op = source[op_end..].trim_start_matches(is_token_space);
Diagnostic {
kind: DiagnosticKind::MissingLeftOperand { operator },
span: SourceSpan {
start: op_pos,
end: op_end
},
message: format!("expected operand before `{}`", operator),
related: vec![],
suggestions: vec![
Suggestion {
description: format!("insert operand before `{}`", operator),
edits: vec![Edit {
span: SourceSpan {
start: op_pos,
end: op_pos
},
replacement: "0 ".into(),
placeholders: vec![Placeholder {
span: SourceSpan { start: 0, end: 1 },
description: "operand",
valid_kinds: OPERAND_KINDS
}]
}]
},
Suggestion {
description: format!("remove `{}`", operator),
edits: vec![replacement(
op_pos,
source.len() - after_op.len(),
""
)]
},
]
}
}
fn make_missing_parameter(comma: usize, next: usize) -> Diagnostic
{
Diagnostic {
kind: DiagnosticKind::MissingParameter,
span: SourceSpan {
start: comma,
end: comma + 1
},
message: "expected a formal parameter after `,`".into(),
related: vec![],
suggestions: vec![Suggestion {
description: "remove `,`".into(),
edits: vec![replacement(comma, next, "")]
}]
}
}
fn make_missing_dice_faces(source: &str, d_pos: usize) -> Diagnostic
{
let insert_pos = d_pos + 1;
Diagnostic {
kind: DiagnosticKind::MissingDiceFaces,
span: SourceSpan {
start: d_pos,
end: d_pos + 1
},
message: format!(
"expected face count after `{}`",
&source[d_pos..d_pos + 1]
),
related: vec![],
suggestions: vec![Suggestion {
description: "insert face count".into(),
edits: vec![Edit {
span: SourceSpan {
start: insert_pos,
end: insert_pos
},
replacement: "6".into(),
placeholders: vec![Placeholder {
span: SourceSpan { start: 0, end: 1 },
description: "face count",
valid_kinds: FACE_COUNT_KINDS
}]
}]
}]
}
}
fn begins_drop(text: &str) -> bool
{
text.strip_prefix("drop").is_some_and(|after| {
after.starts_with("lowest")
|| after.starts_with("highest")
|| after
.chars()
.next()
.is_none_or(|c| c.is_whitespace() || !is_bare_word_continue(c))
})
}
fn make_misplaced_drop(
source: &str,
drop_pos: usize,
after_operand: bool
) -> Diagnostic
{
let edit = if after_operand
{
let operand_end =
source[..drop_pos].trim_end_matches(is_token_space).len();
Edit {
span: SourceSpan {
start: operand_end,
end: operand_end
},
replacement: if operand_end == drop_pos { "D6 " } else { "D6" }
.into(),
placeholders: vec![Placeholder {
span: SourceSpan { start: 1, end: 2 },
description: "face count",
valid_kinds: FACE_COUNT_KINDS
}]
}
}
else
{
let separator = if source[..drop_pos].ends_with('-')
{
" "
}
else
{
""
};
Edit {
span: SourceSpan {
start: drop_pos,
end: drop_pos
},
replacement: format!("{}1D6 ", separator),
placeholders: vec![Placeholder {
span: SourceSpan {
start: separator.len(),
end: separator.len() + 3
},
description: "dice expression",
valid_kinds: DICE_KINDS
}]
}
};
Diagnostic {
kind: DiagnosticKind::MisplacedDropClause,
span: SourceSpan {
start: drop_pos,
end: drop_pos + "drop".len()
},
message: "`drop` must follow a dice expression".into(),
related: vec![],
suggestions: vec![Suggestion {
description: if after_operand
{
"insert dice faces before `drop`"
}
else
{
"insert a dice expression before `drop`"
}
.into(),
edits: vec![edit]
}]
}
}
fn make_incomplete_drop(drop_start: usize, drop_end: usize) -> Diagnostic
{
Diagnostic {
kind: DiagnosticKind::IncompleteDropClause,
span: SourceSpan {
start: drop_start,
end: drop_end
},
message: "expected `lowest` or `highest` after `drop`".into(),
related: vec![],
suggestions: vec![Suggestion {
description: "insert drop direction".into(),
edits: vec![Edit {
span: SourceSpan {
start: drop_end,
end: drop_end
},
replacement: " lowest".into(),
placeholders: vec![Placeholder {
span: SourceSpan { start: 1, end: 7 },
description: "direction",
valid_kinds: DROP_DIRECTION_KINDS
}]
}]
}]
}
}
fn make_unopened_delimiter(closer: char, pos: usize) -> Diagnostic
{
Diagnostic {
kind: DiagnosticKind::UnopenedDelimiter { closer },
span: SourceSpan {
start: pos,
end: pos + closer.len_utf8()
},
message: format!("unexpected `{}` with no matching opener", closer),
related: vec![],
suggestions: vec![Suggestion {
description: format!("remove the unopened `{}`", closer),
edits: vec![replacement(pos, pos + closer.len_utf8(), "")]
}]
}
}
fn make_stray_whitespace(source: &str, start: usize, end: usize) -> Diagnostic
{
let (_, _, token) = unexpected_token(source, start);
Diagnostic {
kind: DiagnosticKind::UnexpectedToken,
span: SourceSpan { start, end },
message: format!(
"unexpected `{}`; only spaces, tabs, and line breaks may separate \
tokens",
token
),
related: vec![],
suggestions: vec![Suggestion {
description: format!("replace `{}` with a space", token),
edits: vec![replacement(start, end, " ")]
}]
}
}
fn make_empty_expression(start: usize, end: usize) -> Diagnostic
{
Diagnostic {
kind: DiagnosticKind::EmptyExpression,
span: SourceSpan { start: 0, end: 0 },
message: "expected expression".into(),
related: vec![],
suggestions: vec![Suggestion {
description: "insert expression".into(),
edits: vec![Edit {
span: SourceSpan { start, end },
replacement: "0".into(),
placeholders: vec![Placeholder {
span: SourceSpan { start: 0, end: 1 },
description: "expression",
valid_kinds: OPERAND_KINDS
}]
}]
}]
}
}
pub(crate) fn run_validator<'src>(ast: &Function<'src>) -> Vec<Diagnostic>
{
match Validator::validate(ast)
{
Ok(()) => Vec::new(),
Err(error) => vec![analyze_semantic_error(ast, error)]
}
}
fn analyze_semantic_error<'src>(
ast: &Function<'src>,
error: CompilationError<'src>
) -> Diagnostic
{
match error
{
CompilationError::DuplicateParameter {
name,
first,
duplicate
} => make_duplicate_parameter(ast, &name, first, duplicate),
CompilationError::BindingCollidesWithParameter {
name,
parameter,
binding
} =>
{
make_binding_collides_with_parameter(ast, &name, parameter, binding)
},
CompilationError::DuplicateBinding {
name,
first,
duplicate
} => make_duplicate_binding(ast, &name, first, duplicate),
CompilationError::UseBeforeBind {
name,
reference,
binding
} => make_use_before_bind(ast, &name, reference, binding),
CompilationError::ParseError(_)
| CompilationError::OptimizationFailed =>
{
unreachable!(
"Validator::validate produces only semantic errors; \
ParseError is produced by the parser and OptimizationFailed \
by the optimizer, neither of which is reached from \
diagnose() at this point"
)
}
}
}
fn make_binding_collides_with_parameter<'src>(
ast: &Function<'src>,
name: &str,
parameter: SourceSpan,
binding: SourceSpan
) -> Diagnostic
{
let used = collect_in_use_names(ast);
let fresh = suggest_rename_with_pool(name, &used);
Diagnostic {
kind: DiagnosticKind::BindingCollidesWithParameter {
name: name.to_string()
},
span: binding,
message: format!(
"local binding `{}` collides with a formal parameter of the same \
name; bindings, parameters, and environment variables share one \
flat namespace per function",
name
),
related: vec![RelatedLabel {
span: parameter,
message: "declared as a parameter here".into()
}],
suggestions: vec![Suggestion {
description: format!("rename local binding to `{}`", fresh),
edits: vec![replacement(binding.start, binding.end, &fresh)]
}]
}
}
fn make_duplicate_binding<'src>(
ast: &Function<'src>,
name: &str,
first: SourceSpan,
duplicate: SourceSpan
) -> Diagnostic
{
let used = collect_in_use_names(ast);
let fresh = suggest_rename_with_pool(name, &used);
Diagnostic {
kind: DiagnosticKind::DuplicateBinding {
name: name.to_string()
},
span: duplicate,
message: format!(
"local binding `{}` is bound more than once; a function body \
provides a single flat namespace, so rebinding is not permitted",
name
),
related: vec![RelatedLabel {
span: first,
message: "first bound here".into()
}],
suggestions: vec![Suggestion {
description: format!("rename duplicate binding to `{}`", fresh),
edits: vec![replacement(duplicate.start, duplicate.end, &fresh)]
}]
}
}
fn make_use_before_bind<'src>(
ast: &Function<'src>,
name: &str,
reference: SourceSpan,
binding: SourceSpan
) -> Diagnostic
{
let used = collect_in_use_names(ast);
let fresh = suggest_rename_with_pool(name, &used);
Diagnostic {
kind: DiagnosticKind::UseBeforeBind {
name: name.to_string()
},
span: reference,
message: format!(
"reference to `{}` precedes its binding; references to a local \
binding must lexically follow the binding site, including \
references inside the bound expression itself (self-reference \
is not permitted)",
name
),
related: vec![RelatedLabel {
span: binding,
message: "bound here".into()
}],
suggestions: vec![Suggestion {
description: format!("rename local binding to `{}`", fresh),
edits: vec![replacement(binding.start, binding.end, &fresh)]
}]
}
}
pub(crate) fn collect_in_use_names<'a>(
ast: &'a Function<'_>
) -> HashSet<&'a str>
{
let mut names: HashSet<&'a str> = HashSet::new();
if let Some(ref parameters) = ast.parameters
{
for param in parameters
{
names.insert(¶m.name);
}
}
for event in Walk::new(Node::Expression(&ast.body))
{
match event
{
Event::Enter(Node::Expression(Expression::Variable(v))) =>
{
names.insert(&v.name);
},
Event::Enter(Node::Expression(Expression::Binding(b))) =>
{
names.insert(&b.name);
},
_ =>
{}
}
}
names
}
fn make_duplicate_parameter<'src>(
ast: &Function<'src>,
name: &str,
first: SourceSpan,
duplicate: SourceSpan
) -> Diagnostic
{
let parameters = ast.parameters.as_deref().unwrap_or(&[]);
let fresh = suggest_rename(name, parameters);
Diagnostic {
kind: DiagnosticKind::DuplicateParameter {
name: name.to_string()
},
span: duplicate,
message: format!(
"parameter `{}` is declared more than once; review references \
to `{}` in the body — one may have meant a different parameter \
or an external variable",
name, name
),
related: vec![RelatedLabel {
span: first,
message: "first declared here".into()
}],
suggestions: vec![Suggestion {
description: format!("rename duplicate parameter to `{}`", fresh),
edits: vec![replacement(duplicate.start, duplicate.end, &fresh)]
}]
}
}
fn suggest_rename(duplicate: &str, parameters: &[Parameter<'_>]) -> String
{
let used: HashSet<&str> = parameters.iter().map(|p| &*p.name).collect();
suggest_rename_with_pool(duplicate, &used)
}
fn suggest_rename_with_pool(duplicate: &str, used: &HashSet<&str>) -> String
{
let mut chars = duplicate.chars();
if let (Some(c), None) = (chars.next(), chars.next())
&& c.is_ascii_alphabetic()
{
let start = if c.is_ascii_uppercase() { b'A' } else { b'a' };
let highest = used
.iter()
.filter_map(|n| {
let mut cs = n.chars();
match (cs.next(), cs.next())
{
(Some(ch), None)
if ch.is_ascii_alphabetic()
&& ch.is_ascii_uppercase()
== c.is_ascii_uppercase() =>
{
Some(ch as u8)
},
_ => None
}
})
.max()
.unwrap_or(c as u8);
for offset in 1u8..26
{
let code = start + ((highest - start + offset) % 26);
let candidate = (code as char).to_string();
if !used.contains(candidate.as_str())
{
return candidate;
}
}
}
for i in 0usize..
{
let candidate = format!("new{}", i);
if !used.contains(candidate.as_str())
{
return candidate;
}
}
unreachable!("cannot exhaust usize worth of `newN` candidates")
}
#[cfg_attr(doc, aquamarine::aquamarine)]
pub fn diagnose(source: &str) -> DiagnoseResult
{
if let Ok(ast) = Parser::parse(source)
{
return DiagnoseResult {
diagnostics: run_validator(&ast),
corrected_source: Some(source.to_string())
};
}
let strays = stray_whitespace(source);
let mut text = source.as_bytes().to_vec();
for &(start, end) in &strays
{
text[start..end].fill(b' ');
}
let text = String::from_utf8(text)
.expect("replacing whole characters with spaces preserves UTF-8");
let mut doctor = Doctor::new(&text, source, strays);
let recovered = Parser::parse_recovering(&text, &mut doctor).is_ok();
doctor.finish(recovered)
}