rename crates/ to engrams/; add el-compiler el package with bootstrap artifact
- crates/ → engrams/ (Rust engrams live here)
- el-compiler/ added: el self-hosting compiler as an el package
- src/{compiler,lexer,parser,codegen}.el
- bootstrap/el-compiler.elc (114KB, Rust-compiled seed)
- el.toml Cargo.toml workspace paths updated
- neuron-rs cross-repo path deps fixed (were pointing to products/ instead of foundation/)
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
//! Error types for el-lint.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LintError {
|
||||
#[error("lex error: {0}")]
|
||||
Lex(String),
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! el-lint — linter for el source files.
|
||||
//!
|
||||
//! Combines:
|
||||
//! - `el-arch` architectural rule violations (VBD, EBD, swarm, security, graph)
|
||||
//! - Style checks (naming conventions, function length, empty bodies)
|
||||
//! - Format check (`el-fmt` canonical check, rule I001)
|
||||
|
||||
pub mod error;
|
||||
pub mod linter;
|
||||
pub mod report;
|
||||
pub mod rules;
|
||||
|
||||
pub use error::LintError;
|
||||
pub use linter::Linter;
|
||||
pub use report::{LintDiagnostic, LintReport, LintSeverity};
|
||||
|
||||
/// Lint el source code. Returns a report with all diagnostics.
|
||||
pub fn lint(source: &str) -> Result<LintReport, LintError> {
|
||||
Linter::new().lint(source)
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn do_lint(src: &str) -> LintReport {
|
||||
lint(src).unwrap()
|
||||
}
|
||||
|
||||
// 1. Clean, canonical code → no errors, no warnings
|
||||
#[test]
|
||||
fn test_clean_code_no_errors() {
|
||||
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||
let report = do_lint(src);
|
||||
assert!(!report.has_errors(), "unexpected errors: {:?}", report.diagnostics);
|
||||
assert_eq!(report.warning_count(), 0, "unexpected warnings: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 2. @accessor calling @manager fn → VBD-001 error
|
||||
#[test]
|
||||
fn test_accessor_calls_manager() {
|
||||
let src = concat!(
|
||||
"@manager\nfn save_data() -> Void {\n}\n\n",
|
||||
"@accessor\nfn get_data() -> Void {\n save_data()\n}\n"
|
||||
);
|
||||
let report = do_lint(src);
|
||||
assert!(
|
||||
report.has_errors(),
|
||||
"expected arch error for accessor calling manager"
|
||||
);
|
||||
let has_vbd = report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code.contains("VBD"));
|
||||
assert!(has_vbd, "expected VBD code: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 3. activate in a loop → GRAPH-001 warning
|
||||
#[test]
|
||||
fn test_activate_in_loop() {
|
||||
let src = concat!(
|
||||
"@accessor\nfn load_all(items: [String]) -> Void {\n",
|
||||
" for x in items {\n",
|
||||
" activate User where \"query\"\n",
|
||||
" }\n}\n"
|
||||
);
|
||||
let report = do_lint(src);
|
||||
let has_graph = report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code.contains("GRAPH") || d.code.contains("N1"));
|
||||
assert!(has_graph, "expected GRAPH/N1 diagnostic: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 4. Function with uppercase name → S002 warning
|
||||
#[test]
|
||||
fn test_fn_uppercase_name() {
|
||||
let src = "fn MyFunction() -> Void {\n}\n";
|
||||
let report = do_lint(src);
|
||||
let has_s002 = report.diagnostics.iter().any(|d| d.code == "S002");
|
||||
assert!(has_s002, "expected S002: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 5. Type with lowercase name → S004 warning
|
||||
#[test]
|
||||
fn test_type_lowercase_name() {
|
||||
let src = "type myType {\n x: Int\n}\n";
|
||||
let report = do_lint(src);
|
||||
let has_s004 = report.diagnostics.iter().any(|d| d.code == "S004");
|
||||
assert!(has_s004, "expected S004: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 6. Empty function body → S003 info
|
||||
#[test]
|
||||
fn test_empty_fn_body() {
|
||||
let src = "fn empty() -> Void {\n}\n";
|
||||
let report = do_lint(src);
|
||||
let has_s003 = report.diagnostics.iter().any(|d| d.code == "S003");
|
||||
assert!(has_s003, "expected S003: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 7. Non-canonical formatting → I001 info
|
||||
#[test]
|
||||
fn test_non_canonical_format() {
|
||||
// Missing trailing newline triggers I001 (formatter adds it, source doesn't have it)
|
||||
let src = "42";
|
||||
let report = do_lint(src);
|
||||
let has_i001 = report.diagnostics.iter().any(|d| d.code == "I001");
|
||||
assert!(has_i001, "expected I001: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 8. Canonical formatting → no I001
|
||||
#[test]
|
||||
fn test_canonical_format_no_i001() {
|
||||
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||
let report = do_lint(src);
|
||||
let has_i001 = report.diagnostics.iter().any(|d| d.code == "I001");
|
||||
assert!(!has_i001, "unexpected I001: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 9. has_errors() true when errors present
|
||||
#[test]
|
||||
fn test_has_errors_true() {
|
||||
let src = concat!(
|
||||
"@manager\nfn save() -> Void {\n}\n\n",
|
||||
"@accessor\nfn get() -> Void {\n save()\n}\n"
|
||||
);
|
||||
let report = do_lint(src);
|
||||
assert!(report.has_errors());
|
||||
}
|
||||
|
||||
// 10. has_errors() false when only warnings/info
|
||||
#[test]
|
||||
fn test_has_errors_false_warnings_only() {
|
||||
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
|
||||
let report = do_lint(src);
|
||||
assert!(!report.has_errors(), "should not have errors, only warnings");
|
||||
}
|
||||
|
||||
// 11. error_count() correct
|
||||
#[test]
|
||||
fn test_error_count() {
|
||||
let src = concat!(
|
||||
"@manager\nfn save() -> Void {\n}\n\n",
|
||||
"@accessor\nfn get() -> Void {\n save()\n}\n"
|
||||
);
|
||||
let report = do_lint(src);
|
||||
assert!(report.error_count() >= 1);
|
||||
}
|
||||
|
||||
// 12. warning_count() correct
|
||||
#[test]
|
||||
fn test_warning_count() {
|
||||
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
|
||||
let report = do_lint(src);
|
||||
assert!(report.warning_count() >= 1, "expected at least one warning");
|
||||
}
|
||||
|
||||
// 13. display() output contains "error" prefix for errors
|
||||
#[test]
|
||||
fn test_display_error_prefix() {
|
||||
let src = concat!(
|
||||
"@manager\nfn save() -> Void {\n}\n\n",
|
||||
"@accessor\nfn get() -> Void {\n save()\n}\n"
|
||||
);
|
||||
let report = do_lint(src);
|
||||
let display = report.display();
|
||||
assert!(display.contains("error"), "expected 'error' in display: {display}");
|
||||
}
|
||||
|
||||
// 14. display() contains "No issues found." for clean code
|
||||
#[test]
|
||||
fn test_display_no_issues() {
|
||||
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||
let report = do_lint(src);
|
||||
if !report.has_errors() && report.warning_count() == 0 {
|
||||
let display = report.display();
|
||||
assert!(
|
||||
display.contains("No issues found."),
|
||||
"expected 'No issues found.': {display}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 15. to_json() is valid JSON
|
||||
#[test]
|
||||
fn test_to_json_valid() {
|
||||
let src = "fn add(a: Int, b: Int) -> Int {\n return a + b\n}\n";
|
||||
let report = do_lint(src);
|
||||
let json = report.to_json();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON");
|
||||
assert!(parsed.is_array(), "expected JSON array");
|
||||
}
|
||||
|
||||
// 16. to_json() contains severity field
|
||||
#[test]
|
||||
fn test_to_json_has_severity() {
|
||||
let src = "fn MyFunction() -> Int {\n return 1\n}\n";
|
||||
let report = do_lint(src);
|
||||
let json = report.to_json();
|
||||
assert!(json.contains("severity"), "expected severity field: {json}");
|
||||
}
|
||||
|
||||
// 17. Multiple issues in same file → all reported
|
||||
#[test]
|
||||
fn test_multiple_issues() {
|
||||
let src = "fn MyFunction() -> Void {\n}\ntype myType {\n x: Int\n}\n";
|
||||
let report = do_lint(src);
|
||||
// S002 for fn name + S003 for empty body + S004 for type name
|
||||
assert!(
|
||||
report.diagnostics.len() >= 2,
|
||||
"expected multiple diagnostics: {:?}",
|
||||
report.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
// 18. @experience calling @experience → arch error
|
||||
#[test]
|
||||
fn test_experience_calls_experience() {
|
||||
let src = concat!(
|
||||
"@experience\nfn exp_a() -> Void {\n}\n\n",
|
||||
"@experience\nfn exp_b() -> Void {\n exp_a()\n}\n"
|
||||
);
|
||||
let report = do_lint(src);
|
||||
assert!(
|
||||
report.has_errors(),
|
||||
"expected arch error for experience calling experience"
|
||||
);
|
||||
}
|
||||
|
||||
// 19. @public fn with activate → arch error
|
||||
#[test]
|
||||
fn test_public_fn_with_activate() {
|
||||
let src = "@public\nfn api_fn() -> Void {\n activate User where \"query\"\n}\n";
|
||||
let report = do_lint(src);
|
||||
assert!(
|
||||
report.has_errors(),
|
||||
"expected arch error for public fn with activate"
|
||||
);
|
||||
}
|
||||
|
||||
// 20. @swarm_agent calling @swarm_agent → diagnostic
|
||||
#[test]
|
||||
fn test_swarm_agent_calls_swarm_agent() {
|
||||
let src = concat!(
|
||||
"@swarm_agent\nfn agent_a() -> Void {\n}\n\n",
|
||||
"@swarm_agent\nfn agent_b() -> Void {\n agent_a()\n}\n"
|
||||
);
|
||||
let report = do_lint(src);
|
||||
// SwarmAgentIsolation should flag this
|
||||
let has_swarm = report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code.contains("SWARM") || d.severity == LintSeverity::Error || d.severity == LintSeverity::Warning);
|
||||
assert!(has_swarm, "expected swarm diagnostic: {:?}", report.diagnostics);
|
||||
}
|
||||
|
||||
// 21. Nested functions → linting still works
|
||||
#[test]
|
||||
fn test_nested_functions() {
|
||||
let src = concat!(
|
||||
"fn outer(x: Int) -> Int {\n",
|
||||
" fn inner(y: Int) -> Int {\n",
|
||||
" return y + 1\n",
|
||||
" }\n",
|
||||
" return inner(x)\n",
|
||||
"}\n"
|
||||
);
|
||||
// Should not panic
|
||||
let result = lint(src);
|
||||
assert!(result.is_ok(), "lint failed on nested functions");
|
||||
}
|
||||
|
||||
// 22. LintReport::file_path is None by default
|
||||
#[test]
|
||||
fn test_file_path_none() {
|
||||
let src = "fn f() -> Void {\n}\n";
|
||||
let report = do_lint(src);
|
||||
assert!(report.file_path.is_none());
|
||||
}
|
||||
|
||||
// 23. source_lines is counted correctly
|
||||
#[test]
|
||||
fn test_source_lines_counted() {
|
||||
let src = "fn f() -> Int {\n return 1\n}\n";
|
||||
let report = do_lint(src);
|
||||
assert_eq!(report.source_lines, 3);
|
||||
}
|
||||
|
||||
// 24. Empty source → no crash
|
||||
#[test]
|
||||
fn test_empty_source() {
|
||||
let result = lint("");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Core linter — orchestrates arch rules, style rules, and format check.
|
||||
|
||||
use el_arch::{ArchChecker, Severity as ArchSeverity};
|
||||
|
||||
use crate::{
|
||||
error::LintError,
|
||||
report::{LintDiagnostic, LintReport, LintSeverity},
|
||||
rules,
|
||||
};
|
||||
|
||||
pub struct Linter {
|
||||
arch_checker: ArchChecker,
|
||||
}
|
||||
|
||||
impl Linter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
arch_checker: ArchChecker::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lint(&self, source: &str) -> Result<LintReport, LintError> {
|
||||
let tokens = el_lexer::tokenize(source)
|
||||
.map_err(|e| LintError::Lex(e.to_string()))?;
|
||||
let program = el_parser::parse(tokens, source.to_string())
|
||||
.map_err(|e| LintError::Parse(e.to_string()))?;
|
||||
|
||||
let mut diagnostics = Vec::new();
|
||||
|
||||
// 1. Run el-arch architectural rules.
|
||||
let arch_diags = self.arch_checker.check(&program);
|
||||
for d in arch_diags {
|
||||
diagnostics.push(LintDiagnostic {
|
||||
severity: match d.severity {
|
||||
ArchSeverity::Error => LintSeverity::Error,
|
||||
ArchSeverity::Warning => LintSeverity::Warning,
|
||||
},
|
||||
code: d.rule,
|
||||
message: d.message,
|
||||
location: d.location.unwrap_or_else(|| "unknown".into()),
|
||||
suggestion: None,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Run style rules.
|
||||
let style_diags = rules::check_style(&program);
|
||||
diagnostics.extend(style_diags);
|
||||
|
||||
// 3. Check whether the source is in canonical format.
|
||||
match el_fmt::is_canonical(source) {
|
||||
Ok(false) => {
|
||||
diagnostics.push(LintDiagnostic {
|
||||
severity: LintSeverity::Info,
|
||||
code: "I001".into(),
|
||||
message: "source is not in canonical format — run `el fmt` to fix".into(),
|
||||
location: "file".into(),
|
||||
suggestion: Some("el fmt --in-place <file.el>".into()),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let source_lines = source.lines().count();
|
||||
Ok(LintReport {
|
||||
diagnostics,
|
||||
file_path: None,
|
||||
source_lines,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Linter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Diagnostic report types for el-lint.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum LintSeverity {
|
||||
Error,
|
||||
Warning,
|
||||
Info,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LintDiagnostic {
|
||||
pub severity: LintSeverity,
|
||||
/// Rule code, e.g. "E001", "W002", "S001", "I001".
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
/// Human-readable location hint, e.g. "function foo" or "file".
|
||||
pub location: String,
|
||||
pub suggestion: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LintReport {
|
||||
pub diagnostics: Vec<LintDiagnostic>,
|
||||
pub file_path: Option<String>,
|
||||
pub source_lines: usize,
|
||||
}
|
||||
|
||||
impl LintReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
self.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.severity == LintSeverity::Error)
|
||||
}
|
||||
|
||||
pub fn error_count(&self) -> usize {
|
||||
self.diagnostics
|
||||
.iter()
|
||||
.filter(|d| d.severity == LintSeverity::Error)
|
||||
.count()
|
||||
}
|
||||
|
||||
pub fn warning_count(&self) -> usize {
|
||||
self.diagnostics
|
||||
.iter()
|
||||
.filter(|d| d.severity == LintSeverity::Warning)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Format as human-readable output (similar to rustc error output).
|
||||
pub fn display(&self) -> String {
|
||||
let mut out = String::new();
|
||||
for d in &self.diagnostics {
|
||||
let prefix = match d.severity {
|
||||
LintSeverity::Error => "error",
|
||||
LintSeverity::Warning => "warning",
|
||||
LintSeverity::Info => "info",
|
||||
};
|
||||
out.push_str(&format!("[{}] {}: {}\n", d.code, prefix, d.message));
|
||||
out.push_str(&format!(" --> {}\n", d.location));
|
||||
if let Some(suggestion) = &d.suggestion {
|
||||
out.push_str(&format!(" help: {}\n", suggestion));
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
if self.diagnostics.is_empty() {
|
||||
out.push_str("No issues found.\n");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Format as JSON for editor integration.
|
||||
pub fn to_json(&self) -> String {
|
||||
let items: Vec<serde_json::Value> = self
|
||||
.diagnostics
|
||||
.iter()
|
||||
.map(|d| {
|
||||
serde_json::json!({
|
||||
"severity": match d.severity {
|
||||
LintSeverity::Error => "error",
|
||||
LintSeverity::Warning => "warning",
|
||||
LintSeverity::Info => "info",
|
||||
},
|
||||
"code": d.code,
|
||||
"message": d.message,
|
||||
"location": d.location,
|
||||
"suggestion": d.suggestion,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&items).unwrap()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Style and correctness rules for el-lint (beyond el-arch architectural rules).
|
||||
|
||||
use el_parser::{Program, Stmt};
|
||||
|
||||
use crate::report::{LintDiagnostic, LintSeverity};
|
||||
|
||||
/// Run all style rules against the program and return diagnostics.
|
||||
pub fn check_style(program: &Program) -> Vec<LintDiagnostic> {
|
||||
let mut diags = Vec::new();
|
||||
|
||||
for stmt in &program.stmts {
|
||||
check_stmt(stmt, &mut diags);
|
||||
}
|
||||
|
||||
diags
|
||||
}
|
||||
|
||||
fn check_stmt(stmt: &Stmt, diags: &mut Vec<LintDiagnostic>) {
|
||||
match stmt {
|
||||
Stmt::FnDef { name, body, .. } => {
|
||||
// S001: Function body too long (>50 statements)
|
||||
if body.len() > 50 {
|
||||
diags.push(LintDiagnostic {
|
||||
severity: LintSeverity::Warning,
|
||||
code: "S001".into(),
|
||||
message: format!(
|
||||
"function `{name}` has {} statements — consider splitting",
|
||||
body.len()
|
||||
),
|
||||
location: format!("function {name}"),
|
||||
suggestion: Some(
|
||||
"extract sub-functions for each logical concern".into(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// S002: Function name not snake_case
|
||||
if name.chars().any(|c| c.is_uppercase()) {
|
||||
diags.push(LintDiagnostic {
|
||||
severity: LintSeverity::Warning,
|
||||
code: "S002".into(),
|
||||
message: format!("function `{name}` should be snake_case"),
|
||||
location: format!("function {name}"),
|
||||
suggestion: Some(format!("rename to `{}`", to_snake_case(name))),
|
||||
});
|
||||
}
|
||||
|
||||
// S003: Empty function body
|
||||
if body.is_empty() {
|
||||
diags.push(LintDiagnostic {
|
||||
severity: LintSeverity::Info,
|
||||
code: "S003".into(),
|
||||
message: format!("function `{name}` has an empty body"),
|
||||
location: format!("function {name}"),
|
||||
suggestion: Some("add implementation or remove if unused".into()),
|
||||
});
|
||||
}
|
||||
|
||||
// Recurse into nested function defs
|
||||
for s in body {
|
||||
check_stmt(s, diags);
|
||||
}
|
||||
}
|
||||
|
||||
Stmt::TypeDef { name, .. } => {
|
||||
// S004: Type name not PascalCase
|
||||
if !is_pascal_case(name) {
|
||||
diags.push(LintDiagnostic {
|
||||
severity: LintSeverity::Warning,
|
||||
code: "S004".into(),
|
||||
message: format!("type `{name}` should be PascalCase"),
|
||||
location: format!("type {name}"),
|
||||
suggestion: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Stmt::EnumDef { name, .. } => {
|
||||
// S004 also applies to enums
|
||||
if !is_pascal_case(name) {
|
||||
diags.push(LintDiagnostic {
|
||||
severity: LintSeverity::Warning,
|
||||
code: "S004".into(),
|
||||
message: format!("enum `{name}` should be PascalCase"),
|
||||
location: format!("enum {name}"),
|
||||
suggestion: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Stmt::ImplDef { methods, .. } => {
|
||||
for m in methods {
|
||||
check_stmt(m, diags);
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert CamelCase/mixed to snake_case.
|
||||
fn to_snake_case(s: &str) -> String {
|
||||
let mut result = String::new();
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if c.is_uppercase() && i > 0 {
|
||||
result.push('_');
|
||||
}
|
||||
result.push(c.to_lowercase().next().unwrap());
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Returns true if the first character is uppercase (PascalCase convention).
|
||||
fn is_pascal_case(s: &str) -> bool {
|
||||
s.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
|
||||
}
|
||||
Reference in New Issue
Block a user