patx/relay-lang

Add Relay module imports across multiple files

Commit 3c13717 · Harrison Erd · 2026-02-10T12:42:58-05:00

Changeset
3c137178425adfcecb2e5849544f9791778f97fc
Parents
d07f8862ec0aec70389f42df0cde371f9586dcfb

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 3fbc3df..2f8cd70 100644
--- a/README.md
+++ b/README.md
@@ -172,6 +172,24 @@ fn example()
 x = 42  // This is also a comment
 ```
 
+### Modules and Imports
+
+Relay supports loading code from multiple `.ry` files with `import`:
+
+```relay
+import utils
+import web.routes
+import shared/helpers.ry
+```
+
+**How imports resolve:**
+- `import utils` loads `utils.ry`
+- `import web.routes` loads `web/routes.ry`
+- Relative paths are resolved from the importing file's directory
+- A module is loaded only once per run (duplicate imports are ignored)
+
+Imported modules execute in the same global scope, so functions and variables they define become directly available.
+
 ### Data Types
 
 #### Primitives
@@ -1825,7 +1843,7 @@ cargo run -- examples/hello.ry
 
 - Follow Rust conventions and `rustfmt` formatting
 - Add comments for complex logic
-- Keep the single-file architecture for now (v0.1)
+- Core interpreter remains in a single Rust file (v0.1) while Relay scripts support multi-file imports
 - Update this README for any user-facing changes
 
 ### Reporting Issues
@@ -1840,7 +1858,7 @@ Found a bug? [Open an issue](https://github.com/patx/relay-lang/issues) with:
 ## Roadmap
 
 **v0.2 (Planned):**
-- [ ] Multiple file support and imports
+- [x] Multiple file support and imports
 - [x] List comprehensions
 - [x] Destructuring assignment
 - [x] Error handling with try/except
diff --git a/src/main.rs b/src/main.rs
index d827eda..de857f7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -11,8 +11,9 @@
 //   cargo run -- path/to/app.ry
 
 use std::{
-    collections::HashMap,
+    collections::{HashMap, HashSet},
     future::Future,
+    path::PathBuf,
     pin::Pin,
     sync::{Arc, Mutex},
     time::Duration,
@@ -70,6 +71,9 @@ struct Program {
 #[derive(Debug, Clone)]
 enum Stmt {
     Expr(Expr),
+    Import {
+        module: String,
+    },
     Assign {
         name: String,
         expr: Expr,
@@ -228,7 +232,7 @@ enum Tok {
     Colon,  // used in dict literals only
     Op(String),
 
-    Keyword(String), // fn if for while return True False None str int float
+    Keyword(String), // fn if for while return try except import True False None str int float
 }
 
 #[derive(Debug, Clone)]
@@ -427,7 +431,7 @@ impl<'a> Lexer<'a> {
                         let id = self.read_ident();
                         let k = match id.as_str() {
                             "fn" | "if" | "for" | "while" | "return" | "try" | "except"
-                            | "True" | "False" | "None" | "str" | "int" | "float" => {
+                            | "import" | "True" | "False" | "None" | "str" | "int" | "float" => {
                                 Tok::Keyword(id)
                             }
                             _ => Tok::Ident(id),
@@ -675,6 +679,9 @@ impl Parser {
         if self.peek_kw("try") {
             return self.parse_try_except();
         }
+        if self.peek_kw("import") {
+            return self.parse_import();
+        }
 
         if self.peek_destructure_assign() {
             return self.parse_destructure_assign();
@@ -846,6 +853,17 @@ impl Parser {
         })
     }
 
+    fn parse_import(&mut self) -> RResult<Stmt> {
+        self.expect_kw("import")?;
+        let mut module = self.expect_ident()?;
+        while self.peek_is(&Tok::Dot) {
+            self.bump();
+            module.push('.');
+            module.push_str(&self.expect_ident()?);
+        }
+        Ok(Stmt::Import { module })
+    }
+
     fn peek_destructure_assign(&self) -> bool {
         let mut idx = 0usize;
         if !matches!(
@@ -1316,9 +1334,7 @@ impl Thunk {
         Self { expr, env }
     }
     async fn run(&self) -> RResult<Value> {
-        let ev = Evaluator {
-            env: self.env.clone(),
-        };
+        let ev = Evaluator::new(self.env.clone());
         ev.eval_expr(&self.expr).await
     }
 }
@@ -1449,6 +1465,13 @@ fn render_template(s: &str, locals: &HashMap<String, Value>) -> String {
 
 struct Evaluator {
     env: Arc<tokio::sync::Mutex<Env>>,
+    module_state: Arc<tokio::sync::Mutex<ModuleState>>,
+}
+
+#[derive(Debug, Clone)]
+struct ModuleState {
+    loaded: HashSet<PathBuf>,
+    stack: Vec<PathBuf>,
 }
 
 enum Flow {
@@ -1458,7 +1481,13 @@ enum Flow {
 
 impl Evaluator {
     fn new(env: Arc<tokio::sync::Mutex<Env>>) -> Self {
-        Self { env }
+        Self {
+            env,
+            module_state: Arc::new(tokio::sync::Mutex::new(ModuleState {
+                loaded: HashSet::new(),
+                stack: Vec::new(),
+            })),
+        }
     }
 
     async fn snapshot_env(&self) -> Arc<tokio::sync::Mutex<Env>> {
@@ -1478,6 +1507,71 @@ impl Evaluator {
         Ok(last)
     }
 
+    async fn eval_program_in_file(&self, p: &Program, file_path: PathBuf) -> RResult<Value> {
+        {
+            let mut state = self.module_state.lock().await;
+            state.loaded.insert(file_path.clone());
+            state.stack.push(file_path);
+        }
+        let result = self.eval_program(p).await;
+        {
+            let mut state = self.module_state.lock().await;
+            state.stack.pop();
+        }
+        result
+    }
+
+    fn module_name_to_relative_path(module: &str) -> PathBuf {
+        if module.ends_with(".ry") || module.contains('/') {
+            return PathBuf::from(module);
+        }
+        let mut pb = PathBuf::new();
+        for part in module.split('.') {
+            pb.push(part);
+        }
+        pb.set_extension("ry");
+        pb
+    }
+
+    async fn import_module(&self, module: &str) -> RResult<()> {
+        let module_path = Self::module_name_to_relative_path(module);
+        let base_dir = {
+            let state = self.module_state.lock().await;
+            state
+                .stack
+                .last()
+                .and_then(|p| p.parent().map(|parent| parent.to_path_buf()))
+                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
+        };
+        let candidate = base_dir.join(module_path);
+        let canonical = tokio::fs::canonicalize(&candidate).await.map_err(|e| {
+            RelayError::Runtime(format!(
+                "Failed to resolve module '{module}' from '{}': {e}",
+                candidate.display()
+            ))
+        })?;
+
+        {
+            let state = self.module_state.lock().await;
+            if state.loaded.contains(&canonical) {
+                return Ok(());
+            }
+        }
+
+        let src = tokio::fs::read_to_string(&canonical).await.map_err(|e| {
+            RelayError::Runtime(format!(
+                "Failed to read module '{}': {e}",
+                canonical.display()
+            ))
+        })?;
+        let mut lexer = Lexer::new(&src);
+        let tokens = lexer.tokenize()?;
+        let mut parser = Parser::new(tokens);
+        let program = parser.parse_program()?;
+        self.eval_program_in_file(&program, canonical).await?;
+        Ok(())
+    }
+
     async fn eval_block(&self, b: &[Stmt]) -> RResult<Flow> {
         for s in b {
             match self.eval_stmt(s).await? {
@@ -1539,6 +1633,10 @@ impl Evaluator {
 
                 Ok(Flow::None)
             }
+            Stmt::Import { module } => {
+                self.import_module(module).await?;
+                Ok(Flow::None)
+            }
             Stmt::Assign { name, expr } => {
                 let v = self.eval_expr(expr).await?;
                 let mut env = self.env.lock().await;
@@ -3706,6 +3804,7 @@ async fn main() -> anyhow::Result<()> {
 
     let mut parser = Parser::new(tokens);
     let program = parser.parse_program()?;
+    let entry_path = tokio::fs::canonicalize(&path).await?;
 
     let env = Arc::new(tokio::sync::Mutex::new(Env::new_global()));
     let evaluator = Arc::new(Evaluator::new(env.clone()));
@@ -3721,7 +3820,7 @@ async fn main() -> anyhow::Result<()> {
 
     // run program (this defines functions, registers decorators if route exists before defs)
     let result = evaluator
-        .eval_program(&program)
+        .eval_program_in_file(&program, entry_path)
         .await
         .map_err(anyhow::Error::msg)?;