feat: v2 module system — import/export with multi-file compilation

Syntax:
  import { Counter, shared_count } from "./shared"
  export let shared_count = 0
  export component Counter = ...

Implementation:
- Lexer: Import, Export keywords
- AST: ImportDecl(names, source), Export(name, inner_decl)
- Parser: parse_import_decl, parse_export_decl
- CLI: resolve_imports() — recursive file resolution, dedup, inline

Resolves relative paths, adds .ds extension, handles transitive imports.
110 tests, 0 failures.
This commit is contained in:
enzotar 2026-02-25 20:36:18 -08:00
parent 26d6c4f17a
commit 6368b798cf
6 changed files with 202 additions and 8 deletions

View file

@ -69,7 +69,7 @@ fn main() {
}
}
fn compile(source: &str) -> Result<String, String> {
fn compile(source: &str, base_dir: &Path) -> Result<String, String> {
// 1. Lex
let mut lexer = ds_parser::Lexer::new(source);
let tokens = lexer.tokenize();
@ -83,18 +83,90 @@ fn compile(source: &str) -> Result<String, String> {
// 2. Parse
let mut parser = ds_parser::Parser::new(tokens);
let program = parser.parse_program().map_err(|e| e.to_string())?;
let mut program = parser.parse_program().map_err(|e| e.to_string())?;
// 3. Analyze
// 3. Resolve imports — inline exported declarations from imported files
resolve_imports(&mut program, base_dir)?;
// 4. Analyze
let graph = ds_analyzer::SignalGraph::from_program(&program);
let views = ds_analyzer::SignalGraph::analyze_views(&program);
// 4. Codegen
// 5. Codegen
let html = ds_codegen::JsEmitter::emit_html(&program, &graph, &views);
Ok(html)
}
/// Resolve `import { X, Y } from "./file"` by parsing the imported file
/// and inlining the matching `export`ed declarations.
fn resolve_imports(program: &mut ds_parser::Program, base_dir: &Path) -> Result<(), String> {
use std::collections::HashSet;
let mut imported_decls = Vec::new();
let mut seen_files: HashSet<PathBuf> = HashSet::new();
for decl in &program.declarations {
if let ds_parser::Declaration::Import(import) = decl {
// Resolve the file path relative to base_dir
let mut import_path = base_dir.join(&import.source);
if !import_path.extension().map_or(false, |e| e == "ds") {
import_path.set_extension("ds");
}
let import_path = import_path.canonicalize().unwrap_or(import_path.clone());
if seen_files.contains(&import_path) {
continue; // Skip duplicate imports
}
seen_files.insert(import_path.clone());
// Read and parse the imported file
let imported_source = fs::read_to_string(&import_path)
.map_err(|e| format!("Cannot import '{}': {}", import.source, e))?;
let mut lexer = ds_parser::Lexer::new(&imported_source);
let tokens = lexer.tokenize();
for tok in &tokens {
if let ds_parser::TokenKind::Error(msg) = &tok.kind {
return Err(format!("Lexer error in '{}' at line {}: {}", import.source, tok.line, msg));
}
}
let mut parser = ds_parser::Parser::new(tokens);
let mut imported_program = parser.parse_program()
.map_err(|e| format!("Parse error in '{}': {}", import.source, e))?;
// Recursively resolve imports in the imported file
let imported_dir = import_path.parent().unwrap_or(base_dir);
resolve_imports(&mut imported_program, imported_dir)?;
// Extract matching exports
let names: HashSet<&str> = import.names.iter().map(|s| s.as_str()).collect();
for d in &imported_program.declarations {
match d {
ds_parser::Declaration::Export(name, inner) if names.contains(name.as_str()) => {
imported_decls.push(*inner.clone());
}
// Also include non-exported decls that exports depend on
// (for now, include all let decls from the imported file)
ds_parser::Declaration::Let(_) => {
imported_decls.push(d.clone());
}
_ => {}
}
}
}
}
// Remove Import declarations and prepend imported decls
program.declarations.retain(|d| !matches!(d, ds_parser::Declaration::Import(_)));
let mut merged = imported_decls;
merged.append(&mut program.declarations);
program.declarations = merged;
Ok(())
}
fn cmd_build(file: &Path, output: &Path) {
println!("🔨 DreamStack build");
println!(" source: {}", file.display());
@ -107,7 +179,8 @@ fn cmd_build(file: &Path, output: &Path) {
}
};
match compile(&source) {
let base_dir = file.parent().unwrap_or(Path::new("."));
match compile(&source, base_dir) {
Ok(html) => {
fs::create_dir_all(output).unwrap();
let out_path = output.join("index.html");
@ -194,7 +267,8 @@ fn cmd_dev(file: &Path, port: u16) {
};
let start = Instant::now();
match compile(&source) {
let base_dir = file.parent().unwrap_or(Path::new("."));
match compile(&source, base_dir) {
Ok(html) => {
let ms = start.elapsed().as_millis();
let html_with_hmr = inject_hmr(&html);
@ -262,7 +336,7 @@ h2 {{ color: #f87171; margin-bottom: 16px; }}
// Recompile
if let Ok(src) = fs::read_to_string(&watch_file) {
let start = Instant::now();
match compile(&src) {
match compile(&src, watch_file.parent().unwrap_or(Path::new("."))) {
Ok(html) => {
let ms = start.elapsed().as_millis();
let new_version = v_watcher.fetch_add(1, Ordering::SeqCst) + 1;
@ -472,7 +546,7 @@ fn cmd_stream(file: &Path, relay: &str, mode: &str, port: u16) {
)
};
match compile(&stream_source) {
match compile(&stream_source, file.parent().unwrap_or(Path::new("."))) {
Ok(html) => {
let html_with_hmr = inject_hmr(&html);
println!("✅ Compiled with streaming enabled");

View file

@ -30,6 +30,18 @@ pub enum Declaration {
Every(EveryDecl),
/// Top-level expression statement: `log("hello")`, `push(items, x)`
ExprStatement(Expr),
/// `import { Card, Button } from "./components"`
Import(ImportDecl),
/// `export let count = 0`, `export component Card(...) = ...`
Export(String, Box<Declaration>),
}
/// `import { Card, Button } from "./components"`
#[derive(Debug, Clone)]
pub struct ImportDecl {
pub names: Vec<String>,
pub source: String,
pub span: Span,
}
/// `let count = 0` or `let doubled = count * 2`

View file

@ -55,6 +55,8 @@ pub enum TokenKind {
Delta,
Signals,
Every,
Import,
Export,
// Operators
Plus,
@ -330,6 +332,8 @@ impl Lexer {
"route" => TokenKind::Route,
"navigate" => TokenKind::Navigate,
"every" => TokenKind::Every,
"import" => TokenKind::Import,
"export" => TokenKind::Export,
_ => TokenKind::Ident(ident.clone()),
};

View file

@ -99,6 +99,8 @@ impl Parser {
TokenKind::Constrain => self.parse_constrain_decl(),
TokenKind::Stream => self.parse_stream_decl(),
TokenKind::Every => self.parse_every_decl(),
TokenKind::Import => self.parse_import_decl(),
TokenKind::Export => self.parse_export_decl(),
// Expression statement: `log("hello")`, `push(items, x)`
TokenKind::Ident(_) => {
let expr = self.parse_expr()?;
@ -125,6 +127,83 @@ impl Parser {
}))
}
// import { Card, Button } from "./components"
fn parse_import_decl(&mut self) -> Result<Declaration, ParseError> {
let line = self.current_token().line;
self.advance(); // consume 'import'
// Parse the import names: { name1, name2 }
self.expect(&TokenKind::LBrace)?;
let mut names = Vec::new();
loop {
self.skip_newlines();
if self.check(&TokenKind::RBrace) {
break;
}
match self.peek().clone() {
TokenKind::Ident(name) => {
names.push(name);
self.advance();
}
// Allow keywords to be imported as names (e.g., component names)
_ => {
let tok = self.peek().clone();
return Err(self.error(format!("expected identifier in import, got {:?}", tok)));
}
}
self.skip_newlines();
if self.check(&TokenKind::Comma) {
self.advance(); // consume ','
}
}
self.expect(&TokenKind::RBrace)?;
// Parse "from"
self.expect(&TokenKind::From)?;
// Parse the source path
let source = match self.peek().clone() {
TokenKind::StringFragment(s) => {
self.advance();
// Consume the StringEnd if present
if self.check(&TokenKind::StringEnd) {
self.advance();
}
s
}
TokenKind::StringEnd => {
self.advance();
String::new()
}
_ => return Err(self.error("expected string after 'from'".to_string())),
};
Ok(Declaration::Import(ImportDecl {
names,
source,
span: Span { start: 0, end: 0, line },
}))
}
// export let count = 0 / export component Card(...) = ...
fn parse_export_decl(&mut self) -> Result<Declaration, ParseError> {
self.advance(); // consume 'export'
// Parse the inner declaration
let inner = self.parse_declaration()?;
// Extract the name being exported
let name = match &inner {
Declaration::Let(d) => d.name.clone(),
Declaration::View(d) => d.name.clone(),
Declaration::Component(d) => d.name.clone(),
Declaration::Effect(d) => d.name.clone(),
_ => return Err(self.error("can only export let, view, component, or effect".to_string())),
};
Ok(Declaration::Export(name, Box::new(inner)))
}
fn parse_let_decl(&mut self) -> Result<Declaration, ParseError> {
let line = self.current_token().line;
self.advance(); // consume 'let'

15
examples/modules/app.ds Normal file
View file

@ -0,0 +1,15 @@
-- Multi-file module demo
-- Imports shared state and components from another .ds file
import { shared_count, Counter } from "./shared"
let local_count = 0
view app =
column [
text "Module Demo"
text "Local: {local_count}"
button "Local +1" { click: local_count += 1 }
text "Shared (from module): {shared_count}"
Counter
]

View file

@ -0,0 +1,10 @@
-- Shared state module — exported values used by the main app
export let shared_count = 0
export component Counter =
column [
text "Shared counter: {shared_count}"
button "+" { click: shared_count += 1 }
button "-" { click: shared_count -= 1 }
]