feat: add extern let declarations for JS FFI

Add support for `extern let name: Type` and `extern let name: Type = "jsName"`
syntax for declaring external JavaScript values. This follows the same pattern
as extern fn across all compiler passes: parser, typechecker, interpreter
(runtime error placeholder), JS backend (emits JS name directly without
mangling), formatter, linter, modules, and symbol table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 19:29:44 -05:00
parent 1b629aaae4
commit 667a94b4dc
10 changed files with 161 additions and 2 deletions

View File

@@ -223,6 +223,8 @@ pub enum Declaration {
Impl(ImplDecl), Impl(ImplDecl),
/// Extern function declaration (FFI): extern fn name(params): ReturnType /// Extern function declaration (FFI): extern fn name(params): ReturnType
ExternFn(ExternFnDecl), ExternFn(ExternFnDecl),
/// Extern let declaration (FFI): extern let name: Type
ExternLet(ExternLetDecl),
} }
/// Function declaration /// Function declaration
@@ -445,6 +447,19 @@ pub struct ExternFnDecl {
pub span: Span, pub span: Span,
} }
/// Extern let declaration (FFI)
#[derive(Debug, Clone)]
pub struct ExternLetDecl {
pub visibility: Visibility,
/// Documentation comment
pub doc: Option<String>,
pub name: Ident,
pub typ: TypeExpr,
/// Optional JS name override: extern let foo: T = "window.foo"
pub js_name: Option<String>,
pub span: Span,
}
/// Type expressions /// Type expressions
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TypeExpr { pub enum TypeExpr {

View File

@@ -73,6 +73,8 @@ pub struct JsBackend {
used_effects: HashSet<String>, used_effects: HashSet<String>,
/// Extern function names mapped to their JS names /// Extern function names mapped to their JS names
extern_fns: HashMap<String, String>, extern_fns: HashMap<String, String>,
/// Extern let names mapped to their JS names
extern_lets: HashMap<String, String>,
} }
impl JsBackend { impl JsBackend {
@@ -96,6 +98,7 @@ impl JsBackend {
var_substitutions: HashMap::new(), var_substitutions: HashMap::new(),
used_effects: HashSet::new(), used_effects: HashSet::new(),
extern_fns: HashMap::new(), extern_fns: HashMap::new(),
extern_lets: HashMap::new(),
} }
} }
@@ -123,6 +126,13 @@ impl JsBackend {
self.extern_fns.insert(ext.name.name.clone(), js_name); self.extern_fns.insert(ext.name.name.clone(), js_name);
self.functions.insert(ext.name.name.clone()); self.functions.insert(ext.name.name.clone());
} }
Declaration::ExternLet(ext) => {
let js_name = ext
.js_name
.clone()
.unwrap_or_else(|| ext.name.name.clone());
self.extern_lets.insert(ext.name.name.clone(), js_name);
}
_ => {} _ => {}
} }
} }
@@ -1097,6 +1107,9 @@ impl JsBackend {
} else if self.functions.contains(&ident.name) { } else if self.functions.contains(&ident.name) {
// Function reference (used as value) // Function reference (used as value)
Ok(self.mangle_name(&ident.name)) Ok(self.mangle_name(&ident.name))
} else if let Some(js_name) = self.extern_lets.get(&ident.name) {
// Extern let: use JS name directly (no mangling)
Ok(js_name.clone())
} else { } else {
Ok(self.escape_js_keyword(&ident.name)) Ok(self.escape_js_keyword(&ident.name))
} }

View File

@@ -3,7 +3,7 @@
//! Formats Lux source code according to standard style guidelines. //! Formats Lux source code according to standard style guidelines.
use crate::ast::{ use crate::ast::{
BehavioralProperty, BinaryOp, Declaration, EffectDecl, ExternFnDecl, Expr, FunctionDecl, BehavioralProperty, BinaryOp, Declaration, EffectDecl, ExternFnDecl, ExternLetDecl, Expr, FunctionDecl,
HandlerDecl, ImplDecl, ImplMethod, LetDecl, Literal, LiteralKind, Pattern, Program, Statement, HandlerDecl, ImplDecl, ImplMethod, LetDecl, Literal, LiteralKind, Pattern, Program, Statement,
TraitDecl, TypeDecl, TypeDef, TypeExpr, UnaryOp, VariantFields, Visibility, TraitDecl, TypeDecl, TypeDef, TypeExpr, UnaryOp, VariantFields, Visibility,
}; };
@@ -104,6 +104,7 @@ impl Formatter {
Declaration::Trait(t) => self.format_trait(t), Declaration::Trait(t) => self.format_trait(t),
Declaration::Impl(i) => self.format_impl(i), Declaration::Impl(i) => self.format_impl(i),
Declaration::ExternFn(e) => self.format_extern_fn(e), Declaration::ExternFn(e) => self.format_extern_fn(e),
Declaration::ExternLet(e) => self.format_extern_let(e),
} }
} }
@@ -152,6 +153,27 @@ impl Formatter {
self.newline(); self.newline();
} }
fn format_extern_let(&mut self, ext: &ExternLetDecl) {
let indent = self.indent();
self.write(&indent);
if ext.visibility == Visibility::Public {
self.write("pub ");
}
self.write("extern let ");
self.write(&ext.name.name);
self.write(": ");
self.write(&self.format_type_expr(&ext.typ));
// 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) { fn format_function(&mut self, func: &FunctionDecl) {
let indent = self.indent(); let indent = self.indent();
self.write(&indent); self.write(&indent);

View File

@@ -1431,6 +1431,14 @@ impl Interpreter {
Ok(Value::Unit) Ok(Value::Unit)
} }
Declaration::ExternLet(ext) => {
// Register a placeholder that errors at runtime (extern lets only work in JS)
let name = ext.name.name.clone();
self.global_env
.define(&name, Value::ExternFn { name: name.clone(), arity: 0 });
Ok(Value::Unit)
}
Declaration::Effect(_) | Declaration::Trait(_) | Declaration::Impl(_) => { Declaration::Effect(_) | Declaration::Trait(_) | Declaration::Impl(_) => {
// These are compile-time only // These are compile-time only
Ok(Value::Unit) Ok(Value::Unit)

View File

@@ -406,6 +406,9 @@ impl Linter {
Declaration::ExternFn(e) => { Declaration::ExternFn(e) => {
self.defined_functions.insert(e.name.name.clone()); self.defined_functions.insert(e.name.name.clone());
} }
Declaration::ExternLet(e) => {
self.define_var(&e.name.name);
}
Declaration::Let(l) => { Declaration::Let(l) => {
self.define_var(&l.name.name); self.define_var(&l.name.name);
} }

View File

@@ -2311,6 +2311,25 @@ fn extract_module_doc(source: &str, path: &str) -> Result<ModuleDoc, String> {
properties: vec![], properties: vec![],
}); });
} }
ast::Declaration::ExternLet(ext) => {
let js_note = ext.js_name.as_ref()
.map(|n| format!(" = \"{}\"", n))
.unwrap_or_default();
let signature = format!(
"extern let {}: {}{}",
ext.name.name,
format_type(&ext.typ),
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) => { ast::Declaration::Effect(e) => {
let doc = extract_doc_comment(source, e.span.start); let doc = extract_doc_comment(source, e.span.start);
let ops: Vec<String> = e.operations.iter() let ops: Vec<String> = e.operations.iter()

View File

@@ -53,6 +53,7 @@ impl Module {
Declaration::Type(t) => t.visibility == Visibility::Public, Declaration::Type(t) => t.visibility == Visibility::Public,
Declaration::Trait(t) => t.visibility == Visibility::Public, Declaration::Trait(t) => t.visibility == Visibility::Public,
Declaration::ExternFn(e) => e.visibility == Visibility::Public, Declaration::ExternFn(e) => e.visibility == Visibility::Public,
Declaration::ExternLet(e) => e.visibility == Visibility::Public,
// Effects, handlers, and impls are always public for now // Effects, handlers, and impls are always public for now
Declaration::Effect(_) | Declaration::Handler(_) | Declaration::Impl(_) => true, Declaration::Effect(_) | Declaration::Handler(_) | Declaration::Impl(_) => true,
} }
@@ -298,6 +299,9 @@ impl ModuleLoader {
Declaration::ExternFn(e) if e.visibility == Visibility::Public => { Declaration::ExternFn(e) if e.visibility == Visibility::Public => {
exports.insert(e.name.name.clone()); exports.insert(e.name.name.clone());
} }
Declaration::ExternLet(e) if e.visibility == Visibility::Public => {
exports.insert(e.name.name.clone());
}
_ => {} _ => {}
} }
} }

View File

@@ -238,7 +238,7 @@ impl Parser {
match self.peek_kind() { match self.peek_kind() {
TokenKind::Fn => Ok(Declaration::Function(self.parse_function_decl(visibility, doc)?)), TokenKind::Fn => Ok(Declaration::Function(self.parse_function_decl(visibility, doc)?)),
TokenKind::Extern => Ok(Declaration::ExternFn(self.parse_extern_fn_decl(visibility, doc)?)), TokenKind::Extern => self.parse_extern_decl(visibility, doc),
TokenKind::Effect => Ok(Declaration::Effect(self.parse_effect_decl(doc)?)), TokenKind::Effect => Ok(Declaration::Effect(self.parse_effect_decl(doc)?)),
TokenKind::Handler => Ok(Declaration::Handler(self.parse_handler_decl()?)), TokenKind::Handler => Ok(Declaration::Handler(self.parse_handler_decl()?)),
TokenKind::Type => Ok(Declaration::Type(self.parse_type_decl(visibility, doc)?)), TokenKind::Type => Ok(Declaration::Type(self.parse_type_decl(visibility, doc)?)),
@@ -324,6 +324,58 @@ impl Parser {
}) })
} }
/// Parse extern declaration: dispatch to extern fn or extern let
fn parse_extern_decl(&mut self, visibility: Visibility, doc: Option<String>) -> Result<Declaration, ParseError> {
// Peek past 'extern' to see if it's 'fn' or 'let'
if self.pos + 1 < self.tokens.len() {
match &self.tokens[self.pos + 1].kind {
TokenKind::Fn => Ok(Declaration::ExternFn(self.parse_extern_fn_decl(visibility, doc)?)),
TokenKind::Let => Ok(Declaration::ExternLet(self.parse_extern_let_decl(visibility, doc)?)),
_ => Err(self.error("Expected 'fn' or 'let' after 'extern'")),
}
} else {
Err(self.error("Expected 'fn' or 'let' after 'extern'"))
}
}
/// Parse extern let declaration: extern let name: Type = "jsName"
fn parse_extern_let_decl(&mut self, visibility: Visibility, doc: Option<String>) -> Result<ExternLetDecl, ParseError> {
let start = self.current_span();
self.expect(TokenKind::Extern)?;
self.expect(TokenKind::Let)?;
let name = self.parse_ident()?;
// Type annotation
self.expect(TokenKind::Colon)?;
let typ = 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 let")),
}
} else {
None
};
let span = start.merge(self.previous_span());
Ok(ExternLetDecl {
visibility,
doc,
name,
typ,
js_name,
span,
})
}
/// Parse extern function declaration: extern fn name<T>(params): ReturnType = "jsName" /// 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> { fn parse_extern_fn_decl(&mut self, visibility: Visibility, doc: Option<String>) -> Result<ExternFnDecl, ParseError> {
let start = self.current_span(); let start = self.current_span();

View File

@@ -269,6 +269,24 @@ impl SymbolTable {
let id = self.add_symbol(scope_idx, symbol); let id = self.add_symbol(scope_idx, symbol);
self.add_reference(id, ext.name.span, true, true); self.add_reference(id, ext.name.span, true, true);
} }
Declaration::ExternLet(ext) => {
let is_public = matches!(ext.visibility, Visibility::Public);
let sig = format!(
"extern let {}: {}",
ext.name.name,
self.type_expr_to_string(&ext.typ)
);
let mut symbol = self.new_symbol(
ext.name.name.clone(),
SymbolKind::Variable,
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

@@ -1238,6 +1238,11 @@ impl TypeChecker {
let fn_type = Type::function(param_types, return_type); let fn_type = Type::function(param_types, return_type);
self.env.bind(&ext.name.name, TypeScheme::mono(fn_type)); self.env.bind(&ext.name.name, TypeScheme::mono(fn_type));
} }
Declaration::ExternLet(ext) => {
// Register extern let with its declared type
let typ = self.resolve_type(&ext.typ);
self.env.bind(&ext.name.name, TypeScheme::mono(typ));
}
} }
} }