doge_interp/lib.rs
1//! A tree-walking interpreter for Doge — the second execution engine beside the
2//! Rust-transpiling compiler. It evaluates a checked AST directly against
3//! `doge-runtime`, so `doge repl` (and the interpreter path in general) skips the
4//! rustc build entirely. Every value operation calls the same `doge-runtime`
5//! function the generated Rust would, so an interpreted program behaves
6//! identically to a compiled one — a guarantee the examples parity suite enforces.
7
8use std::cell::RefCell;
9use std::collections::HashMap;
10use std::rc::Rc;
11use std::sync::Arc;
12
13use doge_compiler as dc;
14use doge_runtime::{DogeError, DogeResult, ErrorKind, Value};
15
16mod analyze;
17mod call;
18mod exec;
19mod expr;
20mod natives;
21mod pack;
22#[cfg(test)]
23mod tests;
24
25pub use dc::{ClassInfo, ReplParse, SessionScope};
26
27/// A shared, mutable binding cell — the interpreter's variables and the compiled
28/// runtime's are the same `Rc<RefCell<Value>>`, so closures capture by sharing.
29type Cell = doge_runtime::Cell;
30
31/// A lexical scope: names bound in it to their shared cells. A function call gets
32/// a fresh one seeded with its captures and parameters; a file's top level shares
33/// one persistent scope (the REPL session's globals live here).
34type Scope = Rc<RefCell<HashMap<String, Cell>>>;
35
36fn scope() -> Scope {
37 Rc::new(RefCell::new(HashMap::new()))
38}
39
40fn cell(value: Value) -> Cell {
41 Rc::new(RefCell::new(value))
42}
43
44/// A resolved `so` import: a stdlib module, or a user module by file id.
45#[derive(Clone, Copy)]
46enum ModuleRef {
47 Stdlib(&'static dc::Module),
48 User(u32),
49}
50
51/// One source file's top-level scope: its persistent globals and its resolved
52/// imports (local name → module).
53struct FileScope {
54 globals: Scope,
55 imports: HashMap<String, ModuleRef>,
56}
57
58/// A user-defined function, method, or closure, keyed by a program-wide `fn_id`.
59/// The `capture_names` name each captured cell a closure value carries, in the
60/// same order, so a call can rebuild the closure's captured scope.
61struct Template {
62 name: String,
63 file_id: u32,
64 params: dc::Params,
65 body: Rc<[dc::Stmt]>,
66 capture_names: Vec<String>,
67 /// The class a method belongs to (for `super`); `None` for a plain function.
68 method_class: Option<u32>,
69}
70
71/// How a builtin/stdlib native is invoked: the exact runtime function it wires to
72/// plus how many arguments it takes.
73struct Native {
74 name: String,
75 runtime_fn: &'static str,
76 arity: Arity,
77}
78
79/// A native's accepted argument count: a fixed number, `range`'s one-or-two, or
80/// `gib`'s zero-or-one.
81#[derive(Clone, Copy)]
82enum Arity {
83 Exact(usize),
84 OneOrTwo,
85 ZeroOrOne,
86}
87
88/// Anything callable through a `fn_id`: a user definition, a runtime native, or a
89/// class constructor (a class name used as a value calls this to build an
90/// instance, carrying the class's `class_id`).
91enum Callable {
92 User(Template),
93 Native(Native),
94 Ctor(u32),
95}
96
97/// One class, keyed by a program-wide `class_id`: its name, defining file, parent
98/// (for inheritance and `super`), own methods (method name → `fn_id`), and the
99/// `fn_id` of its constructor callable (for materializing the class as a value).
100struct ClassData {
101 name: String,
102 file_id: u32,
103 parent: Option<u32>,
104 methods: HashMap<String, usize>,
105 ctor_fn_id: usize,
106}
107
108/// Non-local control flow bubbling out of statement execution.
109enum Flow {
110 Normal,
111 Return(Value),
112 Break,
113 Continue,
114}
115
116/// An interpreter session. Holds the program-wide callable and class tables, one
117/// scope per file, and the mutable run state (recursion depth, current location).
118/// A `doge repl` session reuses one `Interp` across snippets, so bindings persist.
119pub struct Interp {
120 callables: Vec<Rc<Callable>>,
121 classes: Vec<Rc<ClassData>>,
122 file_scopes: Vec<FileScope>,
123 /// Per file: top-level function name → `fn_id`, for hoisting function values.
124 file_funcs: Vec<HashMap<String, usize>>,
125 /// Per file: class name → `class_id`, for resolving constructors.
126 file_class_ids: Vec<HashMap<String, u32>>,
127 /// Builtin name → `fn_id`; stable for the session so function values keep working.
128 builtin_ids: HashMap<String, usize>,
129 /// (module, member) → `fn_id` for stdlib module functions.
130 module_fn_ids: HashMap<(String, String), usize>,
131 /// One path per file id, for rendering error locations.
132 file_paths: Vec<Rc<str>>,
133 depth: usize,
134 cur_fid: u32,
135 cur_line: u32,
136 /// The class whose method body is currently running, for `super` resolution.
137 current_method_class: Option<u32>,
138 /// A module constant initializer that failed during integration; surfaced
139 /// when the entry runs, matching the compiled program's initialization order.
140 pending_module_error: Option<DogeError>,
141 /// Names declared with `so … =` in the entry scope, so the REPL's checker keeps
142 /// rejecting reassignment to them across snippets.
143 session_consts: Vec<String>,
144 /// The loaded program, shared so a `pack.zoom` pup can rebuild a fresh
145 /// interpreter over the same files on its own thread. `None` in a REPL session
146 /// (which has no whole-program handle), where `pack.zoom` is not yet available.
147 program: Option<Arc<dc::Program>>,
148}
149
150/// Run a whole loaded program to completion: initialize every module, then
151/// execute the entry file's top-level statements. Used to run a `.doge` file
152/// through the interpreter (the parity path beside `doge build`). Takes the
153/// program by `Arc` so a `pack.zoom` pup can rebuild an interpreter over it.
154pub fn run_program(program: Arc<dc::Program>) -> DogeResult<()> {
155 let mut interp = Interp::new();
156 interp.run(program)
157}
158
159impl Default for Interp {
160 fn default() -> Self {
161 Interp::new()
162 }
163}
164
165impl Interp {
166 /// Integrate a loaded program and run its entry file to completion.
167 pub fn run(&mut self, program: Arc<dc::Program>) -> DogeResult<()> {
168 self.program = Some(program.clone());
169 self.integrate_program(&program);
170 self.run_entry(&program)
171 }
172
173 /// Integrate a loaded program *without* running its entry body — the setup the
174 /// test runner needs before it drives individual `test`-prefixed functions with
175 /// [`call_entry_function`]. A module constant initializer that failed during
176 /// integration surfaces here, just as it would when the entry runs.
177 pub fn prepare(&mut self, program: Arc<dc::Program>) -> DogeResult<()> {
178 self.program = Some(program.clone());
179 self.integrate_program(&program);
180 match self.pending_module_error.take() {
181 Some(err) => Err(err),
182 None => Ok(()),
183 }
184 }
185
186 /// The file id and line the interpreter last executed — the site of an uncaught
187 /// error, for the caller to render a doge-flavored location.
188 pub fn error_site(&self) -> (usize, u32) {
189 (self.cur_fid as usize, self.cur_line)
190 }
191
192 /// A fresh session with only the runtime natives (builtins + stdlib) registered.
193 pub fn new() -> Interp {
194 let mut interp = Interp {
195 callables: Vec::new(),
196 classes: Vec::new(),
197 file_scopes: vec![FileScope {
198 globals: scope(),
199 imports: HashMap::new(),
200 }],
201 file_funcs: vec![HashMap::new()],
202 file_class_ids: vec![HashMap::new()],
203 builtin_ids: HashMap::new(),
204 module_fn_ids: HashMap::new(),
205 file_paths: vec![Rc::from("<repl>")],
206 depth: 0,
207 cur_fid: 0,
208 cur_line: 0,
209 current_method_class: None,
210 pending_module_error: None,
211 session_consts: Vec::new(),
212 program: None,
213 };
214 interp.register_natives();
215 interp
216 }
217
218 // ----- file-scope helpers -----
219
220 fn globals(&self, fid: u32) -> Scope {
221 self.file_scopes[fid as usize].globals.clone()
222 }
223
224 fn import_ref(&self, fid: u32, name: &str) -> Option<ModuleRef> {
225 self.file_scopes[fid as usize].imports.get(name).copied()
226 }
227
228 /// Look up a name in a call frame, then the file's globals.
229 fn lookup(&self, frame: &Scope, fid: u32, name: &str) -> Option<Cell> {
230 if let Some(c) = frame.borrow().get(name) {
231 return Some(c.clone());
232 }
233 self.file_scopes[fid as usize]
234 .globals
235 .borrow()
236 .get(name)
237 .cloned()
238 }
239
240 /// The class defined as `name` in file `fid`, if any.
241 fn class_id_in(&self, fid: u32, name: &str) -> Option<u32> {
242 self.file_class_ids[fid as usize].get(name).copied()
243 }
244
245 /// Track the current source location so an uncaught or caught error reports
246 /// the file and line it was raised at, exactly as the compiled program's
247 /// `cur_file`/`cur_line` do.
248 fn mark(&mut self, fid: u32, span: dc::Span) {
249 self.cur_fid = fid;
250 self.cur_line = span.line;
251 }
252
253 /// The path of the file whose code is currently executing, for error values.
254 fn cur_path(&self) -> Rc<str> {
255 self.file_paths
256 .get(self.cur_fid as usize)
257 .cloned()
258 .unwrap_or_else(|| Rc::from("<repl>"))
259 }
260
261 // ----- program integration -----
262
263 /// Fold a loaded program's files into the session: create each file's scope
264 /// and imports, analyze every definition into the callable/class tables, hoist
265 /// top-level functions and classes, and initialize module constants.
266 fn integrate_program(&mut self, program: &dc::Program) {
267 // File 0 already has a scope (the session's entry/globals); add the rest.
268 for file in &program.files {
269 let fid = file.file_id as usize;
270 while self.file_scopes.len() <= fid {
271 self.file_scopes.push(FileScope {
272 globals: scope(),
273 imports: HashMap::new(),
274 });
275 self.file_funcs.push(HashMap::new());
276 self.file_class_ids.push(HashMap::new());
277 self.file_paths.push(Rc::from("<repl>"));
278 }
279 self.file_paths[fid] = Rc::from(file.path.as_str());
280 self.resolve_imports(file);
281 }
282 for file in &program.files {
283 self.analyze_file(&file.script.stmts, file.file_id);
284 self.hoist_file(&file.script.stmts, file.file_id);
285 }
286 // Module constants first, in dependency order, so a module that reads
287 // another's constant finds it ready — then the entry runs inline later.
288 for &fid in &program.init_order {
289 let stmts = program.files[fid as usize].script.stmts.clone();
290 if let Err(err) = self.init_module(&stmts, fid) {
291 // A module's constant initializer failing is a program error; it
292 // surfaces when the entry runs, matching the compiled init order.
293 self.pending_module_error = Some(err);
294 break;
295 }
296 }
297 }
298
299 /// Resolve one file's `so` imports into its scope's import map.
300 fn resolve_imports(&mut self, file: &dc::ProgramFile) {
301 let fid = file.file_id as usize;
302 for (name, module) in &file.stdlib_imports {
303 self.file_scopes[fid]
304 .imports
305 .insert(name.clone(), ModuleRef::Stdlib(module));
306 }
307 for (name, target) in &file.user_imports {
308 self.file_scopes[fid]
309 .imports
310 .insert(name.clone(), ModuleRef::User(*target));
311 }
312 }
313
314 /// Bind a file's top-level functions to function values and pre-bind its
315 /// hoisted variable names to `none`, mirroring the compiler's `Env` fields.
316 fn hoist_file(&mut self, stmts: &[dc::Stmt], fid: u32) {
317 let globals = self.globals(fid);
318 for name in dc::hoisted_names(stmts) {
319 globals
320 .borrow_mut()
321 .entry(name)
322 .or_insert_with(|| cell(Value::None));
323 }
324 self.hoist_functions(stmts, &globals, fid);
325 }
326
327 /// Run one module's constant initializers in its own scope.
328 fn init_module(&mut self, stmts: &[dc::Stmt], fid: u32) -> DogeResult<()> {
329 let globals = self.globals(fid);
330 for stmt in stmts {
331 if matches!(stmt, dc::Stmt::ConstDecl { .. }) {
332 self.exec_stmt(stmt, &globals, fid)?;
333 }
334 }
335 Ok(())
336 }
337
338 /// Execute the entry file's top-level statements (skipping definitions and
339 /// imports, which are already integrated), returning any uncaught error.
340 fn run_entry(&mut self, program: &dc::Program) -> DogeResult<()> {
341 if let Some(err) = self.pending_module_error.take() {
342 return Err(err);
343 }
344 let entry = &program.files[0];
345 let globals = self.globals(0);
346 for stmt in &entry.script.stmts {
347 if matches!(
348 stmt,
349 dc::Stmt::FuncDef { .. } | dc::Stmt::ObjDef { .. } | dc::Stmt::Import { .. }
350 ) {
351 continue;
352 }
353 match self.exec_stmt(stmt, &globals, 0)? {
354 Flow::Normal => {}
355 // `return`/`bork`/`continue` at the top level are rejected by the
356 // checker, so reaching here would be a checked-away impossibility.
357 _ => break,
358 }
359 }
360 Ok(())
361 }
362
363 // ----- REPL session -----
364
365 /// The session's accumulated top-level scope, for seeding the checker of the
366 /// next snippet. Built from live interpreter state so the checker and runtime
367 /// never disagree about what is in scope.
368 pub fn session_scope(&self) -> SessionScope {
369 let mut globals: Vec<String> = self.file_scopes[0]
370 .globals
371 .borrow()
372 .keys()
373 .cloned()
374 .collect();
375 globals.extend(self.file_scopes[0].imports.keys().cloned());
376 globals.extend(self.file_class_ids[0].keys().cloned());
377 let classes = self.file_class_ids[0]
378 .iter()
379 .map(|(name, id)| {
380 let data = &self.classes[*id as usize];
381 ClassInfo {
382 name: name.clone(),
383 parent: data.parent.map(|p| self.classes[p as usize].name.clone()),
384 methods: data.methods.keys().cloned().collect(),
385 }
386 })
387 .collect();
388 SessionScope {
389 globals,
390 consts: self.session_consts.clone(),
391 classes,
392 }
393 }
394
395 /// Evaluate one checked REPL snippet in the session: integrate its definitions,
396 /// run its statements, and return the value of a trailing bare expression for
397 /// the prompt to echo (`None` when the last statement is not a bare expression).
398 pub fn eval_snippet(&mut self, path: &str, script: &dc::Script) -> DogeResult<Option<Value>> {
399 self.file_paths[0] = Rc::from(path);
400 for stmt in &script.stmts {
401 if let dc::Stmt::Import { module, .. } = stmt {
402 self.register_repl_import(module)?;
403 }
404 }
405 self.analyze_file(&script.stmts, 0);
406 self.hoist_file(&script.stmts, 0);
407
408 let globals = self.globals(0);
409 let count = script.stmts.len();
410 for (i, stmt) in script.stmts.iter().enumerate() {
411 match stmt {
412 dc::Stmt::FuncDef { .. } | dc::Stmt::ObjDef { .. } | dc::Stmt::Import { .. } => {}
413 dc::Stmt::ExprStmt { expr } if i + 1 == count => {
414 self.mark(0, expr.span());
415 return Ok(Some(self.eval(expr, &globals, 0)?));
416 }
417 dc::Stmt::ConstDecl { name, .. } => {
418 self.exec_stmt(stmt, &globals, 0)?;
419 if !self.session_consts.iter().any(|c| c == name) {
420 self.session_consts.push(name.clone());
421 }
422 }
423 _ => {
424 self.exec_stmt(stmt, &globals, 0)?;
425 }
426 }
427 }
428 Ok(None)
429 }
430
431 /// Register a `so` import encountered in a REPL snippet. Stdlib modules bind
432 /// into the entry scope; user modules are only available when running a file.
433 fn register_repl_import(&mut self, module: &str) -> DogeResult<()> {
434 if self.file_scopes[0].imports.contains_key(module) {
435 return Ok(());
436 }
437 match dc::stdlib_module(module) {
438 Some(m) => {
439 self.file_scopes[0]
440 .imports
441 .insert(module.to_string(), ModuleRef::Stdlib(m));
442 Ok(())
443 }
444 None => Err(DogeError::new(
445 ErrorKind::ValueError,
446 format!(
447 "user modules aren't available in the repl yet — run the file instead (doge bark <script>.doge) to use {module}"
448 ),
449 )),
450 }
451 }
452}