1use std::collections::HashSet;
31use std::fmt;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum DiagnosticCode {
38 UndefinedRole,
41 DuplicateRole,
43 RoleIndexOutOfBounds,
45 InvalidRoleParam,
47 SelfCommunication,
49
50 UndefinedMessage,
53 DuplicateMessage,
55 MessageTypeMismatch,
57 InvalidMessageFormat,
59
60 SyntaxError,
63 MissingElement,
65 UnexpectedToken,
67 InvalidIdentifier,
69
70 EmptyProtocol,
73 UnreachableCode,
75 InfiniteLoop,
77 EmptyChoice,
79 DuplicateBranch,
81
82 InvalidAnnotationKey,
85 InvalidAnnotationValue,
87 ConflictingAnnotations,
89
90 ChoicePropagationError,
93 IndistinguishableChoiceBranches,
95}
96
97impl DiagnosticCode {
98 #[must_use]
100 pub fn code(&self) -> &'static str {
101 match self {
102 Self::UndefinedRole => "R001",
104 Self::DuplicateRole => "R002",
105 Self::RoleIndexOutOfBounds => "R003",
106 Self::InvalidRoleParam => "R004",
107 Self::SelfCommunication => "R005",
108
109 Self::UndefinedMessage => "M001",
111 Self::DuplicateMessage => "M002",
112 Self::MessageTypeMismatch => "M003",
113 Self::InvalidMessageFormat => "M004",
114
115 Self::SyntaxError => "S001",
117 Self::MissingElement => "S002",
118 Self::UnexpectedToken => "S003",
119 Self::InvalidIdentifier => "S004",
120
121 Self::EmptyProtocol => "P001",
123 Self::UnreachableCode => "P002",
124 Self::InfiniteLoop => "P003",
125 Self::EmptyChoice => "P004",
126 Self::DuplicateBranch => "P005",
127
128 Self::InvalidAnnotationKey => "A001",
130 Self::InvalidAnnotationValue => "A002",
131 Self::ConflictingAnnotations => "A003",
132
133 Self::ChoicePropagationError => "C001",
135 Self::IndistinguishableChoiceBranches => "C002",
136 }
137 }
138
139 #[must_use]
141 pub fn doc_url(&self) -> String {
142 format!("https://telltale.dev/errors/{}", self.code().to_lowercase())
143 }
144}
145
146impl fmt::Display for DiagnosticCode {
147 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148 write!(f, "{}", self.code())
149 }
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
154pub enum Severity {
155 Note,
157 Warning,
159 Error,
161}
162
163impl fmt::Display for Severity {
164 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165 match self {
166 Self::Note => write!(f, "note"),
167 Self::Warning => write!(f, "warning"),
168 Self::Error => write!(f, "error"),
169 }
170 }
171}
172
173#[derive(Debug, Clone)]
175pub struct SourceLocation {
176 pub line: usize,
178 pub column: usize,
180 pub end_line: usize,
182 pub end_column: usize,
184 pub source_line: String,
186 pub file: Option<String>,
188}
189
190impl SourceLocation {
191 pub fn new(line: usize, column: usize, source_line: impl Into<String>) -> Self {
193 Self {
194 line,
195 column,
196 end_line: line,
197 end_column: column + 1,
198 source_line: source_line.into(),
199 file: None,
200 }
201 }
202
203 pub fn with_end(mut self, end_line: usize, end_column: usize) -> Self {
205 self.end_line = end_line;
206 self.end_column = end_column;
207 self
208 }
209
210 pub fn with_file(mut self, file: impl Into<String>) -> Self {
212 self.file = Some(file.into());
213 self
214 }
215}
216
217#[derive(Debug, Clone)]
219pub struct Diagnostic {
220 pub code: DiagnosticCode,
222 pub severity: Severity,
224 pub message: String,
226 pub location: Option<SourceLocation>,
228 pub suggestions: Vec<String>,
230 pub notes: Vec<String>,
232 pub related: Vec<RelatedInfo>,
234}
235
236#[derive(Debug, Clone)]
238pub struct RelatedInfo {
239 pub location: SourceLocation,
241 pub message: String,
243}
244
245impl Diagnostic {
246 pub fn new(code: DiagnosticCode, severity: Severity, message: impl Into<String>) -> Self {
248 Self {
249 code,
250 severity,
251 message: message.into(),
252 location: None,
253 suggestions: Vec::new(),
254 notes: Vec::new(),
255 related: Vec::new(),
256 }
257 }
258
259 pub fn error(code: DiagnosticCode, message: impl Into<String>) -> Self {
261 Self::new(code, Severity::Error, message)
262 }
263
264 pub fn warning(code: DiagnosticCode, message: impl Into<String>) -> Self {
266 Self::new(code, Severity::Warning, message)
267 }
268
269 pub fn with_location(mut self, location: SourceLocation) -> Self {
271 self.location = Some(location);
272 self
273 }
274
275 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
277 self.suggestions.push(suggestion.into());
278 self
279 }
280
281 pub fn with_note(mut self, note: impl Into<String>) -> Self {
283 self.notes.push(note.into());
284 self
285 }
286
287 pub fn with_related(mut self, location: SourceLocation, message: impl Into<String>) -> Self {
289 self.related.push(RelatedInfo {
290 location,
291 message: message.into(),
292 });
293 self
294 }
295
296 #[must_use]
298 pub fn format(&self) -> String {
299 let mut output = String::new();
300
301 output.push_str(&format!(
303 "{}[{}]: {}\n",
304 self.severity, self.code, self.message
305 ));
306
307 if let Some(loc) = &self.location {
309 let file = loc.file.as_deref().unwrap_or("input");
310 output.push_str(&format!(" --> {}:{}:{}\n", file, loc.line, loc.column));
311
312 let line_num_width = loc.line.to_string().len().max(3);
314 output.push_str(&format!("{:width$} |\n", " ", width = line_num_width));
315 output.push_str(&format!(
316 "{:>width$} | {}\n",
317 loc.line,
318 loc.source_line,
319 width = line_num_width
320 ));
321
322 let spaces = " ".repeat(line_num_width + 3 + loc.column - 1);
324 let underline_len = if loc.line == loc.end_line {
325 (loc.end_column - loc.column).max(1)
326 } else {
327 loc.source_line.len().saturating_sub(loc.column) + 1
328 };
329 let underline = "^".repeat(underline_len);
330 output.push_str(&format!("{spaces}{underline}\n"));
331 }
332
333 for suggestion in &self.suggestions {
335 output.push_str(&format!(" = help: {suggestion}\n"));
336 }
337
338 for note in &self.notes {
340 output.push_str(&format!(" = note: {note}\n"));
341 }
342
343 for related in &self.related {
345 let file = related.location.file.as_deref().unwrap_or("input");
346 output.push_str(&format!(
347 " --> {}:{}:{}: {}\n",
348 file, related.location.line, related.location.column, related.message
349 ));
350 }
351
352 output
353 }
354}
355
356impl fmt::Display for Diagnostic {
357 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
358 write!(f, "{}", self.format())
359 }
360}
361
362#[derive(Debug, Default)]
364pub struct DiagnosticCollector {
365 diagnostics: Vec<Diagnostic>,
366}
367
368impl DiagnosticCollector {
369 pub fn new() -> Self {
371 Self::default()
372 }
373
374 pub fn add(&mut self, diagnostic: Diagnostic) {
376 self.diagnostics.push(diagnostic);
377 }
378
379 pub fn error(&mut self, code: DiagnosticCode, message: impl Into<String>) {
381 self.add(Diagnostic::error(code, message));
382 }
383
384 pub fn warning(&mut self, code: DiagnosticCode, message: impl Into<String>) {
386 self.add(Diagnostic::warning(code, message));
387 }
388
389 #[must_use]
391 pub fn has_errors(&self) -> bool {
392 self.diagnostics
393 .iter()
394 .any(|d| d.severity == Severity::Error)
395 }
396
397 #[must_use]
399 pub fn error_count(&self) -> usize {
400 self.diagnostics
401 .iter()
402 .filter(|d| d.severity == Severity::Error)
403 .count()
404 }
405
406 #[must_use]
408 pub fn warning_count(&self) -> usize {
409 self.diagnostics
410 .iter()
411 .filter(|d| d.severity == Severity::Warning)
412 .count()
413 }
414
415 #[must_use]
417 pub fn diagnostics(&self) -> &[Diagnostic] {
418 &self.diagnostics
419 }
420
421 pub fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
423 std::mem::take(&mut self.diagnostics)
424 }
425
426 #[must_use]
428 pub fn format_all(&self) -> String {
429 let mut output = String::new();
430 for diagnostic in &self.diagnostics {
431 output.push_str(&diagnostic.format());
432 output.push('\n');
433 }
434
435 let errors = self.error_count();
437 let warnings = self.warning_count();
438 if errors > 0 || warnings > 0 {
439 output.push_str(&format!(
440 "{}: {} error{}, {} warning{}\n",
441 if errors > 0 { "aborting" } else { "finished" },
442 errors,
443 if errors == 1 { "" } else { "s" },
444 warnings,
445 if warnings == 1 { "" } else { "s" }
446 ));
447 }
448
449 output
450 }
451}
452
453pub fn validate_roles(
459 referenced_roles: &[(&str, Option<SourceLocation>)],
460 declared_roles: &HashSet<String>,
461 collector: &mut DiagnosticCollector,
462) {
463 for (role, location) in referenced_roles {
464 if !declared_roles.contains(*role) {
465 let available: Vec<_> = declared_roles.iter().cloned().collect();
466 let mut diagnostic = Diagnostic::error(
467 DiagnosticCode::UndefinedRole,
468 format!("Undefined role '{role}'"),
469 );
470
471 if let Some(loc) = location {
472 diagnostic = diagnostic.with_location(loc.clone());
473 }
474
475 let similar = find_similar_strings(role, &available);
477 if !similar.is_empty() {
478 diagnostic = diagnostic.with_suggestion(format!("Did you mean '{}'?", similar[0]));
479 }
480
481 diagnostic = diagnostic
482 .with_suggestion(format!("Add '{role}' to the roles declaration"))
483 .with_note(format!("Available roles: {}", available.join(", ")));
484
485 collector.add(diagnostic);
486 }
487 }
488}
489
490pub fn check_self_communication(
492 from: &str,
493 to: &str,
494 location: Option<SourceLocation>,
495 collector: &mut DiagnosticCollector,
496) {
497 if from == to {
498 let mut diagnostic = Diagnostic::warning(
499 DiagnosticCode::SelfCommunication,
500 format!("Role '{from}' sends message to itself"),
501 );
502
503 if let Some(loc) = location {
504 diagnostic = diagnostic.with_location(loc);
505 }
506
507 diagnostic = diagnostic
508 .with_note("Self-communication is usually a protocol design error")
509 .with_suggestion("Consider splitting into separate roles if this is intentional");
510
511 collector.add(diagnostic);
512 }
513}
514
515fn find_similar_strings(target: &str, candidates: &[String]) -> Vec<String> {
517 let target_lower = target.to_lowercase();
518 let mut similar: Vec<_> = candidates
519 .iter()
520 .filter_map(|s| {
521 let distance = levenshtein_distance(&target_lower, &s.to_lowercase());
522 if distance <= 2 {
523 Some((s.clone(), distance))
524 } else {
525 None
526 }
527 })
528 .collect();
529
530 similar.sort_by_key(|(_, d)| *d);
531 similar.into_iter().map(|(s, _)| s).collect()
532}
533
534#[allow(clippy::needless_range_loop)]
536fn levenshtein_distance(s1: &str, s2: &str) -> usize {
537 let s1_chars: Vec<char> = s1.chars().collect();
538 let s2_chars: Vec<char> = s2.chars().collect();
539 let m = s1_chars.len();
540 let n = s2_chars.len();
541
542 if m == 0 {
543 return n;
544 }
545 if n == 0 {
546 return m;
547 }
548
549 let mut dp = vec![vec![0usize; n + 1]; m + 1];
550
551 for i in 0..=m {
552 dp[i][0] = i;
553 }
554 for j in 0..=n {
555 dp[0][j] = j;
556 }
557
558 for i in 1..=m {
559 for j in 1..=n {
560 let cost = if s1_chars[i - 1] == s2_chars[j - 1] {
561 0
562 } else {
563 1
564 };
565 dp[i][j] = (dp[i - 1][j] + 1)
566 .min(dp[i][j - 1] + 1)
567 .min(dp[i - 1][j - 1] + cost);
568 }
569 }
570
571 dp[m][n]
572}
573
574#[cfg(test)]
575mod tests {
576 use super::*;
577
578 #[test]
579 fn test_diagnostic_code_display() {
580 assert_eq!(DiagnosticCode::UndefinedRole.code(), "R001");
581 assert_eq!(DiagnosticCode::DuplicateMessage.code(), "M002");
582 assert_eq!(DiagnosticCode::SyntaxError.code(), "S001");
583 }
584
585 #[test]
586 fn test_diagnostic_format() {
587 let diagnostic = Diagnostic::error(DiagnosticCode::UndefinedRole, "Undefined role 'Bob'")
588 .with_location(SourceLocation::new(5, 10, "Alice -> Bob: Request;").with_end(5, 13))
589 .with_suggestion("Add 'Bob' to the roles declaration")
590 .with_note("Available roles: Alice, Server");
591
592 let formatted = diagnostic.format();
593 assert!(formatted.contains("error[R001]"));
594 assert!(formatted.contains("Undefined role 'Bob'"));
595 assert!(formatted.contains("help:"));
596 assert!(formatted.contains("note:"));
597 }
598
599 #[test]
600 fn test_levenshtein_distance() {
601 assert_eq!(levenshtein_distance("hello", "hello"), 0);
602 assert_eq!(levenshtein_distance("hello", "helo"), 1);
603 assert_eq!(levenshtein_distance("hello", "world"), 4);
604 assert_eq!(levenshtein_distance("", "abc"), 3);
605 }
606
607 #[test]
608 fn test_find_similar_strings() {
609 let candidates = vec![
610 "Alice".to_string(),
611 "Bob".to_string(),
612 "Charlie".to_string(),
613 ];
614 let similar = find_similar_strings("Alic", &candidates);
615 assert_eq!(similar, vec!["Alice"]);
616 }
617
618 #[test]
619 fn test_collector() {
620 let mut collector = DiagnosticCollector::new();
621 collector.error(DiagnosticCode::UndefinedRole, "Test error");
622 collector.warning(DiagnosticCode::SelfCommunication, "Test warning");
623
624 assert!(collector.has_errors());
625 assert_eq!(collector.error_count(), 1);
626 assert_eq!(collector.warning_count(), 1);
627 }
628}