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 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 18:38:42 -05:00
parent 400acc3f35
commit fbb7ddb6c3
11 changed files with 312 additions and 7 deletions

View File

@@ -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<String>,
pub name: Ident,
pub type_params: Vec<Ident>,
pub params: Vec<Parameter>,
pub return_type: TypeExpr,
/// Optional JS name override: extern fn foo(...): T = "jsFoo"
pub js_name: Option<String>,
pub span: Span,
}
/// Type expressions
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TypeExpr {

View File

@@ -71,6 +71,8 @@ pub struct JsBackend {
var_substitutions: HashMap<String, String>,
/// Effects actually used in the program (for tree-shaking runtime)
used_effects: HashSet<String>,
/// Extern function names mapped to their JS names
extern_fns: HashMap<String, String>,
}
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)
}

View File

@@ -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::<Vec<_>>()
.join(", "),
);
self.write(">");
}
// Parameters
self.write("(");
let params: Vec<String> = ext
.params
.iter()
.map(|p| format!("{}: {}", p.name.name, self.format_type_expr(&p.typ)))
.collect();
self.write(&params.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);

View File

@@ -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, "<extern fn {}>", 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),

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -2288,6 +2288,29 @@ fn extract_module_doc(source: &str, path: &str) -> Result<ModuleDoc, String> {
is_public: matches!(t.visibility, ast::Visibility::Public),
});
}
ast::Declaration::ExternFn(ext) => {
let params: Vec<String> = 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<String> = 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""#);

View File

@@ -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());
}
_ => {}
}
}

View File

@@ -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<T>(params): ReturnType = "jsName"
fn parse_extern_fn_decl(&mut self, visibility: Visibility, doc: Option<String>) -> Result<ExternFnDecl, ParseError> {
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<String>) -> Result<EffectDecl, ParseError> {
let start = self.current_span();

View File

@@ -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<String> = 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);
}
}
}

View File

@@ -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<Type> = 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));
}
}
}