diff --git a/compiler/ds-cli/src/main.rs b/compiler/ds-cli/src/main.rs index 6e33acc..670c07f 100644 --- a/compiler/ds-cli/src/main.rs +++ b/compiler/ds-cli/src/main.rs @@ -69,7 +69,7 @@ fn main() { } } -fn compile(source: &str) -> Result { +fn compile(source: &str, base_dir: &Path) -> Result { // 1. Lex let mut lexer = ds_parser::Lexer::new(source); let tokens = lexer.tokenize(); @@ -83,18 +83,90 @@ fn compile(source: &str) -> Result { // 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 = 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"); diff --git a/compiler/ds-parser/src/ast.rs b/compiler/ds-parser/src/ast.rs index a9d3dff..0532eb3 100644 --- a/compiler/ds-parser/src/ast.rs +++ b/compiler/ds-parser/src/ast.rs @@ -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), +} + +/// `import { Card, Button } from "./components"` +#[derive(Debug, Clone)] +pub struct ImportDecl { + pub names: Vec, + pub source: String, + pub span: Span, } /// `let count = 0` or `let doubled = count * 2` diff --git a/compiler/ds-parser/src/lexer.rs b/compiler/ds-parser/src/lexer.rs index aecb361..503cd26 100644 --- a/compiler/ds-parser/src/lexer.rs +++ b/compiler/ds-parser/src/lexer.rs @@ -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()), }; diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index 4c42dcf..3b3a176 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -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 { + 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 { + 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 { let line = self.current_token().line; self.advance(); // consume 'let' diff --git a/examples/modules/app.ds b/examples/modules/app.ds new file mode 100644 index 0000000..e66fe2f --- /dev/null +++ b/examples/modules/app.ds @@ -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 + ] diff --git a/examples/modules/shared.ds b/examples/modules/shared.ds new file mode 100644 index 0000000..36c4aac --- /dev/null +++ b/examples/modules/shared.ds @@ -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 } + ]