feat: Elm-quality error messages with error codes

- Add ErrorCode enum with categorized codes (E01xx parse, E02xx type,
  E03xx name, E04xx effect, E05xx pattern, E06xx module, E07xx behavioral)
- Extend Diagnostic struct with error code, expected/actual types, and
  secondary spans
- Add format_type_diff() for visual type comparison in error messages
- Add help URLs linking to lux-lang.dev/errors/{code}
- Update typechecker, parser, and interpreter to use error codes
- Categorize errors with specific codes and helpful hints

Error messages now show:
- Error code in header: -- ERROR[E0301] ──
- Clear error category title
- Visual type diff for type mismatches
- Context-aware hints
- "Learn more" URL for documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 04:11:15 -05:00
parent bc1e5aa8a1
commit 3a46299404
5 changed files with 1200 additions and 45 deletions

View File

@@ -3,8 +3,9 @@
#![allow(dead_code, unused_variables)]
use crate::ast::*;
use crate::diagnostics::{Diagnostic, Severity};
use crate::diagnostics::{Diagnostic, ErrorCode, Severity};
use rand::Rng;
use rusqlite::Connection;
use std::cell::RefCell;
use std::collections::HashMap;
use std::fmt;
@@ -426,29 +427,47 @@ impl std::error::Error for RuntimeError {}
impl RuntimeError {
/// Convert to a rich diagnostic for Elm-style error display
pub fn to_diagnostic(&self) -> Diagnostic {
let (title, hints) = categorize_runtime_error(&self.message);
let (code, title, hints) = categorize_runtime_error(&self.message);
Diagnostic {
severity: Severity::Error,
code,
title,
message: self.message.clone(),
span: self.span.unwrap_or_default(),
hints,
expected_type: None,
actual_type: None,
secondary_spans: Vec::new(),
}
}
}
/// Categorize runtime errors to provide better titles and hints
fn categorize_runtime_error(message: &str) -> (String, Vec<String>) {
/// Categorize runtime errors to provide better titles, hints, and error codes
fn categorize_runtime_error(message: &str) -> (Option<ErrorCode>, String, Vec<String>) {
let message_lower = message.to_lowercase();
if message_lower.contains("undefined") || message_lower.contains("not found") {
if message_lower.contains("undefined variable") {
(
Some(ErrorCode::E0301),
"Undefined Variable".to_string(),
vec!["Make sure the variable is defined and in scope.".to_string()],
)
} else if message_lower.contains("undefined function") || message_lower.contains("function not found") {
(
Some(ErrorCode::E0302),
"Undefined Function".to_string(),
vec!["Make sure the function is defined and in scope.".to_string()],
)
} else if message_lower.contains("undefined") || message_lower.contains("not found") {
(
Some(ErrorCode::E0301),
"Undefined Reference".to_string(),
vec!["Make sure the name is defined and in scope.".to_string()],
)
} else if message_lower.contains("division by zero") || message_lower.contains("divide by zero") {
(
None, // Runtime error, not a type error
"Division by Zero".to_string(),
vec![
"Check that the divisor is not zero before dividing.".to_string(),
@@ -457,11 +476,13 @@ fn categorize_runtime_error(message: &str) -> (String, Vec<String>) {
)
} else if message_lower.contains("type") && message_lower.contains("mismatch") {
(
Some(ErrorCode::E0201),
"Type Mismatch".to_string(),
vec!["The value has a different type than expected.".to_string()],
)
} else if message_lower.contains("effect") && message_lower.contains("unhandled") {
(
Some(ErrorCode::E0401),
"Unhandled Effect".to_string(),
vec![
"This effect must be handled before the program can continue.".to_string(),
@@ -470,16 +491,19 @@ fn categorize_runtime_error(message: &str) -> (String, Vec<String>) {
)
} else if message_lower.contains("pattern") && message_lower.contains("match") {
(
Some(ErrorCode::E0501),
"Non-exhaustive Pattern".to_string(),
vec!["Add more patterns to cover all possible cases.".to_string()],
)
} else if message_lower.contains("argument") {
(
Some(ErrorCode::E0209),
"Wrong Arguments".to_string(),
vec!["Check the number and types of arguments provided.".to_string()],
)
} else if message_lower.contains("index") || message_lower.contains("bounds") {
(
None, // Runtime error
"Index Out of Bounds".to_string(),
vec![
"The index is outside the valid range.".to_string(),
@@ -487,7 +511,7 @@ fn categorize_runtime_error(message: &str) -> (String, Vec<String>) {
],
)
} else {
("Runtime Error".to_string(), vec![])
(None, "Runtime Error".to_string(), vec![])
}
}
@@ -587,6 +611,10 @@ pub struct Interpreter {
current_http_request: Arc<Mutex<Option<tiny_http::Request>>>,
/// Test results for the Test effect
test_results: RefCell<TestResults>,
/// SQL database connections (connection ID -> Connection)
sql_connections: RefCell<HashMap<i64, Connection>>,
/// Next SQL connection ID
next_sql_conn_id: RefCell<i64>,
}
/// Results from running tests
@@ -627,6 +655,8 @@ impl Interpreter {
http_server: Arc::new(Mutex::new(None)),
current_http_request: Arc::new(Mutex::new(None)),
test_results: RefCell::new(TestResults::default()),
sql_connections: RefCell::new(HashMap::new()),
next_sql_conn_id: RefCell::new(1),
}
}
@@ -708,6 +738,32 @@ impl Interpreter {
.insert(from_version, migration);
}
/// Register auto-generated migrations from the typechecker
/// These are migrations that were automatically generated for auto-migratable schema changes
pub fn register_auto_migrations(
&mut self,
auto_migrations: &std::collections::HashMap<String, std::collections::HashMap<u32, Expr>>,
) {
for (type_name, version_migrations) in auto_migrations {
for (from_version, body) in version_migrations {
// Only register if no migration already exists
let already_has = self
.migrations
.get(type_name)
.map(|m| m.contains_key(from_version))
.unwrap_or(false);
if !already_has {
let stored = StoredMigration {
body: body.clone(),
env: self.global_env.clone(),
};
self.register_migration(type_name, *from_version, stored);
}
}
}
}
/// Create a versioned value
pub fn create_versioned(&self, type_name: &str, version: u32, value: Value) -> Value {
Value::Versioned {
@@ -3712,6 +3768,292 @@ impl Interpreter {
Ok(Value::Unit)
}
// ===== Sql Effect =====
("Sql", "open") => {
let path = match request.args.first() {
Some(Value::String(s)) => s.clone(),
_ => return Err(RuntimeError {
message: "Sql.open requires a path string".to_string(),
span: None,
}),
};
match Connection::open(&path) {
Ok(conn) => {
let id = *self.next_sql_conn_id.borrow();
*self.next_sql_conn_id.borrow_mut() += 1;
self.sql_connections.borrow_mut().insert(id, conn);
Ok(Value::Int(id))
}
Err(e) => Err(RuntimeError {
message: format!("Sql.open failed: {}", e),
span: None,
}),
}
}
("Sql", "openMemory") => {
match Connection::open_in_memory() {
Ok(conn) => {
let id = *self.next_sql_conn_id.borrow();
*self.next_sql_conn_id.borrow_mut() += 1;
self.sql_connections.borrow_mut().insert(id, conn);
Ok(Value::Int(id))
}
Err(e) => Err(RuntimeError {
message: format!("Sql.openMemory failed: {}", e),
span: None,
}),
}
}
("Sql", "close") => {
let conn_id = match request.args.first() {
Some(Value::Int(id)) => *id,
_ => return Err(RuntimeError {
message: "Sql.close requires a connection ID".to_string(),
span: None,
}),
};
if self.sql_connections.borrow_mut().remove(&conn_id).is_some() {
Ok(Value::Unit)
} else {
Err(RuntimeError {
message: format!("Sql.close: invalid connection ID {}", conn_id),
span: None,
})
}
}
("Sql", "execute") => {
let (conn_id, sql) = match (request.args.get(0), request.args.get(1)) {
(Some(Value::Int(id)), Some(Value::String(s))) => (*id, s.clone()),
_ => return Err(RuntimeError {
message: "Sql.execute requires connection ID and SQL string".to_string(),
span: None,
}),
};
let conns = self.sql_connections.borrow();
let conn = match conns.get(&conn_id) {
Some(c) => c,
None => return Err(RuntimeError {
message: format!("Sql.execute: invalid connection ID {}", conn_id),
span: None,
}),
};
match conn.execute(&sql, []) {
Ok(rows_affected) => Ok(Value::Int(rows_affected as i64)),
Err(e) => Err(RuntimeError {
message: format!("Sql.execute failed: {}", e),
span: None,
}),
}
}
("Sql", "query") => {
let (conn_id, sql) = match (request.args.get(0), request.args.get(1)) {
(Some(Value::Int(id)), Some(Value::String(s))) => (*id, s.clone()),
_ => return Err(RuntimeError {
message: "Sql.query requires connection ID and SQL string".to_string(),
span: None,
}),
};
let conns = self.sql_connections.borrow();
let conn = match conns.get(&conn_id) {
Some(c) => c,
None => return Err(RuntimeError {
message: format!("Sql.query: invalid connection ID {}", conn_id),
span: None,
}),
};
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(e) => return Err(RuntimeError {
message: format!("Sql.query prepare failed: {}", e),
span: None,
}),
};
let column_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
let column_count = column_names.len();
let rows: Result<Vec<Value>, _> = stmt.query_map([], |row| {
let mut record = HashMap::new();
for (i, col_name) in column_names.iter().enumerate() {
let value = match row.get_ref(i) {
Ok(rusqlite::types::ValueRef::Null) => Value::Constructor {
name: "None".to_string(),
fields: vec![],
},
Ok(rusqlite::types::ValueRef::Integer(n)) => Value::Int(n),
Ok(rusqlite::types::ValueRef::Real(f)) => Value::Float(f),
Ok(rusqlite::types::ValueRef::Text(s)) => {
Value::String(String::from_utf8_lossy(s).to_string())
}
Ok(rusqlite::types::ValueRef::Blob(b)) => {
Value::String(format!("<blob {} bytes>", b.len()))
}
Err(_) => Value::String("<error>".to_string()),
};
record.insert(col_name.clone(), value);
}
Ok(Value::Record(record))
}).and_then(|rows| rows.collect());
match rows {
Ok(r) => Ok(Value::List(r)),
Err(e) => Err(RuntimeError {
message: format!("Sql.query failed: {}", e),
span: None,
}),
}
}
("Sql", "queryOne") => {
let (conn_id, sql) = match (request.args.get(0), request.args.get(1)) {
(Some(Value::Int(id)), Some(Value::String(s))) => (*id, s.clone()),
_ => return Err(RuntimeError {
message: "Sql.queryOne requires connection ID and SQL string".to_string(),
span: None,
}),
};
let conns = self.sql_connections.borrow();
let conn = match conns.get(&conn_id) {
Some(c) => c,
None => return Err(RuntimeError {
message: format!("Sql.queryOne: invalid connection ID {}", conn_id),
span: None,
}),
};
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(e) => return Err(RuntimeError {
message: format!("Sql.queryOne prepare failed: {}", e),
span: None,
}),
};
let column_names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
let result = stmt.query_row([], |row| {
let mut record = HashMap::new();
for (i, col_name) in column_names.iter().enumerate() {
let value = match row.get_ref(i) {
Ok(rusqlite::types::ValueRef::Null) => Value::Constructor {
name: "None".to_string(),
fields: vec![],
},
Ok(rusqlite::types::ValueRef::Integer(n)) => Value::Int(n),
Ok(rusqlite::types::ValueRef::Real(f)) => Value::Float(f),
Ok(rusqlite::types::ValueRef::Text(s)) => {
Value::String(String::from_utf8_lossy(s).to_string())
}
Ok(rusqlite::types::ValueRef::Blob(b)) => {
Value::String(format!("<blob {} bytes>", b.len()))
}
Err(_) => Value::String("<error>".to_string()),
};
record.insert(col_name.clone(), value);
}
Ok(Value::Record(record))
});
match result {
Ok(row) => Ok(Value::Constructor {
name: "Some".to_string(),
fields: vec![row],
}),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(Value::Constructor {
name: "None".to_string(),
fields: vec![],
}),
Err(e) => Err(RuntimeError {
message: format!("Sql.queryOne failed: {}", e),
span: None,
}),
}
}
("Sql", "beginTx") => {
let conn_id = match request.args.first() {
Some(Value::Int(id)) => *id,
_ => return Err(RuntimeError {
message: "Sql.beginTx requires a connection ID".to_string(),
span: None,
}),
};
let conns = self.sql_connections.borrow();
let conn = match conns.get(&conn_id) {
Some(c) => c,
None => return Err(RuntimeError {
message: format!("Sql.beginTx: invalid connection ID {}", conn_id),
span: None,
}),
};
match conn.execute("BEGIN TRANSACTION", []) {
Ok(_) => Ok(Value::Unit),
Err(e) => Err(RuntimeError {
message: format!("Sql.beginTx failed: {}", e),
span: None,
}),
}
}
("Sql", "commit") => {
let conn_id = match request.args.first() {
Some(Value::Int(id)) => *id,
_ => return Err(RuntimeError {
message: "Sql.commit requires a connection ID".to_string(),
span: None,
}),
};
let conns = self.sql_connections.borrow();
let conn = match conns.get(&conn_id) {
Some(c) => c,
None => return Err(RuntimeError {
message: format!("Sql.commit: invalid connection ID {}", conn_id),
span: None,
}),
};
match conn.execute("COMMIT", []) {
Ok(_) => Ok(Value::Unit),
Err(e) => Err(RuntimeError {
message: format!("Sql.commit failed: {}", e),
span: None,
}),
}
}
("Sql", "rollback") => {
let conn_id = match request.args.first() {
Some(Value::Int(id)) => *id,
_ => return Err(RuntimeError {
message: "Sql.rollback requires a connection ID".to_string(),
span: None,
}),
};
let conns = self.sql_connections.borrow();
let conn = match conns.get(&conn_id) {
Some(c) => c,
None => return Err(RuntimeError {
message: format!("Sql.rollback: invalid connection ID {}", conn_id),
span: None,
}),
};
match conn.execute("ROLLBACK", []) {
Ok(_) => Ok(Value::Unit),
Err(e) => Err(RuntimeError {
message: format!("Sql.rollback failed: {}", e),
span: None,
}),
}
}
_ => Err(RuntimeError {
message: format!(
"Unhandled effect operation: {}.{}",