Skip to main content

telltale_runtime/compiler/
diagnostics.rs

1//! Diagnostic Framework for Choreographic Compilation
2//!
3//! This module provides a comprehensive error and warning system for the
4//! choreography compiler, including:
5//!
6//! - Error codes for programmatic handling
7//! - Detailed error messages with source locations
8//! - Suggestions for common mistakes
9//! - Severity levels for errors and warnings
10//!
11//! # Error Code Format
12//!
13//! Error codes follow the format `C###` where:
14//! - `C` is the category (R=Role, M=Message, S=Syntax, P=Protocol)
15//! - `###` is a three-digit numeric identifier (001-999)
16//!
17//! # Example
18//!
19//! ```text
20//! error[R001]: Undefined role 'Bob'
21//!   --> protocol.chor:5:3
22//!    |
23//!  5 | Alice -> Bob: Request;
24//!    |          ^^^ Role 'Bob' is not declared
25//!    |
26//!  = help: Add 'Bob' to the roles declaration, or check for typos
27//!  = note: Available roles: Alice, Server
28//! ```
29
30use std::collections::HashSet;
31use std::fmt;
32
33/// Error codes for choreography compilation.
34///
35/// These codes help users quickly identify error types and find solutions.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum DiagnosticCode {
38    // Role errors (R001-R099)
39    /// Role referenced but not declared
40    UndefinedRole,
41    /// Role declared multiple times
42    DuplicateRole,
43    /// Invalid role index (out of bounds)
44    RoleIndexOutOfBounds,
45    /// Invalid role parameter
46    InvalidRoleParam,
47    /// Self-communication (role sends to itself)
48    SelfCommunication,
49
50    // Message errors (M001-M099)
51    /// Message type not defined
52    UndefinedMessage,
53    /// Duplicate message type
54    DuplicateMessage,
55    /// Message payload type mismatch
56    MessageTypeMismatch,
57    /// Invalid message format
58    InvalidMessageFormat,
59
60    // Syntax errors (S001-S099)
61    /// General syntax error
62    SyntaxError,
63    /// Missing required element
64    MissingElement,
65    /// Unexpected token
66    UnexpectedToken,
67    /// Invalid identifier
68    InvalidIdentifier,
69
70    // Protocol errors (P001-P099)
71    /// Empty protocol
72    EmptyProtocol,
73    /// Unreachable code
74    UnreachableCode,
75    /// Infinite loop without termination
76    InfiniteLoop,
77    /// Choice without branches
78    EmptyChoice,
79    /// Duplicate branch label
80    DuplicateBranch,
81
82    // Annotation errors (A001-A099)
83    /// Invalid annotation key
84    InvalidAnnotationKey,
85    /// Invalid annotation value
86    InvalidAnnotationValue,
87    /// Conflicting annotations
88    ConflictingAnnotations,
89
90    // Choice propagation errors (C001-C099)
91    /// Role cannot determine which branch was selected
92    ChoicePropagationError,
93    /// Choice has no distinguishing messages
94    IndistinguishableChoiceBranches,
95}
96
97impl DiagnosticCode {
98    /// Get the numeric code string (e.g., "R001").
99    #[must_use]
100    pub fn code(&self) -> &'static str {
101        match self {
102            // Role errors
103            Self::UndefinedRole => "R001",
104            Self::DuplicateRole => "R002",
105            Self::RoleIndexOutOfBounds => "R003",
106            Self::InvalidRoleParam => "R004",
107            Self::SelfCommunication => "R005",
108
109            // Message errors
110            Self::UndefinedMessage => "M001",
111            Self::DuplicateMessage => "M002",
112            Self::MessageTypeMismatch => "M003",
113            Self::InvalidMessageFormat => "M004",
114
115            // Syntax errors
116            Self::SyntaxError => "S001",
117            Self::MissingElement => "S002",
118            Self::UnexpectedToken => "S003",
119            Self::InvalidIdentifier => "S004",
120
121            // Protocol errors
122            Self::EmptyProtocol => "P001",
123            Self::UnreachableCode => "P002",
124            Self::InfiniteLoop => "P003",
125            Self::EmptyChoice => "P004",
126            Self::DuplicateBranch => "P005",
127
128            // Annotation errors
129            Self::InvalidAnnotationKey => "A001",
130            Self::InvalidAnnotationValue => "A002",
131            Self::ConflictingAnnotations => "A003",
132
133            // Choice propagation errors
134            Self::ChoicePropagationError => "C001",
135            Self::IndistinguishableChoiceBranches => "C002",
136        }
137    }
138
139    /// Get a documentation URL for this error code.
140    #[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/// Severity levels for diagnostics.
153#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
154pub enum Severity {
155    /// Informational note
156    Note,
157    /// Warning (code will compile but may have issues)
158    Warning,
159    /// Error (compilation will fail)
160    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/// A location in source code.
174#[derive(Debug, Clone)]
175pub struct SourceLocation {
176    /// Line number (1-indexed)
177    pub line: usize,
178    /// Column number (1-indexed)
179    pub column: usize,
180    /// End line (for multi-line spans)
181    pub end_line: usize,
182    /// End column
183    pub end_column: usize,
184    /// The source line content
185    pub source_line: String,
186    /// Optional file name
187    pub file: Option<String>,
188}
189
190impl SourceLocation {
191    /// Create a new source location.
192    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    /// Create with an end position.
204    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    /// Create with a file name.
211    pub fn with_file(mut self, file: impl Into<String>) -> Self {
212        self.file = Some(file.into());
213        self
214    }
215}
216
217/// A diagnostic message (error, warning, or note).
218#[derive(Debug, Clone)]
219pub struct Diagnostic {
220    /// The error code
221    pub code: DiagnosticCode,
222    /// Severity level
223    pub severity: Severity,
224    /// Main error message
225    pub message: String,
226    /// Source location
227    pub location: Option<SourceLocation>,
228    /// Suggested fixes
229    pub suggestions: Vec<String>,
230    /// Additional notes
231    pub notes: Vec<String>,
232    /// Related information
233    pub related: Vec<RelatedInfo>,
234}
235
236/// Related information for a diagnostic.
237#[derive(Debug, Clone)]
238pub struct RelatedInfo {
239    /// Related location
240    pub location: SourceLocation,
241    /// Message
242    pub message: String,
243}
244
245impl Diagnostic {
246    /// Create a new diagnostic.
247    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    /// Create an error diagnostic.
260    pub fn error(code: DiagnosticCode, message: impl Into<String>) -> Self {
261        Self::new(code, Severity::Error, message)
262    }
263
264    /// Create a warning diagnostic.
265    pub fn warning(code: DiagnosticCode, message: impl Into<String>) -> Self {
266        Self::new(code, Severity::Warning, message)
267    }
268
269    /// Add a source location.
270    pub fn with_location(mut self, location: SourceLocation) -> Self {
271        self.location = Some(location);
272        self
273    }
274
275    /// Add a suggestion.
276    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
277        self.suggestions.push(suggestion.into());
278        self
279    }
280
281    /// Add a note.
282    pub fn with_note(mut self, note: impl Into<String>) -> Self {
283        self.notes.push(note.into());
284        self
285    }
286
287    /// Add related information.
288    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    /// Format the diagnostic for display.
297    #[must_use]
298    pub fn format(&self) -> String {
299        let mut output = String::new();
300
301        // Header: error[R001]: message
302        output.push_str(&format!(
303            "{}[{}]: {}\n",
304            self.severity, self.code, self.message
305        ));
306
307        // Location
308        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            // Source line with indicator
313            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            // Underline
323            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        // Suggestions
334        for suggestion in &self.suggestions {
335            output.push_str(&format!("  = help: {suggestion}\n"));
336        }
337
338        // Notes
339        for note in &self.notes {
340            output.push_str(&format!("  = note: {note}\n"));
341        }
342
343        // Related info
344        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/// Collector for diagnostics during compilation.
363#[derive(Debug, Default)]
364pub struct DiagnosticCollector {
365    diagnostics: Vec<Diagnostic>,
366}
367
368impl DiagnosticCollector {
369    /// Create a new collector.
370    pub fn new() -> Self {
371        Self::default()
372    }
373
374    /// Add a diagnostic.
375    pub fn add(&mut self, diagnostic: Diagnostic) {
376        self.diagnostics.push(diagnostic);
377    }
378
379    /// Add an error.
380    pub fn error(&mut self, code: DiagnosticCode, message: impl Into<String>) {
381        self.add(Diagnostic::error(code, message));
382    }
383
384    /// Add a warning.
385    pub fn warning(&mut self, code: DiagnosticCode, message: impl Into<String>) {
386        self.add(Diagnostic::warning(code, message));
387    }
388
389    /// Check if there are any errors.
390    #[must_use]
391    pub fn has_errors(&self) -> bool {
392        self.diagnostics
393            .iter()
394            .any(|d| d.severity == Severity::Error)
395    }
396
397    /// Get the number of errors.
398    #[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    /// Get the number of warnings.
407    #[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    /// Get all diagnostics.
416    #[must_use]
417    pub fn diagnostics(&self) -> &[Diagnostic] {
418        &self.diagnostics
419    }
420
421    /// Take all diagnostics.
422    pub fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
423        std::mem::take(&mut self.diagnostics)
424    }
425
426    /// Format all diagnostics.
427    #[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        // Summary
436        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
453// ============================================================================
454// Validation Helpers
455// ============================================================================
456
457/// Validate that all roles in a protocol are declared.
458pub 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            // Find similar roles for suggestions
476            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
490/// Check for self-communication (role sending to itself).
491pub 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
515/// Find strings similar to the target (for typo suggestions).
516fn 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/// Simple Levenshtein distance implementation.
535#[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}