From fbb7ddb6c3259b6e4565f6a487db4f9907f89dda Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Fri, 20 Feb 2026 18:38:42 -0500 Subject: [PATCH] feat: add extern fn declarations for JS FFI Adds `extern fn` syntax for declaring external JavaScript functions: extern fn getElementById(id: String): Element extern fn getContext(el: Element, kind: String): CanvasCtx = "getContext" pub extern fn alert(msg: String): Unit Changes across 11 files: - Lexer: `extern` keyword - AST: `ExternFnDecl` struct + `Declaration::ExternFn` variant - Parser: parse `extern fn` with optional `= "jsName"` override - Typechecker: register extern fn type signatures - Interpreter: ExternFn value with clear error on call - JS backend: emit extern fn calls using JS name (no _lux suffix) - C backend: silently skips extern fns - Formatter, linter, modules, symbol_table: handle new variant Co-Authored-By: Claude Opus 4.6 --- src/ast.rs | 17 +++++++ src/codegen/js_backend.rs | 15 ++++++ src/formatter.rs | 52 +++++++++++++++++++-- src/interpreter.rs | 33 +++++++++++++ src/lexer.rs | 3 ++ src/linter.rs | 3 ++ src/main.rs | 97 +++++++++++++++++++++++++++++++++++++++ src/modules.rs | 4 ++ src/parser.rs | 54 +++++++++++++++++++++- src/symbol_table.rs | 24 ++++++++++ src/typechecker.rs | 17 +++++-- 11 files changed, 312 insertions(+), 7 deletions(-) diff --git a/src/ast.rs b/src/ast.rs index 08392b0..110603c 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -221,6 +221,8 @@ pub enum Declaration { Trait(TraitDecl), /// Trait implementation: impl Trait for Type { ... } Impl(ImplDecl), + /// Extern function declaration (FFI): extern fn name(params): ReturnType + ExternFn(ExternFnDecl), } /// Function declaration @@ -428,6 +430,21 @@ pub struct ImplMethod { pub span: Span, } +/// Extern function declaration (FFI) +#[derive(Debug, Clone)] +pub struct ExternFnDecl { + pub visibility: Visibility, + /// Documentation comment + pub doc: Option, + pub name: Ident, + pub type_params: Vec, + pub params: Vec, + pub return_type: TypeExpr, + /// Optional JS name override: extern fn foo(...): T = "jsFoo" + pub js_name: Option, + pub span: Span, +} + /// Type expressions #[derive(Debug, Clone, PartialEq, Eq)] pub enum TypeExpr { diff --git a/src/codegen/js_backend.rs b/src/codegen/js_backend.rs index 6330a6d..283c298 100644 --- a/src/codegen/js_backend.rs +++ b/src/codegen/js_backend.rs @@ -71,6 +71,8 @@ pub struct JsBackend { var_substitutions: HashMap, /// Effects actually used in the program (for tree-shaking runtime) used_effects: HashSet, + /// Extern function names mapped to their JS names + extern_fns: HashMap, } impl JsBackend { @@ -93,6 +95,7 @@ impl JsBackend { has_handlers: false, var_substitutions: HashMap::new(), used_effects: HashSet::new(), + extern_fns: HashMap::new(), } } @@ -112,6 +115,14 @@ impl JsBackend { Declaration::Type(t) => { self.collect_type(t)?; } + Declaration::ExternFn(ext) => { + let js_name = ext + .js_name + .clone() + .unwrap_or_else(|| ext.name.name.clone()); + self.extern_fns.insert(ext.name.name.clone(), js_name); + self.functions.insert(ext.name.name.clone()); + } _ => {} } } @@ -2723,6 +2734,10 @@ impl JsBackend { /// Mangle a Lux name to a valid JavaScript name fn mangle_name(&self, name: &str) -> String { + // Extern functions use their JS name directly (no mangling) + if let Some(js_name) = self.extern_fns.get(name) { + return js_name.clone(); + } format!("{}_lux", name) } diff --git a/src/formatter.rs b/src/formatter.rs index dbace69..01696ba 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -3,9 +3,9 @@ //! Formats Lux source code according to standard style guidelines. use crate::ast::{ - BehavioralProperty, BinaryOp, Declaration, EffectDecl, Expr, FunctionDecl, HandlerDecl, - ImplDecl, ImplMethod, LetDecl, Literal, LiteralKind, Pattern, Program, Statement, TraitDecl, - TypeDecl, TypeDef, TypeExpr, UnaryOp, VariantFields, + BehavioralProperty, BinaryOp, Declaration, EffectDecl, ExternFnDecl, Expr, FunctionDecl, + HandlerDecl, ImplDecl, ImplMethod, LetDecl, Literal, LiteralKind, Pattern, Program, Statement, + TraitDecl, TypeDecl, TypeDef, TypeExpr, UnaryOp, VariantFields, Visibility, }; use crate::lexer::Lexer; use crate::parser::Parser; @@ -103,9 +103,55 @@ impl Formatter { Declaration::Handler(h) => self.format_handler(h), Declaration::Trait(t) => self.format_trait(t), Declaration::Impl(i) => self.format_impl(i), + Declaration::ExternFn(e) => self.format_extern_fn(e), } } + fn format_extern_fn(&mut self, ext: &ExternFnDecl) { + let indent = self.indent(); + self.write(&indent); + + if ext.visibility == Visibility::Public { + self.write("pub "); + } + + self.write("extern fn "); + self.write(&ext.name.name); + + // Type parameters + if !ext.type_params.is_empty() { + self.write("<"); + self.write( + &ext.type_params + .iter() + .map(|p| p.name.clone()) + .collect::>() + .join(", "), + ); + self.write(">"); + } + + // Parameters + self.write("("); + let params: Vec = ext + .params + .iter() + .map(|p| format!("{}: {}", p.name.name, self.format_type_expr(&p.typ))) + .collect(); + self.write(¶ms.join(", ")); + self.write("): "); + + // Return type + self.write(&self.format_type_expr(&ext.return_type)); + + // Optional JS name + if let Some(js_name) = &ext.js_name { + self.write(&format!(" = \"{}\"", js_name)); + } + + self.newline(); + } + fn format_function(&mut self, func: &FunctionDecl) { let indent = self.indent(); self.write(&indent); diff --git a/src/interpreter.rs b/src/interpreter.rs index 3ff1666..7681544 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -176,6 +176,11 @@ pub enum Value { }, /// JSON value (for JSON parsing/manipulation) Json(serde_json::Value), + /// Extern function (FFI — only callable from JS backend) + ExternFn { + name: String, + arity: usize, + }, } impl Value { @@ -197,6 +202,7 @@ impl Value { Value::Constructor { .. } => "Constructor", Value::Versioned { .. } => "Versioned", Value::Json(_) => "Json", + Value::ExternFn { .. } => "ExternFn", } } @@ -407,6 +413,7 @@ impl fmt::Display for Value { write!(f, "{} @v{}", value, version) } Value::Json(json) => write!(f, "{}", json), + Value::ExternFn { name, .. } => write!(f, "", name), } } } @@ -1405,6 +1412,25 @@ impl Interpreter { Ok(Value::Unit) } + Declaration::ExternFn(ext) => { + // Register a placeholder that errors at runtime + let name = ext.name.name.clone(); + let arity = ext.params.len(); + // Create a closure that produces a clear error + let closure = Closure { + params: ext.params.iter().map(|p| p.name.name.clone()).collect(), + body: Expr::Literal(crate::ast::Literal { + kind: crate::ast::LiteralKind::Unit, + span: ext.span, + }), + env: self.global_env.clone(), + }; + // We store an ExternFn marker value + self.global_env + .define(&name, Value::ExternFn { name: name.clone(), arity }); + Ok(Value::Unit) + } + Declaration::Effect(_) | Declaration::Trait(_) | Declaration::Impl(_) => { // These are compile-time only Ok(Value::Unit) @@ -1924,6 +1950,13 @@ impl Interpreter { })) } Value::Builtin(builtin) => self.eval_builtin(builtin, args, span), + Value::ExternFn { name, .. } => Err(RuntimeError { + message: format!( + "Extern function '{}' can only be called when compiled to JavaScript (use `lux build --target js`)", + name + ), + span: Some(span), + }), v => Err(RuntimeError { message: format!("Cannot call {}", v.type_name()), span: Some(span), diff --git a/src/lexer.rs b/src/lexer.rs index b6fd015..90f4f6b 100644 --- a/src/lexer.rs +++ b/src/lexer.rs @@ -55,6 +55,7 @@ pub enum TokenKind { Trait, // trait (for type classes) Impl, // impl (for trait implementations) For, // for (in impl Trait for Type) + Extern, // extern (for FFI declarations) // Documentation DocComment(String), // /// doc comment @@ -152,6 +153,7 @@ impl fmt::Display for TokenKind { TokenKind::Trait => write!(f, "trait"), TokenKind::Impl => write!(f, "impl"), TokenKind::For => write!(f, "for"), + TokenKind::Extern => write!(f, "extern"), TokenKind::DocComment(s) => write!(f, "/// {}", s), TokenKind::Is => write!(f, "is"), TokenKind::Pure => write!(f, "pure"), @@ -1008,6 +1010,7 @@ impl<'a> Lexer<'a> { "trait" => TokenKind::Trait, "impl" => TokenKind::Impl, "for" => TokenKind::For, + "extern" => TokenKind::Extern, "is" => TokenKind::Is, "pure" => TokenKind::Pure, "total" => TokenKind::Total, diff --git a/src/linter.rs b/src/linter.rs index 07b4119..a90b170 100644 --- a/src/linter.rs +++ b/src/linter.rs @@ -403,6 +403,9 @@ impl Linter { Declaration::Function(f) => { self.defined_functions.insert(f.name.name.clone()); } + Declaration::ExternFn(e) => { + self.defined_functions.insert(e.name.name.clone()); + } Declaration::Let(l) => { self.define_var(&l.name.name); } diff --git a/src/main.rs b/src/main.rs index 3edb774..7954841 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2288,6 +2288,29 @@ fn extract_module_doc(source: &str, path: &str) -> Result { is_public: matches!(t.visibility, ast::Visibility::Public), }); } + ast::Declaration::ExternFn(ext) => { + let params: Vec = ext.params.iter() + .map(|p| format!("{}: {}", p.name.name, format_type(&p.typ))) + .collect(); + let js_note = ext.js_name.as_ref() + .map(|n| format!(" = \"{}\"", n)) + .unwrap_or_default(); + let signature = format!( + "extern fn {}({}): {}{}", + ext.name.name, + params.join(", "), + format_type(&ext.return_type), + js_note + ); + let doc = extract_doc_comment(source, ext.span.start); + functions.push(FunctionDoc { + name: ext.name.name.clone(), + signature, + description: doc, + is_public: matches!(ext.visibility, ast::Visibility::Public), + properties: vec![], + }); + } ast::Declaration::Effect(e) => { let doc = extract_doc_comment(source, e.span.start); let ops: Vec = e.operations.iter() @@ -4147,6 +4170,80 @@ c")"#; assert!(result.is_err()); } + #[test] + fn test_extern_fn_parse() { + // Extern fn should parse successfully + let source = r#" + extern fn getElementById(id: String): String + let x = 42 + "#; + assert_eq!(eval(source).unwrap(), "42"); + } + + #[test] + fn test_extern_fn_with_js_name() { + // Extern fn with JS name override + let source = r#" + extern fn getCtx(el: String, kind: String): String = "getContext" + let x = 42 + "#; + assert_eq!(eval(source).unwrap(), "42"); + } + + #[test] + fn test_extern_fn_call_errors_in_interpreter() { + // Calling an extern fn in the interpreter should produce a clear error + let source = r#" + extern fn alert(msg: String): Unit + let x = alert("hello") + "#; + let result = eval(source); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("extern") || err.contains("Extern") || err.contains("JavaScript"), + "Error should mention extern/JavaScript: {}", err); + } + + #[test] + fn test_pub_extern_fn() { + // pub extern fn should parse + let source = r#" + pub extern fn requestAnimationFrame(callback: fn(): Unit): Int + let x = 42 + "#; + assert_eq!(eval(source).unwrap(), "42"); + } + + #[test] + fn test_extern_fn_js_codegen() { + // Verify JS backend emits extern fn calls without _lux suffix + use crate::codegen::js_backend::JsBackend; + use crate::parser::Parser; + use crate::lexer::Lexer; + + let source = r#" + extern fn getElementById(id: String): String + extern fn getContext(el: String, kind: String): String = "getContext" + fn main(): Unit = { + let el = getElementById("canvas") + let ctx = getContext(el, "2d") + () + } + "#; + + let tokens = Lexer::new(source).tokenize().unwrap(); + let program = Parser::new(tokens).parse_program().unwrap(); + let mut backend = JsBackend::new(); + let js = backend.generate(&program).unwrap(); + + // getElementById should appear as-is (no _lux suffix) + assert!(js.contains("getElementById("), "JS should call getElementById directly: {}", js); + // getContext should use the JS name override + assert!(js.contains("getContext("), "JS should call getContext directly: {}", js); + // main should still be mangled + assert!(js.contains("main_lux"), "main should be mangled: {}", js); + } + #[test] fn test_invalid_escape_sequence() { let result = eval(r#"let x = "\z""#); diff --git a/src/modules.rs b/src/modules.rs index 4943390..68bef98 100644 --- a/src/modules.rs +++ b/src/modules.rs @@ -52,6 +52,7 @@ impl Module { Declaration::Let(l) => l.visibility == Visibility::Public, Declaration::Type(t) => t.visibility == Visibility::Public, Declaration::Trait(t) => t.visibility == Visibility::Public, + Declaration::ExternFn(e) => e.visibility == Visibility::Public, // Effects, handlers, and impls are always public for now Declaration::Effect(_) | Declaration::Handler(_) | Declaration::Impl(_) => true, } @@ -294,6 +295,9 @@ impl ModuleLoader { // Handlers are always exported exports.insert(h.name.name.clone()); } + Declaration::ExternFn(e) if e.visibility == Visibility::Public => { + exports.insert(e.name.name.clone()); + } _ => {} } } diff --git a/src/parser.rs b/src/parser.rs index 07891a7..bc40179 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -238,6 +238,7 @@ impl Parser { match self.peek_kind() { TokenKind::Fn => Ok(Declaration::Function(self.parse_function_decl(visibility, doc)?)), + TokenKind::Extern => Ok(Declaration::ExternFn(self.parse_extern_fn_decl(visibility, doc)?)), TokenKind::Effect => Ok(Declaration::Effect(self.parse_effect_decl(doc)?)), TokenKind::Handler => Ok(Declaration::Handler(self.parse_handler_decl()?)), TokenKind::Type => Ok(Declaration::Type(self.parse_type_decl(visibility, doc)?)), @@ -246,7 +247,7 @@ impl Parser { TokenKind::Impl => Ok(Declaration::Impl(self.parse_impl_decl()?)), TokenKind::Run => Err(self.error("Bare 'run' expressions are not allowed at top level. Use 'let _ = run ...' or 'let result = run ...'")), TokenKind::Handle => Err(self.error("Bare 'handle' expressions are not allowed at top level. Use 'let _ = handle ...' or 'let result = handle ...'")), - _ => Err(self.error("Expected declaration (fn, effect, handler, type, trait, impl, or let)")), + _ => Err(self.error("Expected declaration (fn, extern, effect, handler, type, trait, impl, or let)")), } } @@ -323,6 +324,57 @@ impl Parser { }) } + /// Parse extern function declaration: extern fn name(params): ReturnType = "jsName" + fn parse_extern_fn_decl(&mut self, visibility: Visibility, doc: Option) -> Result { + let start = self.current_span(); + self.expect(TokenKind::Extern)?; + self.expect(TokenKind::Fn)?; + + let name = self.parse_ident()?; + + // Optional type parameters + let type_params = if self.check(TokenKind::Lt) { + self.parse_type_params()? + } else { + Vec::new() + }; + + self.expect(TokenKind::LParen)?; + let params = self.parse_params()?; + self.expect(TokenKind::RParen)?; + + // Return type + self.expect(TokenKind::Colon)?; + let return_type = self.parse_type()?; + + // Optional JS name override: = "jsName" + let js_name = if self.check(TokenKind::Eq) { + self.advance(); + match self.peek_kind() { + TokenKind::String(s) => { + let name = s.clone(); + self.advance(); + Some(name) + } + _ => return Err(self.error("Expected string literal for JS name in extern fn")), + } + } else { + None + }; + + let span = start.merge(self.previous_span()); + Ok(ExternFnDecl { + visibility, + doc, + name, + type_params, + params, + return_type, + js_name, + span, + }) + } + /// Parse effect declaration fn parse_effect_decl(&mut self, doc: Option) -> Result { let start = self.current_span(); diff --git a/src/symbol_table.rs b/src/symbol_table.rs index a7b6962..fde3c57 100644 --- a/src/symbol_table.rs +++ b/src/symbol_table.rs @@ -245,6 +245,30 @@ impl SymbolTable { Declaration::Handler(h) => self.visit_handler(h, scope_idx), Declaration::Trait(t) => self.visit_trait(t, scope_idx), Declaration::Impl(i) => self.visit_impl(i, scope_idx), + Declaration::ExternFn(ext) => { + let is_public = matches!(ext.visibility, Visibility::Public); + let params: Vec = ext + .params + .iter() + .map(|p| format!("{}: {}", p.name.name, self.type_expr_to_string(&p.typ))) + .collect(); + let sig = format!( + "extern fn {}({}): {}", + ext.name.name, + params.join(", "), + self.type_expr_to_string(&ext.return_type) + ); + let mut symbol = self.new_symbol( + ext.name.name.clone(), + SymbolKind::Function, + ext.span, + Some(sig), + is_public, + ); + symbol.documentation = ext.doc.clone(); + let id = self.add_symbol(scope_idx, symbol); + self.add_reference(id, ext.name.span, true, true); + } } } diff --git a/src/typechecker.rs b/src/typechecker.rs index 7f9f8cb..f2182e3 100644 --- a/src/typechecker.rs +++ b/src/typechecker.rs @@ -5,9 +5,9 @@ use std::collections::HashMap; use crate::ast::{ - self, BinaryOp, Declaration, EffectDecl, Expr, FunctionDecl, HandlerDecl, Ident, ImplDecl, - ImportDecl, LetDecl, Literal, LiteralKind, MatchArm, Parameter, Pattern, Program, Span, - Statement, TraitDecl, TypeDecl, TypeExpr, UnaryOp, VariantFields, + self, BinaryOp, Declaration, EffectDecl, ExternFnDecl, Expr, FunctionDecl, HandlerDecl, Ident, + ImplDecl, ImportDecl, LetDecl, Literal, LiteralKind, MatchArm, Parameter, Pattern, Program, + Span, Statement, TraitDecl, TypeDecl, TypeExpr, UnaryOp, VariantFields, }; use crate::diagnostics::{find_similar_names, format_did_you_mean, Diagnostic, ErrorCode, Severity}; use crate::exhaustiveness::{check_exhaustiveness, missing_patterns_hint}; @@ -1227,6 +1227,17 @@ impl TypeChecker { let trait_impl = self.collect_impl(impl_decl); self.env.trait_impls.push(trait_impl); } + Declaration::ExternFn(ext) => { + // Register extern fn type signature (like a regular function but no body) + let param_types: Vec = ext + .params + .iter() + .map(|p| self.resolve_type(&p.typ)) + .collect(); + let return_type = self.resolve_type(&ext.return_type); + let fn_type = Type::function(param_types, return_type); + self.env.bind(&ext.name.name, TypeScheme::mono(fn_type)); + } } }