patx/relay-lang
Add Relay module imports across multiple files
Commit 3c13717 · Harrison Erd · 2026-02-10T12:42:58-05:00
Comments
No comments yet.
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)?;