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