From f94f0558f415fc810fdf465ce2a3c86f883bcbc5 Mon Sep 17 00:00:00 2001 From: Taylor Holliday Date: Sat, 15 Aug 2026 15:28:34 -0700 Subject: [PATCH 1/3] Make entry points optional Previously a requested entry point that the source didn't define was a hard compile error. Now the backends skip undefined entry points and the client decides whether a missing one matters. - DeclTable::find_entry_point() is the single Option-returning lookup; monomorph, Cranelift, VM, stack, LLVM JIT and AOT all skip misses. - program.entry is the first entry point actually found; the VM/stack/asm run helpers early-return on an entry-less program. - Compiler gains found_entry_points()/missing_entry_points(); running still errors when nothing was found. - FFI entry-point slots are Option so indices stay aligned with the names passed to lyte_compiler_new when one is missing. New lyte_program_has_entry_point(); Swift entryPoint(named:/at:) returns nil for an undefined one. - The CLI is a client that requires them: it reports every missing entry point and exits 1 before compiling, AOT, or running. --check no longer needs an entry point. Also fixes --entry under the Cranelift JIT, which compiled and ran "main" regardless of the requested entry points. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KU75jHWHD6nzWZb5tau87e --- Sources/CLyte/include/lyte.h | 9 ++- Sources/Lyte/Lyte.swift | 8 ++- cli/src/main.rs | 34 ++++++--- src/compiler.rs | 129 +++++++++++++++++++++++++++++++++- src/decl_table.rs | 12 ++++ src/ffi.rs | 76 +++++++++++++------- src/jit.rs | 15 ++-- src/llvm_aot.rs | 11 ++- src/llvm_jit.rs | 30 +++++--- src/monomorph_pass.rs | 30 ++++---- src/stack_codegen.rs | 27 ++++--- src/stack_interp_bridge.rs | 4 ++ src/stack_vm.rs | 4 ++ src/vm.rs | 4 ++ src/vm_arm64.rs | 4 ++ src/vm_codegen.rs | 27 ++++--- tests/cases/custom_entry.lyte | 13 ++++ 17 files changed, 326 insertions(+), 111 deletions(-) create mode 100644 tests/cases/custom_entry.lyte diff --git a/Sources/CLyte/include/lyte.h b/Sources/CLyte/include/lyte.h index 430f4b44..4f564c45 100644 --- a/Sources/CLyte/include/lyte.h +++ b/Sources/CLyte/include/lyte.h @@ -100,10 +100,17 @@ void lyte_program_set_print_callback(LyteProgram* program, lyte_print_fn callbac // ============ Entry point invocation ============ +/// Returns true if the entry point at the given index was defined in the +/// compiled source. Entry points are optional: a requested name that the +/// source doesn't define is simply not compiled, and it's up to the caller +/// to decide whether that's an error. +bool lyte_program_has_entry_point(const LyteProgram* program, size_t entry_point); + /// Call an entry point by index with an external globals buffer. /// The index corresponds to the order of entry points passed to lyte_compiler_new. /// The buffer must be at least lyte_program_get_globals_size() bytes. -/// Returns true on success, false if cancelled, error, or invalid index. +/// Returns true on success, false if cancelled, error, invalid index, or if +/// the entry point wasn't defined in the source. bool lyte_entry_point_call(LyteProgram* program, size_t entry_point, uint8_t* globals); // ============ Globals helpers ============ diff --git a/Sources/Lyte/Lyte.swift b/Sources/Lyte/Lyte.swift index ae834223..398eeaa6 100644 --- a/Sources/Lyte/Lyte.swift +++ b/Sources/Lyte/Lyte.swift @@ -112,15 +112,19 @@ public final class Program { globals.first { $0.name == name } } - /// Look up an entry point by name. Returns nil if not found. + /// Look up an entry point by name. Returns nil if it wasn't requested, or + /// if the source doesn't define it — entry points are optional, so it's up + /// to the caller to decide whether a missing one is an error. public func entryPoint(named name: String) -> EntryPoint? { guard let index = entryPointNames.firstIndex(of: name) else { return nil } - return EntryPoint(program: self, index: index) + return entryPoint(at: index) } /// Get an entry point by index (matching the order passed to LyteCompiler.init). + /// Returns nil if the source doesn't define it. public func entryPoint(at index: Int) -> EntryPoint? { guard index >= 0 && index < entryPointNames.count else { return nil } + guard lyte_program_has_entry_point(handle, index) else { return nil } return EntryPoint(program: self, index: index) } diff --git a/cli/src/main.rs b/cli/src/main.rs index 96a5a9bb..cacca7ed 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -73,6 +73,17 @@ struct Args { target: String, } +/// The compiler treats entry points as optional (undefined ones are skipped), +/// so requiring them is the client's job. The CLI needs every requested entry +/// point to exist before it compiles or runs anything. +fn require_entry_points(compiler: &lyte::Compiler) -> bool { + let missing = compiler.missing_entry_points(); + for name in &missing { + eprintln!("entry point function '{}' not found", name); + } + missing.is_empty() +} + fn run(args: Args) -> i32 { let mut paths = vec![]; @@ -167,18 +178,12 @@ fn run(args: Args) -> i32 { // For `--check`, also run monomorphization so the post-monomorph safety // check (which catches bounds violations in `[T; N]` function bodies) - // fires here too. The actual compiled code is discarded. Skip if no - // entry point is defined — many test snippets are entry-point-less. + // fires here too. The actual compiled code is discarded. Undefined entry + // points are simply skipped — many test snippets are entry-point-less. if args.check && compiler.has_decls() { - let has_entry = compiler - .effective_entry_points() - .iter() - .any(|name| !compiler.decls().find(*name).is_empty()); - if has_entry { - if let Err(e) = compiler.specialize() { - eprintln!("{}", e); - return 1; - } + if let Err(e) = compiler.specialize() { + eprintln!("{}", e); + return 1; } } @@ -188,6 +193,9 @@ fn run(args: Args) -> i32 { #[cfg(feature = "llvm")] { if let Some(out) = args.aot.as_deref() { + if !require_entry_points(&compiler) { + return 1; + } return run_aot( &mut compiler, out, @@ -250,6 +258,10 @@ fn run(args: Args) -> i32 { return 1; } + if !require_entry_points(&compiler) { + return 1; + } + let compile_start = Instant::now(); if let Err(e) = compiler.specialize() { eprintln!("{}", e); diff --git a/src/compiler.rs b/src/compiler.rs index ff97305d..bb2ad0c1 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -471,6 +471,10 @@ impl Compiler { } /// Returns the effective entry points (defaults to ["main"] if none set). + /// + /// These are the *requested* entry points. Entry points are optional: the + /// backends skip any that aren't defined, so it's up to the client to + /// decide whether a missing one is an error (see `missing_entry_points`). pub fn effective_entry_points(&self) -> Vec { if self.entry_points.is_empty() { vec![Name::new("main".into())] @@ -479,6 +483,26 @@ impl Compiler { } } + /// The effective entry points that are defined as functions. + /// Only meaningful after `check()`. + pub fn found_entry_points(&self) -> Vec { + self.effective_entry_points() + .into_iter() + .filter(|name| self.decls.find_entry_point(*name).is_some()) + .collect() + } + + /// The effective entry points that are *not* defined as functions. + /// Clients that require an entry point should check this and report an + /// error; compilation itself just skips them. + /// Only meaningful after `check()`. + pub fn missing_entry_points(&self) -> Vec { + self.effective_entry_points() + .into_iter() + .filter(|name| self.decls.find_entry_point(*name).is_none()) + .collect() + } + pub fn parse_file(&mut self, path: &str) { let contents = fs::read_to_string(path); @@ -824,7 +848,8 @@ impl Compiler { } } - /// Compile to native code via Cranelift JIT. + /// Compile to native code via Cranelift JIT. Returns the code pointer of + /// the first entry point that's defined. #[cfg(feature = "cranelift")] pub fn jit(&self) -> Result<(*const u8, usize, JIT), String> { let mut jit = JIT::default(); @@ -833,7 +858,15 @@ impl Compiler { if self.decls.decls.is_empty() { return Err(String::from("No declarations to compile")); } - let (code_ptr, globals_size) = jit.compile(&self.decls)?; + let entry_points = self.effective_entry_points(); + let (map, globals_size) = jit.compile_multi(&self.decls, &entry_points)?; + let code_ptr = entry_points + .iter() + .find_map(|name| map.get(name).copied()) + .ok_or_else(|| match entry_points.first() { + Some(name) => format!("entry point function '{}' not found", name), + None => "no entry point to run".to_string(), + })?; Ok((code_ptr, globals_size, jit)) } @@ -938,6 +971,9 @@ impl Compiler { /// Run the code using the stack VM interpreter. pub fn run_stack(&mut self) -> Result { let program = self.compile_stack()?; + if program.entry_points.is_empty() { + return Err(self.no_entry_point_error()); + } let mut vm = crate::stack_vm::StackVM::new(); Ok(vm.run(&program)) } @@ -945,10 +981,21 @@ impl Compiler { /// Run the code using the VM interpreter. pub fn run_vm(&mut self) -> Result { let program = self.compile_vm()?; + if program.entry_points.is_empty() { + return Err(self.no_entry_point_error()); + } let mut vm = VM::new(); Ok(vm.run(&program)) } + /// Running requires an entry point, even though compiling doesn't. + fn no_entry_point_error(&self) -> String { + match self.effective_entry_points().first() { + Some(name) => format!("entry point function '{}' not found", name), + None => "no entry point to run".to_string(), + } + } + /// Compile to a backend-agnostic CompiledProgram. /// Auto-selects LLVM JIT (when available) or VM. pub fn compile_program(&self) -> Result { @@ -1506,6 +1553,84 @@ mod tests { jit.free_memory(); } + #[test] + fn test_missing_entry_point_is_skipped_vm() { + // Only "init" is defined. The missing "process" entry point is not an + // error — it's just absent from the compiled program. + let code = r#" + var counter: i32 + + init { + counter = 10 + } + "#; + + let mut compiler = Compiler::new(); + compiler.parse(code, "."); + compiler.set_entry_points(&["init", "process"]); + assert!(compiler.check()); + + assert_eq!(compiler.found_entry_points(), vec![Name::str("init")]); + assert_eq!(compiler.missing_entry_points(), vec![Name::str("process")]); + + compiler.specialize().unwrap(); + let program = compiler.compile_vm().unwrap(); + + assert!(program.entry_points.contains_key(&Name::str("init"))); + assert!(!program.entry_points.contains_key(&Name::str("process"))); + + let mut vm = crate::vm::VM::new(); + vm.call(&program, Name::str("init"), &[]).unwrap(); + assert!(vm.call(&program, Name::str("process"), &[]).is_err()); + } + + #[cfg(feature = "cranelift")] + #[test] + fn test_missing_entry_point_is_skipped_jit() { + let code = r#" + var counter: i32 + + init { + counter = 10 + } + "#; + + let mut compiler = Compiler::new(); + compiler.parse(code, "."); + compiler.set_entry_points(&["init", "process"]); + assert!(compiler.check()); + compiler.specialize().unwrap(); + + let (map, _globals_size, jit) = compiler.jit_multi().unwrap(); + assert!(map.contains_key(&Name::str("init"))); + assert!(!map.contains_key(&Name::str("process"))); + jit.free_memory(); + } + + #[test] + fn test_no_entry_points_found_compiles_but_does_not_run() { + // A library with no entry point at all still compiles cleanly; only + // running requires one. + let code = r#" + helper(x: i32) -> i32 { + x * 2 + } + "#; + + let mut compiler = Compiler::new(); + compiler.parse(code, "."); + assert!(compiler.check()); + assert!(compiler.found_entry_points().is_empty()); + assert_eq!(compiler.missing_entry_points(), vec![Name::str("main")]); + + compiler.specialize().unwrap(); + let program = compiler.compile_vm().unwrap(); + assert!(program.entry_points.is_empty()); + + let err = compiler.run_vm().unwrap_err(); + assert!(err.contains("not found"), "{}", err); + } + #[test] fn test_multi_entry_shared_function() { // Both entry points call the same helper function diff --git a/src/decl_table.rs b/src/decl_table.rs index d86abcb8..2e232895 100644 --- a/src/decl_table.rs +++ b/src/decl_table.rs @@ -46,6 +46,18 @@ impl DeclTable { &self.decls[range] } + /// Looks up an entry point function by name. + /// + /// Entry points are optional: a name that doesn't resolve to a function + /// yields None so backends can skip it. Deciding whether a missing entry + /// point is an error is up to the client. + pub fn find_entry_point(&self, name: Name) -> Option<&FuncDecl> { + match self.find(name).first() { + Some(Decl::Func(d)) => Some(d), + _ => None, + } + } + /// Calls f for every enum containing a case named name. /// This is for resolving .enum_case expressions. pub fn find_enum(&self, name: Name, f: &mut impl FnMut(Name)) { diff --git a/src/ffi.rs b/src/ffi.rs index 6fa36bf4..c379dcc3 100644 --- a/src/ffi.rs +++ b/src/ffi.rs @@ -21,6 +21,8 @@ struct GlobalInfo { /// An entry point: backend-specific data. /// Index matches the order of entry points passed to lyte_compiler_new. +/// Entry points are optional — a requested name that isn't defined in the +/// source keeps its slot (as None) so the indices stay stable. struct EntryPointInfo { #[cfg(feature = "llvm")] fn_addr: usize, @@ -233,7 +235,7 @@ pub struct LyteProgram { inner: crate::llvm_jit::LLVMCompiledProgram, globals_size: usize, globals_info: Vec, - entry_points: Vec, + entry_points: Vec>, cancel_callback: Option bool>, cancel_userdata: *mut u8, print_callback: Option, @@ -249,7 +251,7 @@ pub struct LyteProgram { vm: VM, globals_size: usize, globals_info: Vec, - entry_points: Vec, + entry_points: Vec>, cancel_callback: Option bool>, cancel_userdata: *mut u8, print_callback: Option, @@ -266,7 +268,7 @@ pub struct LyteProgram { backend: StackBackend, globals_size: usize, globals_info: Vec, - entry_points: Vec, + entry_points: Vec>, cancel_callback: Option bool>, cancel_userdata: *mut u8, print_callback: Option, @@ -325,12 +327,15 @@ pub unsafe extern "C" fn lyte_compiler_compile(ptr: *mut LyteCompiler) -> *mut L _ => unreachable!(), }; - let mut entry_points = Vec::new(); - for ep_name in &entry_point_names { - if let Some(&fn_addr) = inner.entry_points.get(ep_name) { - entry_points.push(EntryPointInfo { fn_addr }); - } - } + let entry_points: Vec> = entry_point_names + .iter() + .map(|ep_name| { + inner + .entry_points + .get(ep_name) + .map(|&fn_addr| EntryPointInfo { fn_addr }) + }) + .collect(); let program = Box::new(LyteProgram { inner, @@ -361,12 +366,15 @@ pub unsafe extern "C" fn lyte_compiler_compile(ptr: *mut LyteCompiler) -> *mut L let linked = LinkedProgram::from_program(&vm_program); let vm = VM::new(); - let mut entry_points = Vec::new(); - for ep_name in &entry_point_names { - if let Some(&func_idx) = vm_program.entry_points.get(ep_name) { - entry_points.push(EntryPointInfo { func_idx }); - } - } + let entry_points: Vec> = entry_point_names + .iter() + .map(|ep_name| { + vm_program + .entry_points + .get(ep_name) + .map(|&func_idx| EntryPointInfo { func_idx }) + }) + .collect(); let program = Box::new(LyteProgram { vm_program, @@ -398,12 +406,15 @@ pub unsafe extern "C" fn lyte_compiler_compile(ptr: *mut LyteCompiler) -> *mut L let globals_info = make_globals_info(crate::cancel::CANCEL_FLAG_RESERVED as usize); - let mut entry_points = Vec::new(); - for ep_name in &entry_point_names { - if let Some(&func_idx) = stack_program.entry_points.get(ep_name) { - entry_points.push(EntryPointInfo { func_idx }); - } - } + let entry_points: Vec> = entry_point_names + .iter() + .map(|ep_name| { + stack_program + .entry_points + .get(ep_name) + .map(|&func_idx| EntryPointInfo { func_idx }) + }) + .collect(); let backend = StackBackend::new(&stack_program); @@ -559,9 +570,26 @@ pub unsafe extern "C" fn lyte_globals_bind_extern( ptr::write_unaligned(globals.add(offset + 8) as *mut usize, context as usize); } +/// Returns true if the entry point at the given index was defined in the +/// compiled source. Entry points are optional: a requested name that isn't +/// defined simply isn't compiled, and it's up to the caller to decide whether +/// that's an error. +#[no_mangle] +pub unsafe extern "C" fn lyte_program_has_entry_point( + ptr: *const LyteProgram, + entry_point: usize, +) -> bool { + if ptr.is_null() { + return false; + } + let program = &*ptr; + matches!(program.entry_points.get(entry_point), Some(Some(_))) +} + /// Call an entry point by index with an external globals buffer. /// The index corresponds to the order of entry points passed to lyte_compiler_new. -/// Returns true on success, false if cancelled or invalid index. +/// Returns true on success, false if cancelled, if the index is invalid, or if +/// that entry point wasn't defined in the source. #[no_mangle] pub unsafe extern "C" fn lyte_entry_point_call( ptr: *mut LyteProgram, @@ -573,8 +601,8 @@ pub unsafe extern "C" fn lyte_entry_point_call( } let program = &mut *ptr; let ep = match program.entry_points.get(entry_point) { - Some(ep) => ep, - None => return false, + Some(Some(ep)) => ep, + _ => return false, }; // Install print callback for the duration of this call. diff --git a/src/jit.rs b/src/jit.rs index a90057cf..225d08f6 100644 --- a/src/jit.rs +++ b/src/jit.rs @@ -166,12 +166,15 @@ impl JIT { let code_ptr = map .get(&main_name) .copied() - .ok_or_else(|| "main function not found".to_string())?; + .ok_or_else(|| "entry point function 'main' not found".to_string())?; Ok((code_ptr, globals_size)) } /// Compile multiple entry points into native code. /// Returns (name→code_ptr map, globals_size). + /// + /// Entry points that aren't defined are skipped, so the returned map only + /// contains the ones that were found. pub fn compile_multi( &mut self, decls: &DeclTable, @@ -181,14 +184,8 @@ impl JIT { let mut func_ids = Vec::new(); for &ep_name in entry_points { - let ep_decls = decls.find(ep_name); - if ep_decls.is_empty() { - return Err(format!("entry point function '{}' not found", ep_name)); - } - let ep_decl = if let Decl::Func(d) = &ep_decls[0] { - d - } else { - return Err(format!("'{}' is not a function", ep_name)); + let Some(ep_decl) = decls.find_entry_point(ep_name) else { + continue; }; let id = self.compile_function(decls, ep_decl)?; func_ids.push((ep_name, id)); diff --git a/src/llvm_aot.rs b/src/llvm_aot.rs index 195dba96..219b8076 100644 --- a/src/llvm_aot.rs +++ b/src/llvm_aot.rs @@ -235,16 +235,13 @@ pub fn compile_aot( Ok(()) } +/// Collect signature info for the entry points that are defined. Undefined +/// entry points are skipped — they simply get no wrapper or header entry. fn collect_entries(decls: &DeclTable, entry_points: &[Name]) -> Result, String> { let mut out = Vec::with_capacity(entry_points.len()); for &ep_name in entry_points { - let found = decls.find(ep_name); - if found.is_empty() { - return Err(format!("entry point '{}' not found", ep_name)); - } - let f = match &found[0] { - Decl::Func(d) => d, - _ => return Err(format!("'{}' is not a function", ep_name)), + let Some(f) = decls.find_entry_point(ep_name) else { + continue; }; let mut params = Vec::new(); for p in &f.params { diff --git a/src/llvm_jit.rs b/src/llvm_jit.rs index dd86900e..9ed5fa3f 100644 --- a/src/llvm_jit.rs +++ b/src/llvm_jit.rs @@ -483,15 +483,11 @@ pub(crate) fn build_module<'ctx>( state.declare_globals(decls); + // Entry points that aren't defined are skipped — the client decides + // whether a missing entry point is an error. for &ep_name in entry_points { - let ep_decls = decls.find(ep_name); - if ep_decls.is_empty() { - return Err(format!("entry point function '{}' not found", ep_name)); - } - let ep_decl = if let Decl::Func(d) = &ep_decls[0] { - d - } else { - return Err(format!("'{}' is not a function", ep_name)); + let Some(ep_decl) = decls.find_entry_point(ep_name) else { + continue; }; state.compile_function(decls, ep_decl)?; } @@ -579,8 +575,17 @@ fn compile_and_run_with_context( } } - // Look up the first entry point for execution. - let run_name = &*entry_points[0]; + // Look up the first entry point that exists for execution. Running does + // require one, so this is where a missing entry point becomes an error. + let run_ep = entry_points + .iter() + .copied() + .find(|n| decls.find_entry_point(*n).is_some()) + .ok_or_else(|| match entry_points.first() { + Some(n) => format!("entry point function '{}' not found", n), + None => "no entry point to run".to_string(), + })?; + let run_name = &*run_ep; let fn_addr = ee .get_function_address(run_name) .map_err(|e| format!("function '{}' not found in JIT: {:?}", run_name, e))?; @@ -678,9 +683,12 @@ impl LLVMJIT { } } - // Look up all entry point addresses. + // Look up the addresses of the entry points that were found. let mut ep_map = HashMap::new(); for &ep_name in entry_points { + if decls.find_entry_point(ep_name).is_none() { + continue; + } let fn_addr = ee .get_function_address(&*ep_name) .map_err(|e| format!("function '{}' not found in JIT: {:?}", ep_name, e))?; diff --git a/src/monomorph_pass.rs b/src/monomorph_pass.rs index 6ca31b59..671f0d80 100644 --- a/src/monomorph_pass.rs +++ b/src/monomorph_pass.rs @@ -45,6 +45,9 @@ impl MonomorphPass { /// /// Each entry point is processed as a root. The `processed_non_generic` /// set prevents reprocessing shared functions reached from multiple roots. + /// + /// Entry points that aren't defined are skipped — whether a missing entry + /// point is an error is up to the client. pub fn monomorphize_multi( &mut self, decls: &DeclTable, @@ -52,10 +55,6 @@ impl MonomorphPass { ) -> Result, String> { for &entry_point in entry_points { let func_decls = decls.find(entry_point); - if func_decls.is_empty() { - return Err(format!("entry point function '{}' not found", entry_point)); - } - if func_decls.len() > 1 { return Err(format!( "Multiple overloads found for entry point function '{}'", @@ -63,15 +62,15 @@ impl MonomorphPass { )); } - if let Decl::Func(fdecl) = &func_decls[0] { - if !self.processed_non_generic.contains(&fdecl.name) { - self.processed_non_generic.insert(fdecl.name); - let mut fdecl = fdecl.clone(); - self.process_function(&mut fdecl, decls)?; - self.out_decls.push(Decl::Func(fdecl)); - } - } else { - return Err(format!("Entry point '{}' is not a function", entry_point)); + let Some(fdecl) = decls.find_entry_point(entry_point) else { + continue; + }; + + if !self.processed_non_generic.contains(&fdecl.name) { + self.processed_non_generic.insert(fdecl.name); + let mut fdecl = fdecl.clone(); + self.process_function(&mut fdecl, decls)?; + self.out_decls.push(Decl::Func(fdecl)); } } @@ -997,10 +996,9 @@ mod tests { let mut pass = MonomorphPass::new(); let decls = DeclTable::new(vec![]); + // A missing entry point is not an error — it's simply skipped. let result = pass.monomorphize(&decls, Name::str("main")); - // Should fail because the entry point doesn't exist - assert!(result.is_err()); - assert!(result.unwrap_err().contains("not found")); + assert_eq!(result.unwrap().len(), 0); } #[test] diff --git a/src/stack_codegen.rs b/src/stack_codegen.rs index f3e4bf36..7f0e82ae 100644 --- a/src/stack_codegen.rs +++ b/src/stack_codegen.rs @@ -130,6 +130,9 @@ impl StackCodegen { } /// Compile multiple entry points into a StackProgram. + /// + /// Entry points that aren't defined are skipped: only the ones that were + /// found show up in `program.entry_points`. pub fn compile_multi( &mut self, decls: &DeclTable, @@ -141,13 +144,8 @@ impl StackCodegen { if self.compiled_functions.contains(&ep_name) { continue; } - let ep_decls = decls.find(ep_name); - if ep_decls.is_empty() { - return Err(format!("entry point function '{}' not found", ep_name)); - } - let ep_decl = match &ep_decls[0] { - Decl::Func(d) => d, - _ => return Err(format!("'{}' is not a function", ep_name)), + let Some(ep_decl) = decls.find_entry_point(ep_name) else { + continue; }; self.compile_function(ep_decl, decls)?; @@ -165,16 +163,17 @@ impl StackCodegen { } } - // Set entry point. - self.program.entry = *self - .func_indices - .get(&entry_points[0]) - .ok_or_else(|| format!("entry point '{}' not found", entry_points[0]))?; - - // Populate entry_points map. + // Populate entry_points map, and set program.entry to the first entry + // point that was actually found. If none were found, program.entry + // stays at its default and the map is empty. + let mut entry_set = false; for &ep_name in entry_points { if let Some(&idx) = self.func_indices.get(&ep_name) { self.program.entry_points.insert(ep_name, idx); + if !entry_set { + self.program.entry = idx; + entry_set = true; + } } } diff --git a/src/stack_interp_bridge.rs b/src/stack_interp_bridge.rs index c87972f3..14cc7403 100644 --- a/src/stack_interp_bridge.rs +++ b/src/stack_interp_bridge.rs @@ -917,6 +917,10 @@ fn encode_imm(op: &StackOp, func_idx: u32) -> [u64; 3] { /// Convert a StackProgram to C instruction format and run it. pub fn run(program: &StackProgram) -> i64 { + // Nothing to run: no entry point was found at compile time. + if program.functions.is_empty() { + return 0; + } let mut backend = StackBackend::new(program); let mut owned_globals: Vec = vec![0u8; program.globals_size]; backend.call_entry(program.entry, owned_globals.as_mut_ptr()) diff --git a/src/stack_vm.rs b/src/stack_vm.rs index 2af0b43d..4d0480b4 100644 --- a/src/stack_vm.rs +++ b/src/stack_vm.rs @@ -123,6 +123,10 @@ impl StackVM { } pub fn run(&mut self, program: &StackProgram) -> i64 { + // Nothing to run: no entry point was found at compile time. + if program.functions.is_empty() { + return 0; + } self.globals.resize(program.globals_size, 0); self.cancelled = false; diff --git a/src/vm.rs b/src/vm.rs index 4a67ab74..99a56b8e 100644 --- a/src/vm.rs +++ b/src/vm.rs @@ -1282,6 +1282,10 @@ impl VM { /// Run the program and return the result. /// Globals are always re-zeroed. pub fn run(&mut self, program: &VMProgram) -> i64 { + // Nothing to run: no entry point was found at compile time. + if program.functions.is_empty() { + return 0; + } // Always reinitialize globals for run(). self.globals = vec![0u8; program.globals_size]; self.run_inner(program, program.entry, &[]) diff --git a/src/vm_arm64.rs b/src/vm_arm64.rs index 74f340aa..55418f69 100644 --- a/src/vm_arm64.rs +++ b/src/vm_arm64.rs @@ -221,6 +221,10 @@ impl VM { /// This provides the same semantics as `run()` but with a hand-written /// dispatch loop that pins VM state in callee-saved registers. pub fn run_asm(&mut self, program: &VMProgram) -> i64 { + // Nothing to run: no entry point was found at compile time. + if program.functions.is_empty() { + return 0; + } let linked = LinkedProgram::from_program(program); // Initialize VM state diff --git a/src/vm_codegen.rs b/src/vm_codegen.rs index 23e173a7..8363f266 100644 --- a/src/vm_codegen.rs +++ b/src/vm_codegen.rs @@ -114,6 +114,9 @@ impl VMCodegen { } /// Compile multiple entry points into a VMProgram. + /// + /// Entry points that aren't defined are skipped: only the ones that were + /// found show up in `program.entry_points`. pub fn compile_multi( &mut self, decls: &DeclTable, @@ -127,13 +130,8 @@ impl VMCodegen { if self.compiled_functions.contains(&ep_name) { continue; } - let ep_decls = decls.find(ep_name); - if ep_decls.is_empty() { - return Err(format!("entry point function '{}' not found", ep_name)); - } - let ep_decl = match &ep_decls[0] { - Decl::Func(d) => d, - _ => return Err(format!("'{}' is not a function", ep_name)), + let Some(ep_decl) = decls.find_entry_point(ep_name) else { + continue; }; self.compile_function(ep_decl, decls)?; @@ -152,16 +150,17 @@ impl VMCodegen { } } - // Set entry point to first entry point for backward compat. - self.program.entry = *self - .func_indices - .get(&entry_points[0]) - .ok_or_else(|| format!("entry point '{}' not found", entry_points[0]))?; - - // Populate entry_points map. + // Populate entry_points map, and set program.entry to the first entry + // point that was actually found (for backward compat). If none were + // found, program.entry stays at its default and the map is empty. + let mut entry_set = false; for &ep_name in entry_points { if let Some(&idx) = self.func_indices.get(&ep_name) { self.program.entry_points.insert(ep_name, idx); + if !entry_set { + self.program.entry = idx; + entry_set = true; + } } } diff --git a/tests/cases/custom_entry.lyte b/tests/cases/custom_entry.lyte new file mode 100644 index 00000000..0b3ffe87 --- /dev/null +++ b/tests/cases/custom_entry.lyte @@ -0,0 +1,13 @@ +// Entry points are optional and need not be named "main": the client picks +// them, and here we ask for "init". +// args: --entry init +// expected stdout: +// compilation successful +// assert(true) + +var counter: i32 + +init { + counter = 10 + assert(counter == 10) +} From 90e9d101276c6e886817899fed055e61a92c6266 Mon Sep 17 00:00:00 2001 From: Taylor Holliday Date: Sat, 15 Aug 2026 18:27:47 -0700 Subject: [PATCH 2/3] Fix entry point lookup shadowing and unresolved-entry guards find_entry_point only looked at find(name).first(), so a non-function decl sharing the entry point's name (decls with equal names are ordered by source position) shadowed the function: `var main: i32` above `main { ... }` reported 'main' as missing, and specialize() reported a bogus "Multiple overloads found". Scan for the first Decl::Func instead, via a new entry_point_overloads() iterator that monomorph_pass also uses for its overload count. The VM run guards tested functions.is_empty() as a proxy for "no entry point was resolved", but `entry` defaults to 0, so a program with functions and an unresolved entry would run function 0. Add has_entry() to VMProgram and StackProgram checking that `entry` indexes a real function, and use it at all four run sites. VM::run also re-zeroes globals and clears cancelled/trap before the early return, per its documented contract. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KU75jHWHD6nzWZb5tau87e --- src/decl_table.rs | 16 ++++-- src/monomorph_pass.rs | 3 +- src/stack_interp_bridge.rs | 5 +- src/stack_ir.rs | 7 +++ src/stack_vm.rs | 10 ++-- src/vm.rs | 54 +++++++++++++++++-- src/vm_arm64.rs | 10 ++-- .../global_named_like_entry_point.lyte | 14 +++++ 8 files changed, 100 insertions(+), 19 deletions(-) create mode 100644 tests/cases/globals/global_named_like_entry_point.lyte diff --git a/src/decl_table.rs b/src/decl_table.rs index 2e232895..2a885294 100644 --- a/src/decl_table.rs +++ b/src/decl_table.rs @@ -51,11 +51,21 @@ impl DeclTable { /// Entry points are optional: a name that doesn't resolve to a function /// yields None so backends can skip it. Deciding whether a missing entry /// point is an error is up to the client. + /// + /// Non-function decls sharing the name (a global, struct, etc.) are skipped + /// rather than shadowing the function, since decls with equal names are + /// ordered by source position. pub fn find_entry_point(&self, name: Name) -> Option<&FuncDecl> { - match self.find(name).first() { - Some(Decl::Func(d)) => Some(d), + self.entry_point_overloads(name).next() + } + + /// Returns every function declared with the given name, ignoring non-function + /// decls that happen to share it. + pub fn entry_point_overloads(&self, name: Name) -> impl Iterator { + self.find(name).iter().filter_map(|d| match d { + Decl::Func(d) => Some(d), _ => None, - } + }) } /// Calls f for every enum containing a case named name. diff --git a/src/monomorph_pass.rs b/src/monomorph_pass.rs index 671f0d80..ca5c9c73 100644 --- a/src/monomorph_pass.rs +++ b/src/monomorph_pass.rs @@ -54,8 +54,7 @@ impl MonomorphPass { entry_points: &[Name], ) -> Result, String> { for &entry_point in entry_points { - let func_decls = decls.find(entry_point); - if func_decls.len() > 1 { + if decls.entry_point_overloads(entry_point).count() > 1 { return Err(format!( "Multiple overloads found for entry point function '{}'", entry_point diff --git a/src/stack_interp_bridge.rs b/src/stack_interp_bridge.rs index 14cc7403..726441e5 100644 --- a/src/stack_interp_bridge.rs +++ b/src/stack_interp_bridge.rs @@ -917,8 +917,9 @@ fn encode_imm(op: &StackOp, func_idx: u32) -> [u64; 3] { /// Convert a StackProgram to C instruction format and run it. pub fn run(program: &StackProgram) -> i64 { - // Nothing to run: no entry point was found at compile time. - if program.functions.is_empty() { + // Nothing to run: no entry point was resolved at compile time, so + // program.entry is a meaningless default. + if !program.has_entry() { return 0; } let mut backend = StackBackend::new(program); diff --git a/src/stack_ir.rs b/src/stack_ir.rs index 5e0d06d6..7081566e 100644 --- a/src/stack_ir.rs +++ b/src/stack_ir.rs @@ -675,6 +675,13 @@ impl StackProgram { self.functions.push(func); idx } + + /// True if `entry` names a real function. Entry points are optional, and + /// `entry` defaults to 0, so a program compiled with none resolved would + /// otherwise run function 0 (or index out of bounds). + pub fn has_entry(&self) -> bool { + (self.entry as usize) < self.functions.len() + } } /// Display support for stack IR disassembly. diff --git a/src/stack_vm.rs b/src/stack_vm.rs index 4d0480b4..1c42be0d 100644 --- a/src/stack_vm.rs +++ b/src/stack_vm.rs @@ -123,13 +123,15 @@ impl StackVM { } pub fn run(&mut self, program: &StackProgram) -> i64 { - // Nothing to run: no entry point was found at compile time. - if program.functions.is_empty() { - return 0; - } self.globals.resize(program.globals_size, 0); self.cancelled = false; + // Nothing to run: no entry point was resolved at compile time, so + // program.entry is a meaningless default. + if !program.has_entry() { + return 0; + } + let mut func_idx = program.entry; let (mut locals, mut lm_base) = self.enter_function(program, func_idx, &[]); let mut ip: usize = 0; diff --git a/src/vm.rs b/src/vm.rs index 99a56b8e..b8fae918 100644 --- a/src/vm.rs +++ b/src/vm.rs @@ -1062,6 +1062,13 @@ impl VMProgram { self.functions.push(func); idx } + + /// True if `entry` names a real function. Entry points are optional, and + /// `entry` defaults to 0, so a program compiled with none resolved would + /// otherwise run function 0 (or index out of bounds). + pub fn has_entry(&self) -> bool { + (self.entry as usize) < self.functions.len() + } } /// Call frame for function execution @@ -1282,12 +1289,16 @@ impl VM { /// Run the program and return the result. /// Globals are always re-zeroed. pub fn run(&mut self, program: &VMProgram) -> i64 { - // Nothing to run: no entry point was found at compile time. - if program.functions.is_empty() { - return 0; - } // Always reinitialize globals for run(). self.globals = vec![0u8; program.globals_size]; + self.cancelled = false; + self.trap = None; + + // Nothing to run: no entry point was resolved at compile time, so + // program.entry is a meaningless default. + if !program.has_entry() { + return 0; + } self.run_inner(program, program.entry, &[]) } @@ -3339,4 +3350,39 @@ mod tests { vm.run(&program); assert!(vm.cancelled, "expected the infinite loop to be cancelled"); } + + #[test] + fn test_no_entry_point_does_not_run() { + // Entry points are optional, so `entry` can be left at its 0 default + // with nothing to run. Neither shape may execute a function. + let empty = VMProgram::new(); + assert!(!empty.has_entry()); + assert_eq!(VM::new().run(&empty), 0); + + // A stale entry index pointing past the function table (e.g. a program + // rebuilt from a codegen that kept indices from a previous compile). + let mut func = VMFunction::new("test"); + func.emit(Opcode::LoadImm { dst: 0, value: 42 }); + func.emit(Opcode::Return); + + let mut program = VMProgram::new(); + program.add_function(func); + program.entry = 7; + assert!(!program.has_entry()); + assert_eq!(VM::new().run(&program), 0); + } + + #[test] + fn test_run_rezeroes_globals_without_entry_point() { + // run() documents that globals are always re-zeroed; the early return + // for a missing entry point must not skip that. + let mut vm = VM::new(); + vm.globals = vec![0xffu8; 8]; + + let mut program = VMProgram::new(); + program.globals_size = 4; + + assert_eq!(vm.run(&program), 0); + assert_eq!(vm.globals, vec![0u8; 4]); + } } diff --git a/src/vm_arm64.rs b/src/vm_arm64.rs index 55418f69..8c394ddb 100644 --- a/src/vm_arm64.rs +++ b/src/vm_arm64.rs @@ -221,10 +221,6 @@ impl VM { /// This provides the same semantics as `run()` but with a hand-written /// dispatch loop that pins VM state in callee-saved registers. pub fn run_asm(&mut self, program: &VMProgram) -> i64 { - // Nothing to run: no entry point was found at compile time. - if program.functions.is_empty() { - return 0; - } let linked = LinkedProgram::from_program(program); // Initialize VM state @@ -234,6 +230,12 @@ impl VM { self.cancelled = false; self.trap = None; + // Nothing to run: no entry point was resolved at compile time, so + // program.entry is a meaningless default. + if !program.has_entry() { + return 0; + } + // Pre-allocate call stack let mut call_stack = Vec::with_capacity(MAX_CALL_DEPTH); call_stack.resize( diff --git a/tests/cases/globals/global_named_like_entry_point.lyte b/tests/cases/globals/global_named_like_entry_point.lyte new file mode 100644 index 00000000..f8d63624 --- /dev/null +++ b/tests/cases/globals/global_named_like_entry_point.lyte @@ -0,0 +1,14 @@ +// A global sharing the entry point's name must not shadow the entry point +// function. Decls with equal names are ordered by source position, so looking +// at only the first one would report 'main' as missing. + +// expected stdout: +// compilation successful +// assert(true) + +var main: i32 + +main { + main = 3 + assert(main == 3) +} From ce4255a6c4bab63f7518dd5adf910d5445664c34 Mon Sep 17 00:00:00 2001 From: Taylor Holliday Date: Sat, 15 Aug 2026 18:36:14 -0700 Subject: [PATCH 3/3] Make AOT reject undefined entry points; report entry points from the AST collect_entries silently skipped undefined entry points, so a library caller of llvm_aot::compile_aot that misspelled an entry name got a successfully written .o + .h missing that symbol, surfacing only as an undefined-symbol error in the host's link. The CLI pre-checks with require_entry_points, but the public API shouldn't depend on that. AOT's entry point list is the object's export list, so an undefined one is now an error, reported before any file is written. found_entry_points/missing_entry_points were documented "only meaningful after check()" with nothing enforcing it: called earlier (easy for an FFI embedder, since lyte_compiler_compile is the only thing that runs check()), self.decls is empty and every entry point looks missing. Answer from the AST instead, which check() copies into self.decls wholesale, so the result is the same after check() and correct before it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KU75jHWHD6nzWZb5tau87e --- src/compiler.rs | 42 ++++++++++++++++++++++++++++++++++++++---- src/llvm_aot.rs | 44 +++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/compiler.rs b/src/compiler.rs index bb2ad0c1..fe5fe831 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -483,23 +483,35 @@ impl Compiler { } } + /// True if `name` is declared as a top level function in the parsed source. + /// + /// Answered from the AST rather than the decl table so it is valid as soon + /// as the source is parsed. `check()` copies every tree decl into + /// `self.decls`, so the two agree once it has run; before that `self.decls` + /// is empty and every entry point would look missing. + fn entry_point_is_defined(&self, name: Name) -> bool { + self.ast.iter().any(|tree| { + tree.decls + .iter() + .any(|d| matches!(d, Decl::Func(f) if f.name == name)) + }) + } + /// The effective entry points that are defined as functions. - /// Only meaningful after `check()`. pub fn found_entry_points(&self) -> Vec { self.effective_entry_points() .into_iter() - .filter(|name| self.decls.find_entry_point(*name).is_some()) + .filter(|name| self.entry_point_is_defined(*name)) .collect() } /// The effective entry points that are *not* defined as functions. /// Clients that require an entry point should check this and report an /// error; compilation itself just skips them. - /// Only meaningful after `check()`. pub fn missing_entry_points(&self) -> Vec { self.effective_entry_points() .into_iter() - .filter(|name| self.decls.find_entry_point(*name).is_none()) + .filter(|name| !self.entry_point_is_defined(*name)) .collect() } @@ -1607,6 +1619,28 @@ mod tests { jit.free_memory(); } + #[test] + fn test_entry_points_reported_before_check() { + // found/missing_entry_points read the AST, so an embedder that asks + // before check() gets the real answer rather than "everything missing". + let code = r#" + init { + } + "#; + + let mut compiler = Compiler::new(); + compiler.parse(code, "."); + compiler.set_entry_points(&["init", "process"]); + + assert_eq!(compiler.found_entry_points(), vec![Name::str("init")]); + assert_eq!(compiler.missing_entry_points(), vec![Name::str("process")]); + + // And the answer doesn't change once check() has populated decls. + assert!(compiler.check()); + assert_eq!(compiler.found_entry_points(), vec![Name::str("init")]); + assert_eq!(compiler.missing_entry_points(), vec![Name::str("process")]); + } + #[test] fn test_no_entry_points_found_compiles_but_does_not_run() { // A library with no entry point at all still compiles cleanly; only diff --git a/src/llvm_aot.rs b/src/llvm_aot.rs index 219b8076..6429a27b 100644 --- a/src/llvm_aot.rs +++ b/src/llvm_aot.rs @@ -235,13 +235,29 @@ pub fn compile_aot( Ok(()) } -/// Collect signature info for the entry points that are defined. Undefined -/// entry points are skipped — they simply get no wrapper or header entry. +/// Collect signature info for the requested entry points. +/// +/// Unlike the JIT and VM backends, AOT treats an undefined entry point as an +/// error rather than skipping it: the entry point list is the object's export +/// list, so skipping one produces an .o + .h silently missing that symbol, and +/// the mistake only surfaces as an undefined-symbol error in the host's link. fn collect_entries(decls: &DeclTable, entry_points: &[Name]) -> Result, String> { + let missing: Vec = entry_points + .iter() + .filter(|n| decls.find_entry_point(**n).is_none()) + .map(|n| format!("'{}'", n)) + .collect(); + if !missing.is_empty() { + return Err(format!( + "AOT entry point function(s) not found: {}", + missing.join(", ") + )); + } + let mut out = Vec::with_capacity(entry_points.len()); for &ep_name in entry_points { let Some(f) = decls.find_entry_point(ep_name) else { - continue; + unreachable!("checked above"); }; let mut params = Vec::new(); for p in &f.params { @@ -830,3 +846,25 @@ fn c_string_escape(s: &str) -> String { } out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Compiler; + + #[test] + fn undefined_entry_point_is_an_error() { + // AOT exports exactly the requested entry points, so an undefined one + // must fail here rather than yield an .o + .h missing that symbol. + let mut compiler = Compiler::new(); + compiler.parse("var counter: i32\ninit { counter = 10 }\n", "."); + assert!(compiler.check()); + let decls = compiler.decls(); + + assert!(collect_entries(decls, &[Name::str("init")]).is_ok()); + + let err = collect_entries(decls, &[Name::str("init"), Name::str("process")]).unwrap_err(); + assert!(err.contains("'process'"), "{}", err); + assert!(!err.contains("'init'"), "{}", err); + } +}