use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiagnosticCode {
UndefinedRole,
DuplicateRole,
RoleIndexOutOfBounds,
InvalidRoleParam,
SelfCommunication,
UndefinedMessage,
DuplicateMessage,
MessageTypeMismatch,
InvalidMessageFormat,
SyntaxError,
MissingElement,
UnexpectedToken,
InvalidIdentifier,
EmptyProtocol,
UnreachableCode,
InfiniteLoop,
EmptyChoice,
DuplicateBranch,
InvalidAnnotationKey,
InvalidAnnotationValue,
ConflictingAnnotations,
ChoicePropagationError,
IndistinguishableChoiceBranches,
}
impl DiagnosticCode {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::UndefinedRole => "R001",
Self::DuplicateRole => "R002",
Self::RoleIndexOutOfBounds => "R003",
Self::InvalidRoleParam => "R004",
Self::SelfCommunication => "R005",
Self::UndefinedMessage => "M001",
Self::DuplicateMessage => "M002",
Self::MessageTypeMismatch => "M003",
Self::InvalidMessageFormat => "M004",
Self::SyntaxError => "S001",
Self::MissingElement => "S002",
Self::UnexpectedToken => "S003",
Self::InvalidIdentifier => "S004",
Self::EmptyProtocol => "P001",
Self::UnreachableCode => "P002",
Self::InfiniteLoop => "P003",
Self::EmptyChoice => "P004",
Self::DuplicateBranch => "P005",
Self::InvalidAnnotationKey => "A001",
Self::InvalidAnnotationValue => "A002",
Self::ConflictingAnnotations => "A003",
Self::ChoicePropagationError => "C001",
Self::IndistinguishableChoiceBranches => "C002",
}
}
#[must_use]
pub fn doc_url(&self) -> String {
format!("https://telltale.dev/errors/{}", self.code().to_lowercase())
}
}
impl fmt::Display for DiagnosticCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.code())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Note,
Warning,
Error,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Note => write!(f, "note"),
Self::Warning => write!(f, "warning"),
Self::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone)]
pub struct SourceLocation {
pub line: usize,
pub column: usize,
pub end_line: usize,
pub end_column: usize,
pub source_line: String,
pub file: Option<String>,
}
impl SourceLocation {
pub fn new(line: usize, column: usize, source_line: impl Into<String>) -> Self {
Self {
line,
column,
end_line: line,
end_column: column + 1,
source_line: source_line.into(),
file: None,
}
}
pub fn with_end(mut self, end_line: usize, end_column: usize) -> Self {
self.end_line = end_line;
self.end_column = end_column;
self
}
pub fn with_file(mut self, file: impl Into<String>) -> Self {
self.file = Some(file.into());
self
}
}
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub code: DiagnosticCode,
pub severity: Severity,
pub message: String,
pub location: Option<SourceLocation>,
pub suggestions: Vec<String>,
pub notes: Vec<String>,
pub related: Vec<RelatedInfo>,
}
#[derive(Debug, Clone)]
pub struct RelatedInfo {
pub location: SourceLocation,
pub message: String,
}
impl Diagnostic {
pub fn new(code: DiagnosticCode, severity: Severity, message: impl Into<String>) -> Self {
Self {
code,
severity,
message: message.into(),
location: None,
suggestions: Vec::new(),
notes: Vec::new(),
related: Vec::new(),
}
}
pub fn error(code: DiagnosticCode, message: impl Into<String>) -> Self {
Self::new(code, Severity::Error, message)
}
pub fn warning(code: DiagnosticCode, message: impl Into<String>) -> Self {
Self::new(code, Severity::Warning, message)
}
pub fn with_location(mut self, location: SourceLocation) -> Self {
self.location = Some(location);
self
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
self.suggestions.push(suggestion.into());
self
}
pub fn with_note(mut self, note: impl Into<String>) -> Self {
self.notes.push(note.into());
self
}
pub fn with_related(mut self, location: SourceLocation, message: impl Into<String>) -> Self {
self.related.push(RelatedInfo {
location,
message: message.into(),
});
self
}
#[must_use]
pub fn format(&self) -> String {
let mut output = String::new();
output.push_str(&format!(
"{}[{}]: {}\n",
self.severity, self.code, self.message
));
if let Some(loc) = &self.location {
let file = loc.file.as_deref().unwrap_or("input");
output.push_str(&format!(" --> {}:{}:{}\n", file, loc.line, loc.column));
let line_num_width = loc.line.to_string().len().max(3);
output.push_str(&format!("{:width$} |\n", " ", width = line_num_width));
output.push_str(&format!(
"{:>width$} | {}\n",
loc.line,
loc.source_line,
width = line_num_width
));
let spaces = " ".repeat(line_num_width + 3 + loc.column - 1);
let underline_len = if loc.line == loc.end_line {
(loc.end_column - loc.column).max(1)
} else {
loc.source_line.len().saturating_sub(loc.column) + 1
};
let underline = "^".repeat(underline_len);
output.push_str(&format!("{spaces}{underline}\n"));
}
for suggestion in &self.suggestions {
output.push_str(&format!(" = help: {suggestion}\n"));
}
for note in &self.notes {
output.push_str(&format!(" = note: {note}\n"));
}
for related in &self.related {
let file = related.location.file.as_deref().unwrap_or("input");
output.push_str(&format!(
" --> {}:{}:{}: {}\n",
file, related.location.line, related.location.column, related.message
));
}
output
}
}
impl fmt::Display for Diagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.format())
}
}
#[derive(Debug, Default)]
pub struct DiagnosticCollector {
diagnostics: Vec<Diagnostic>,
}
impl DiagnosticCollector {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, diagnostic: Diagnostic) {
self.diagnostics.push(diagnostic);
}
pub fn error(&mut self, code: DiagnosticCode, message: impl Into<String>) {
self.add(Diagnostic::error(code, message));
}
pub fn warning(&mut self, code: DiagnosticCode, message: impl Into<String>) {
self.add(Diagnostic::warning(code, message));
}
#[must_use]
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
}
#[must_use]
pub fn error_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == Severity::Error)
.count()
}
#[must_use]
pub fn warning_count(&self) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == Severity::Warning)
.count()
}
#[must_use]
pub fn diagnostics(&self) -> &[Diagnostic] {
&self.diagnostics
}
pub fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
#[must_use]
pub fn format_all(&self) -> String {
let mut output = String::new();
for diagnostic in &self.diagnostics {
output.push_str(&diagnostic.format());
output.push('\n');
}
let errors = self.error_count();
let warnings = self.warning_count();
if errors > 0 || warnings > 0 {
output.push_str(&format!(
"{}: {} error{}, {} warning{}\n",
if errors > 0 { "aborting" } else { "finished" },
errors,
if errors == 1 { "" } else { "s" },
warnings,
if warnings == 1 { "" } else { "s" }
));
}
output
}
}
pub fn validate_roles(
referenced_roles: &[(&str, Option<SourceLocation>)],
declared_roles: &HashSet<String>,
collector: &mut DiagnosticCollector,
) {
for (role, location) in referenced_roles {
if !declared_roles.contains(*role) {
let available: Vec<_> = declared_roles.iter().cloned().collect();
let mut diagnostic = Diagnostic::error(
DiagnosticCode::UndefinedRole,
format!("Undefined role '{role}'"),
);
if let Some(loc) = location {
diagnostic = diagnostic.with_location(loc.clone());
}
let similar = find_similar_strings(role, &available);
if !similar.is_empty() {
diagnostic = diagnostic.with_suggestion(format!("Did you mean '{}'?", similar[0]));
}
diagnostic = diagnostic
.with_suggestion(format!("Add '{role}' to the roles declaration"))
.with_note(format!("Available roles: {}", available.join(", ")));
collector.add(diagnostic);
}
}
}
pub fn check_self_communication(
from: &str,
to: &str,
location: Option<SourceLocation>,
collector: &mut DiagnosticCollector,
) {
if from == to {
let mut diagnostic = Diagnostic::warning(
DiagnosticCode::SelfCommunication,
format!("Role '{from}' sends message to itself"),
);
if let Some(loc) = location {
diagnostic = diagnostic.with_location(loc);
}
diagnostic = diagnostic
.with_note("Self-communication is usually a protocol design error")
.with_suggestion("Consider splitting into separate roles if this is intentional");
collector.add(diagnostic);
}
}
fn find_similar_strings(target: &str, candidates: &[String]) -> Vec<String> {
let target_lower = target.to_lowercase();
let mut similar: Vec<_> = candidates
.iter()
.filter_map(|s| {
let distance = levenshtein_distance(&target_lower, &s.to_lowercase());
if distance <= 2 {
Some((s.clone(), distance))
} else {
None
}
})
.collect();
similar.sort_by_key(|(_, d)| *d);
similar.into_iter().map(|(s, _)| s).collect()
}
#[allow(clippy::needless_range_loop)]
fn levenshtein_distance(s1: &str, s2: &str) -> usize {
let s1_chars: Vec<char> = s1.chars().collect();
let s2_chars: Vec<char> = s2.chars().collect();
let m = s1_chars.len();
let n = s2_chars.len();
if m == 0 {
return n;
}
if n == 0 {
return m;
}
let mut dp = vec![vec![0usize; n + 1]; m + 1];
for i in 0..=m {
dp[i][0] = i;
}
for j in 0..=n {
dp[0][j] = j;
}
for i in 1..=m {
for j in 1..=n {
let cost = if s1_chars[i - 1] == s2_chars[j - 1] {
0
} else {
1
};
dp[i][j] = (dp[i - 1][j] + 1)
.min(dp[i][j - 1] + 1)
.min(dp[i - 1][j - 1] + cost);
}
}
dp[m][n]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_diagnostic_code_display() {
assert_eq!(DiagnosticCode::UndefinedRole.code(), "R001");
assert_eq!(DiagnosticCode::DuplicateMessage.code(), "M002");
assert_eq!(DiagnosticCode::SyntaxError.code(), "S001");
}
#[test]
fn test_diagnostic_format() {
let diagnostic = Diagnostic::error(DiagnosticCode::UndefinedRole, "Undefined role 'Bob'")
.with_location(SourceLocation::new(5, 10, "Alice -> Bob: Request;").with_end(5, 13))
.with_suggestion("Add 'Bob' to the roles declaration")
.with_note("Available roles: Alice, Server");
let formatted = diagnostic.format();
assert!(formatted.contains("error[R001]"));
assert!(formatted.contains("Undefined role 'Bob'"));
assert!(formatted.contains("help:"));
assert!(formatted.contains("note:"));
}
#[test]
fn test_levenshtein_distance() {
assert_eq!(levenshtein_distance("hello", "hello"), 0);
assert_eq!(levenshtein_distance("hello", "helo"), 1);
assert_eq!(levenshtein_distance("hello", "world"), 4);
assert_eq!(levenshtein_distance("", "abc"), 3);
}
#[test]
fn test_find_similar_strings() {
let candidates = vec![
"Alice".to_string(),
"Bob".to_string(),
"Charlie".to_string(),
];
let similar = find_similar_strings("Alic", &candidates);
assert_eq!(similar, vec!["Alice"]);
}
#[test]
fn test_collector() {
let mut collector = DiagnosticCollector::new();
collector.error(DiagnosticCode::UndefinedRole, "Test error");
collector.warning(DiagnosticCode::SelfCommunication, "Test warning");
assert!(collector.has_errors());
assert_eq!(collector.error_count(), 1);
assert_eq!(collector.warning_count(), 1);
}
}