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:
@@ -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: {}.{}",
|
||||
|
||||
Reference in New Issue
Block a user