Skip to main content

pepl_types/
error.rs

1use crate::Span;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Maximum number of errors reported before fail-fast.
6pub const MAX_ERRORS: usize = 20;
7
8/// Error severity.
9///
10/// Phase 0 only uses `Error`. Warnings are reserved for Phase 1+.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "lowercase")]
13pub enum Severity {
14    Error,
15    Warning,
16}
17
18/// Error category, determined by error code range.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum ErrorCategory {
22    Syntax,
23    Type,
24    Invariant,
25    Capability,
26    Scope,
27    Structure,
28}
29
30/// Numeric error code (E100–E699).
31#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
32pub struct ErrorCode(pub u16);
33
34impl ErrorCode {
35    // ── Syntax errors (E100–E199) ──
36    pub const UNEXPECTED_TOKEN: Self = Self(100);
37    pub const UNCLOSED_BRACE: Self = Self(101);
38    pub const INVALID_KEYWORD: Self = Self(102);
39
40    // ── Type errors (E200–E299) ──
41    pub const UNKNOWN_TYPE: Self = Self(200);
42    pub const TYPE_MISMATCH: Self = Self(201);
43    pub const WRONG_ARG_COUNT: Self = Self(202);
44    pub const NON_EXHAUSTIVE_MATCH: Self = Self(210);
45
46    // ── Invariant errors (E300–E399) ──
47    pub const INVARIANT_UNREACHABLE: Self = Self(300);
48    pub const INVARIANT_UNKNOWN_FIELD: Self = Self(301);
49
50    // ── Capability errors (E400–E499) ──
51    pub const UNDECLARED_CAPABILITY: Self = Self(400);
52    pub const CAPABILITY_UNAVAILABLE: Self = Self(401);
53    pub const UNKNOWN_COMPONENT: Self = Self(402);
54
55    // ── Scope errors (E500–E599) ──
56    pub const VARIABLE_ALREADY_DECLARED: Self = Self(500);
57    pub const STATE_MUTATED_OUTSIDE_ACTION: Self = Self(501);
58    pub const RECURSION_NOT_ALLOWED: Self = Self(502);
59
60    // ── Structure errors (E600–E699) ──
61    pub const BLOCK_ORDERING_VIOLATED: Self = Self(600);
62    pub const DERIVED_FIELD_MODIFIED: Self = Self(601);
63    pub const EXPRESSION_BODY_LAMBDA: Self = Self(602);
64    pub const BLOCK_COMMENT_USED: Self = Self(603);
65    pub const UNDECLARED_CREDENTIAL: Self = Self(604);
66    pub const CREDENTIAL_MODIFIED: Self = Self(605);
67    pub const EMPTY_STATE_BLOCK: Self = Self(606);
68    pub const STRUCTURAL_LIMIT_EXCEEDED: Self = Self(607);
69
70    /// Get the category for this error code.
71    pub fn category(self) -> ErrorCategory {
72        match self.0 {
73            100..=199 => ErrorCategory::Syntax,
74            200..=299 => ErrorCategory::Type,
75            300..=399 => ErrorCategory::Invariant,
76            400..=499 => ErrorCategory::Capability,
77            500..=599 => ErrorCategory::Scope,
78            600..=699 => ErrorCategory::Structure,
79            _ => ErrorCategory::Syntax, // fallback
80        }
81    }
82}
83
84impl fmt::Display for ErrorCode {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        write!(f, "E{}", self.0)
87    }
88}
89
90/// A structured PEPL compiler error.
91///
92/// Matches the error message format defined in the spec (compiler.md).
93/// The View Layer renders these — it must not parse free-form strings.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct PeplError {
96    /// Source file name.
97    pub file: String,
98    /// Error code (e.g., E201).
99    pub code: ErrorCode,
100    /// Error severity.
101    pub severity: Severity,
102    /// Error category (derived from code).
103    pub category: ErrorCategory,
104    /// Human-readable error message.
105    pub message: String,
106    /// Source location.
107    #[serde(flatten)]
108    pub span: Span,
109    /// The exact source line for context.
110    pub source_line: String,
111    /// Optional fix suggestion (for LLM re-prompting).
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub suggestion: Option<String>,
114}
115
116impl PeplError {
117    /// Create a new error.
118    pub fn new(
119        file: impl Into<String>,
120        code: ErrorCode,
121        message: impl Into<String>,
122        span: Span,
123        source_line: impl Into<String>,
124    ) -> Self {
125        Self {
126            file: file.into(),
127            code,
128            severity: Severity::Error,
129            category: code.category(),
130            message: message.into(),
131            span,
132            source_line: source_line.into(),
133            suggestion: None,
134        }
135    }
136
137    /// Attach a fix suggestion.
138    pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
139        self.suggestion = Some(suggestion.into());
140        self
141    }
142}
143
144impl fmt::Display for PeplError {
145    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146        write!(
147            f,
148            "{}: {} [{}] {}",
149            self.span, self.code, self.category, self.message
150        )
151    }
152}
153
154impl std::error::Error for PeplError {}
155
156impl fmt::Display for ErrorCategory {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::Syntax => write!(f, "syntax"),
160            Self::Type => write!(f, "type"),
161            Self::Invariant => write!(f, "invariant"),
162            Self::Capability => write!(f, "capability"),
163            Self::Scope => write!(f, "scope"),
164            Self::Structure => write!(f, "structure"),
165        }
166    }
167}
168
169/// The structured JSON output for compilation results.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct CompileErrors {
172    pub errors: Vec<PeplError>,
173    pub warnings: Vec<PeplError>,
174    pub total_errors: usize,
175    pub total_warnings: usize,
176}
177
178impl CompileErrors {
179    /// Create an empty result (no errors).
180    pub fn empty() -> Self {
181        Self {
182            errors: Vec::new(),
183            warnings: Vec::new(),
184            total_errors: 0,
185            total_warnings: 0,
186        }
187    }
188
189    /// Check if there are any errors.
190    pub fn has_errors(&self) -> bool {
191        self.total_errors > 0
192    }
193
194    /// Add an error, respecting the MAX_ERRORS limit.
195    pub fn push_error(&mut self, error: PeplError) {
196        if self.errors.len() < MAX_ERRORS {
197            self.errors.push(error);
198        }
199        self.total_errors += 1;
200    }
201
202    /// Add a warning.
203    pub fn push_warning(&mut self, warning: PeplError) {
204        self.warnings.push(warning);
205        self.total_warnings += 1;
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_error_code_category() {
215        assert_eq!(
216            ErrorCode::UNEXPECTED_TOKEN.category(),
217            ErrorCategory::Syntax
218        );
219        assert_eq!(ErrorCode::TYPE_MISMATCH.category(), ErrorCategory::Type);
220        assert_eq!(
221            ErrorCode::INVARIANT_UNREACHABLE.category(),
222            ErrorCategory::Invariant
223        );
224        assert_eq!(
225            ErrorCode::UNDECLARED_CAPABILITY.category(),
226            ErrorCategory::Capability
227        );
228        assert_eq!(
229            ErrorCode::VARIABLE_ALREADY_DECLARED.category(),
230            ErrorCategory::Scope
231        );
232        assert_eq!(
233            ErrorCode::BLOCK_ORDERING_VIOLATED.category(),
234            ErrorCategory::Structure
235        );
236    }
237
238    #[test]
239    fn test_error_code_display() {
240        assert_eq!(format!("{}", ErrorCode::TYPE_MISMATCH), "E201");
241        assert_eq!(format!("{}", ErrorCode::UNEXPECTED_TOKEN), "E100");
242    }
243
244    #[test]
245    fn test_pepl_error_creation() {
246        let err = PeplError::new(
247            "test.pepl",
248            ErrorCode::TYPE_MISMATCH,
249            "Type mismatch: expected 'Number', found 'String'",
250            Span::new(12, 5, 12, 22),
251            "  set state.count = \"hello\"",
252        );
253        assert_eq!(err.code, ErrorCode::TYPE_MISMATCH);
254        assert_eq!(err.severity, Severity::Error);
255        assert_eq!(err.category, ErrorCategory::Type);
256    }
257
258    #[test]
259    fn test_pepl_error_with_suggestion() {
260        let err = PeplError::new(
261            "test.pepl",
262            ErrorCode::TYPE_MISMATCH,
263            "Type mismatch",
264            Span::new(1, 1, 1, 10),
265            "set count = \"hello\"",
266        )
267        .with_suggestion("Use convert.to_int(value)");
268        assert_eq!(err.suggestion.as_deref(), Some("Use convert.to_int(value)"));
269    }
270
271    #[test]
272    fn test_pepl_error_json_serialization() {
273        let err = PeplError::new(
274            "WaterTracker.pepl",
275            ErrorCode::TYPE_MISMATCH,
276            "Type mismatch: expected 'Number', found 'String'",
277            Span::new(12, 5, 12, 22),
278            "  set state.count = \"hello\"",
279        )
280        .with_suggestion("Use convert.to_int(value) to convert String to Number");
281
282        let json = serde_json::to_string_pretty(&err).unwrap();
283        assert!(json.contains("\"code\""));
284        assert!(json.contains("\"message\""));
285        assert!(json.contains("\"source_line\""));
286        assert!(json.contains("\"suggestion\""));
287        // Verify JSON field names match compiler.md spec
288        assert!(
289            json.contains("\"line\""),
290            "JSON must use 'line' not 'start_line'"
291        );
292        assert!(
293            json.contains("\"column\""),
294            "JSON must use 'column' not 'start_col'"
295        );
296        assert!(json.contains("\"end_line\""));
297        assert!(
298            json.contains("\"end_column\""),
299            "JSON must use 'end_column' not 'end_col'"
300        );
301
302        // Round-trip
303        let deserialized: PeplError = serde_json::from_str(&json).unwrap();
304        assert_eq!(deserialized.code, err.code);
305        assert_eq!(deserialized.message, err.message);
306    }
307
308    #[test]
309    fn test_compile_errors_max_limit() {
310        let mut errs = CompileErrors::empty();
311        for i in 0..25 {
312            errs.push_error(PeplError::new(
313                "test.pepl",
314                ErrorCode::UNEXPECTED_TOKEN,
315                format!("Error {i}"),
316                Span::point(i as u32 + 1, 1),
317                "",
318            ));
319        }
320        // Only 20 stored, but total count is 25
321        assert_eq!(errs.errors.len(), 20);
322        assert_eq!(errs.total_errors, 25);
323        assert!(errs.has_errors());
324    }
325
326    #[test]
327    fn test_compile_errors_empty() {
328        let errs = CompileErrors::empty();
329        assert!(!errs.has_errors());
330        assert_eq!(errs.total_errors, 0);
331        assert_eq!(errs.total_warnings, 0);
332    }
333
334    #[test]
335    fn test_compile_errors_json_output() {
336        let mut errs = CompileErrors::empty();
337        errs.push_error(PeplError::new(
338            "test.pepl",
339            ErrorCode::TYPE_MISMATCH,
340            "Type mismatch",
341            Span::new(1, 1, 1, 10),
342            "set count = \"hello\"",
343        ));
344
345        let json = serde_json::to_string(&errs).unwrap();
346        assert!(json.contains("\"total_errors\":1"));
347        assert!(json.contains("\"total_warnings\":0"));
348    }
349
350    #[test]
351    fn test_error_determinism_100_iterations() {
352        let first = PeplError::new(
353            "test.pepl",
354            ErrorCode::TYPE_MISMATCH,
355            "Type mismatch",
356            Span::new(12, 5, 12, 22),
357            "set count = \"hello\"",
358        );
359        let first_json = serde_json::to_string(&first).unwrap();
360
361        for i in 0..100 {
362            let err = PeplError::new(
363                "test.pepl",
364                ErrorCode::TYPE_MISMATCH,
365                "Type mismatch",
366                Span::new(12, 5, 12, 22),
367                "set count = \"hello\"",
368            );
369            let json = serde_json::to_string(&err).unwrap();
370            assert_eq!(first_json, json, "Determinism failure at iteration {i}");
371        }
372    }
373}