Skip to main content

newter_compiler/
lib.rs

1//! Newt — design canvas compiler and live renderer.
2//!
3//! Parses `.newt` UI source, builds a layout tree, and renders to a wgpu canvas.
4
5pub mod app;
6pub mod ast;
7pub mod error;
8pub mod html;
9pub mod layout;
10pub mod lexer;
11pub mod parser;
12pub mod react;
13pub mod renderer;
14pub mod serve;
15pub mod value;
16
17pub use ast::{ImportDecl, Program, ProgramItem, ScreenDecl, ThemeDecl};
18pub use error::{format_error, NewtError, Source, Span};
19pub use html::{layout_to_html, layout_to_reactive_html};
20pub use react::layout_to_react;
21pub use layout::{layout_tree, LayoutNode, Rect};
22pub use lexer::{TokenCategory, TokenKind};
23pub use parser::Parser;
24pub use app::App;
25pub use value::{EvalContext, Value, value_to_json};
26
27use std::collections::HashSet;
28
29use lexer::Lexer;
30
31/// Collect color variables from the program for CSS export (:root { --name: #hex; }).
32pub fn theme_css_vars(program: &Program) -> Vec<(String, String)> {
33    let ctx = EvalContext::from_program(program);
34    let mut out = Vec::new();
35    for (name, val) in &ctx.variables {
36        if let Value::Color { r, g, b, a } = val {
37            let hex = if *a == 255 {
38                format!("#{:02x}{:02x}{:02x}", r, g, b)
39            } else {
40                format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a)
41            };
42            out.push((name.clone(), hex));
43        }
44    }
45    out
46}
47/// Returns true if the program contains any `state` declarations.
48pub fn has_state_vars(program: &Program) -> bool {
49    program.items.iter().any(|item| matches!(item, ProgramItem::StateDecl(_)))
50}
51
52use std::path::{Path, PathBuf};
53
54/// Tokenize source for syntax highlighting. Returns (span, category) for each token until Eof.
55pub fn tokenize(source: &str) -> Result<Vec<(Span, TokenCategory)>, NewtError> {
56    let mut lexer = Lexer::new(source, None);
57    let mut out = Vec::new();
58    loop {
59        let tok = lexer.next_token()?;
60        let span = tok.span;
61        let cat = tok.kind.category();
62        out.push((span, cat));
63        if matches!(tok.kind, TokenKind::Eof) {
64            break;
65        }
66    }
67    Ok(out)
68}
69
70/// Parse source code into an AST.
71/// Returns the first error for backward compatibility. Use `parse_all` for all errors.
72pub fn parse(source: &str, path: Option<&str>) -> Result<Program, NewtError> {
73    let mut parser = Parser::new(source, path)?;
74    parser.parse().map_err(|mut errors| {
75        errors.drain(1..);
76        errors.pop().unwrap()
77    })
78}
79
80/// Parse source code, returning all errors at once instead of stopping at the first.
81pub fn parse_all(source: &str, path: Option<&str>) -> Result<Program, Vec<NewtError>> {
82    let mut parser = match Parser::new(source, path) {
83        Ok(p) => p,
84        Err(e) => return Err(vec![e]),
85    };
86    parser.parse()
87}
88
89/// Collect all parse errors from the source. Returns an empty vec on success.
90/// Designed for LSP use where all diagnostics should appear at once.
91pub fn check_all(source: &str, path: Option<&std::path::Path>) -> Vec<NewtError> {
92    let trimmed = source.trim();
93    let path_str = path.and_then(|p| p.to_str());
94    match parse_all(trimmed, path_str) {
95        Ok(program) => {
96            // Parse succeeded — try compile to catch semantic/layout errors
97            let program = if let Some(p) = path {
98                let base = p.parent().unwrap_or_else(|| std::path::Path::new("."));
99                match resolve_imports(program, base) {
100                    Ok(p) => p,
101                    Err(e) => return vec![e],
102                }
103            } else {
104                program
105            };
106            match get_screen(&program, None) {
107                Some(screen) => {
108                    let rect = layout::Rect::new(0.0, 0.0, DEFAULT_VIEWPORT_W, DEFAULT_VIEWPORT_H);
109                    let ctx = EvalContext::from_program(&program);
110                    match layout_tree(&ctx, &screen.body, rect) {
111                        Ok(_) => vec![],
112                        Err(e) => vec![e],
113                    }
114                }
115                None => vec![],
116            }
117        }
118        Err(errors) => errors,
119    }
120}
121
122/// Parse a file into an AST.
123pub fn parse_file(path: &Path) -> Result<Program, NewtError> {
124    let source = std::fs::read_to_string(path)?;
125    parse(&source, path.to_str())
126}
127
128/// Symbol kind for LSP (hover, completion, go-to-definition).
129#[derive(Debug, Clone)]
130pub enum SymbolKind {
131    Variable,
132    Component { params: Vec<String> },
133    Screen,
134    Theme,
135}
136
137/// Build a symbol table from a program: name → (span, kind). Later definitions shadow earlier.
138pub fn symbol_table(program: &Program) -> std::collections::HashMap<String, (Span, SymbolKind)> {
139    use std::collections::HashMap;
140    let mut map = HashMap::new();
141    for item in &program.items {
142        match item {
143            ProgramItem::Variable(v) => {
144                map.insert(
145                    v.name.clone(),
146                    (v.span, SymbolKind::Variable),
147                );
148            }
149            ProgramItem::Component(c) => {
150                map.insert(
151                    c.name.clone(),
152                    (c.span, SymbolKind::Component { params: c.params.clone() }),
153                );
154            }
155            ProgramItem::Screen(s) => {
156                map.insert(s.name.clone(), (s.span, SymbolKind::Screen));
157            }
158            ProgramItem::Theme(t) => {
159                map.insert(t.name.clone(), (t.span, SymbolKind::Theme));
160            }
161            ProgramItem::StateDecl(sd) => {
162                map.insert(sd.name.clone(), (sd.span, SymbolKind::Variable));
163            }
164            _ => {}
165        }
166    }
167    map
168}
169
170/// Keywords for completion (top-level and expression context).
171pub fn completion_keywords() -> Vec<&'static str> {
172    vec![
173        "screen", "let", "state", "component", "theme", "use", "import", "if", "for", "else", "in",
174    ]
175}
176
177/// Element names for completion (row, column, card, button, etc.).
178pub fn completion_element_names() -> Vec<&'static str> {
179    vec![
180        "header", "footer", "container", "sidebar", "section", "box", "text", "row", "column",
181        "grid", "stack", "center", "spacer", "image", "button", "input", "card", "widget",
182        "accordion", "bento", "breadcrumb", "hamburger", "kebab", "meatballs", "doner", "tabs",
183        "pagination", "linkList", "nav", "password", "search", "checkbox", "radio", "dropdown",
184        "combobox", "multiselect", "datePicker", "picker", "slider", "stepper", "toggle", "form",
185        "modal", "confirmDialog", "toast", "notification", "alert", "messageBox", "tooltip",
186        "loader", "progressBar", "badge", "icon", "tag", "comment", "feed", "carousel", "chart",
187    ]
188}
189
190/// Prop names valid for elements (layout, style, content).
191pub fn completion_prop_names() -> Vec<&'static str> {
192    vec![
193        "width", "height", "fill", "stroke", "strokeWidth", "radius", "padding", "gap",
194        "grow", "shrink", "align", "justify", "direction", "fontSize", "fontWeight", "shadow",
195        "content", "minWidth", "maxWidth", "minHeight", "maxHeight", "transition", "aspectRatio",
196        "columns", "rows", "src", "role", "ariaLabel", "focusOrder", "onClick", "href", "name",
197    ]
198}
199
200/// All screen names in the program (order preserved).
201pub fn screen_names(program: &Program) -> Vec<String> {
202    program
203        .items
204        .iter()
205        .filter_map(|i| match i {
206            ProgramItem::Screen(s) => Some(s.name.clone()),
207            _ => None,
208        })
209        .collect()
210}
211
212/// Get a screen by name. If `name` is None, returns the first screen.
213/// Use for multi-screen apps: Home, Dashboard, Settings, etc.
214pub fn get_screen<'a>(program: &'a Program, name: Option<&str>) -> Option<&'a ScreenDecl> {
215    for item in &program.items {
216        if let ProgramItem::Screen(s) = item {
217            if name.map_or(true, |n| s.name == n) {
218                return Some(s);
219            }
220        }
221    }
222    None
223}
224
225/// Default viewport size for IDE and export (width, height).
226pub const DEFAULT_VIEWPORT_W: f32 = 960.0;
227pub const DEFAULT_VIEWPORT_H: f32 = 640.0;
228
229/// Default port for the canvas IDE server.
230pub const DEFAULT_SERVE_PORT: u16 = 3333;
231
232/// Single compile entry: parse → resolve_imports (when path is set) → EvalContext → get_screen → layout_tree.
233/// Use this from CLI, serve, LSP, and app so behavior and diagnostics are consistent.
234/// Viewport is (0, 0, DEFAULT_VIEWPORT_W, DEFAULT_VIEWPORT_H) unless overridden later by the consumer.
235pub fn compile(
236    source: &str,
237    path: Option<&std::path::Path>,
238    screen_name: Option<&str>,
239) -> Result<(Program, layout::LayoutNode), NewtError> {
240    let trimmed = source.trim();
241    let path_str = path.and_then(|p| p.to_str());
242    let program = parse(trimmed, path_str)?;
243    let program = if let Some(p) = path {
244        let base = p.parent().unwrap_or_else(|| std::path::Path::new("."));
245        resolve_imports(program, base)?
246    } else {
247        program
248    };
249    let screen = get_screen(&program, screen_name).ok_or_else(|| {
250        let names = screen_names(&program);
251        if names.is_empty() {
252            NewtError::Other("no screen found".to_string())
253        } else {
254            NewtError::Other(format!(
255                "screen '{}' not found. Available: {}",
256                screen_name.unwrap_or(""),
257                names.join(", ")
258            ))
259        }
260    })?;
261    let rect = layout::Rect::new(0.0, 0.0, DEFAULT_VIEWPORT_W, DEFAULT_VIEWPORT_H);
262    let ctx = EvalContext::from_program(&program);
263    let layout = layout_tree(&ctx, &screen.body, rect)?;
264    Ok((program, layout))
265}
266
267/// Like `compile()` but accepts state overrides and returns the effective state.
268/// State overrides are applied to variables matching `state` declarations in the program.
269pub fn compile_with_state(
270    source: &str,
271    path: Option<&std::path::Path>,
272    screen_name: Option<&str>,
273    state_overrides: &std::collections::HashMap<String, Value>,
274) -> Result<(Program, layout::LayoutNode, std::collections::HashMap<String, Value>), NewtError> {
275    let trimmed = source.trim();
276    let path_str = path.and_then(|p| p.to_str());
277    let program = parse(trimmed, path_str)?;
278    let program = if let Some(p) = path {
279        let base = p.parent().unwrap_or_else(|| std::path::Path::new("."));
280        resolve_imports(program, base)?
281    } else {
282        program
283    };
284    let screen = get_screen(&program, screen_name).ok_or_else(|| {
285        let names = screen_names(&program);
286        if names.is_empty() {
287            NewtError::Other("no screen found".to_string())
288        } else {
289            NewtError::Other(format!(
290                "screen '{}' not found. Available: {}",
291                screen_name.unwrap_or(""),
292                names.join(", ")
293            ))
294        }
295    })?;
296
297    // Collect state variable names
298    let state_var_names: std::collections::HashSet<String> = program
299        .items
300        .iter()
301        .filter_map(|item| {
302            if let ProgramItem::StateDecl(sd) = item {
303                Some(sd.name.clone())
304            } else {
305                None
306            }
307        })
308        .collect();
309
310    let rect = layout::Rect::new(0.0, 0.0, DEFAULT_VIEWPORT_W, DEFAULT_VIEWPORT_H);
311    let mut ctx = EvalContext::from_program(&program);
312
313    // Apply state overrides
314    for (name, val) in state_overrides {
315        if state_var_names.contains(name) {
316            ctx.variables.insert(name.clone(), val.clone());
317        }
318    }
319
320    let layout = layout_tree(&ctx, &screen.body, rect)?;
321
322    // Build effective state (only state vars)
323    let mut effective_state = std::collections::HashMap::new();
324    for name in &state_var_names {
325        if let Some(val) = ctx.variables.get(name) {
326            effective_state.insert(name.clone(), val.clone());
327        }
328    }
329
330    Ok((program, layout, effective_state))
331}
332
333/// Resolve all `import "path.newt"` declarations by loading and merging those files.
334/// Avoids circular imports. `base_dir` is the directory of the current file.
335pub fn resolve_imports(program: Program, base_dir: &Path) -> Result<Program, NewtError> {
336    let mut visited = HashSet::new();
337    resolve_imports_inner(program, base_dir, &mut visited)
338}
339
340fn resolve_imports_inner(
341    program: Program,
342    base_dir: &Path,
343    visited: &mut HashSet<PathBuf>,
344) -> Result<Program, NewtError> {
345    let mut items = Vec::new();
346    for item in program.items {
347        match item {
348            ProgramItem::Import(decl) => {
349                let path = base_dir.join(&decl.path);
350                let path = path
351                    .canonicalize()
352                    .map_err(|e| NewtError::Other(format!("import '{}': {}", decl.path, e)))?;
353                if visited.contains(&path) {
354                    return Err(NewtError::Other(format!(
355                        "circular import: {}",
356                        path.display()
357                    )));
358                }
359                visited.insert(path.clone());
360                let content = std::fs::read_to_string(&path)
361                    .map_err(|e| NewtError::Other(format!("read {}: {}", path.display(), e)))?;
362                let parsed = parse(&content, path.to_str())?;
363                let parent = path.parent().unwrap_or(base_dir);
364                let resolved = resolve_imports_inner(parsed, parent, visited)?;
365                items.extend(resolved.items);
366            }
367            other => items.push(other),
368        }
369    }
370    Ok(Program { items })
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn parse_default_program() {
379        let source = r#"
380let padding = 24;
381let fill = #f0f0f0;
382screen Main {
383  column { gap: 16, padding: padding } {
384    box { fill: #ffffff, radius: 8 } { text { content: "Hi", fontSize: 24 } }
385  }
386}
387"#;
388        let program = parse(source.trim(), None).expect("parse should succeed");
389        assert!(!program.items.is_empty());
390    }
391
392    #[test]
393    fn parse_screen_header_container_syntax() {
394        let source = r#"
395let padding = 24;
396let cardFill = #ffffff;
397let accent = #2563eb;
398
399screen(Main) {
400    header (
401        row ( gap: 12, padding: 16 ) (
402            text("Newt Canvas")
403            button("Menu")
404        )
405    )
406    container (
407        column ( gap: 20, padding: padding ) (
408            card ( fill: cardFill, radius: 12, padding: 24, stroke: #e5e7eb ) (
409                text("Newt Canvas", fontSize: 28)
410            )
411            row ( gap: 12 ) (
412                button("One", fill: #f3f4f6, radius: 8)
413                button("Two", fill: #f3f4f6, radius: 8)
414                button("Three", fill: accent, radius: 8)
415            )
416        )
417    )
418}
419"#;
420        let program = parse(source.trim(), None).expect("parse should succeed");
421        assert!(!program.items.is_empty());
422        let ctx = EvalContext::from_program(&program);
423        let screen = program.items.iter().find_map(|i| {
424            if let crate::ast::ProgramItem::Screen(s) = i {
425                Some(s)
426            } else {
427                None
428            }
429        });
430        let screen = screen.expect("one screen");
431        let rect = crate::layout::Rect::new(0.0, 0.0, 960.0, 640.0);
432        let layout = layout_tree(&ctx, &screen.body, rect).expect("layout");
433        assert!(!layout.children.is_empty(), "screen should have header + container");
434    }
435
436    // --- Lexer Tests ---
437
438    #[test]
439    fn lex_number_tokens() {
440        let mut lexer = lexer::Lexer::new("42 3.14", None);
441        let t1 = lexer.next_token().unwrap();
442        assert!(matches!(t1.kind, TokenKind::Number(n) if (n - 42.0).abs() < f64::EPSILON));
443        let t2 = lexer.next_token().unwrap();
444        assert!(matches!(t2.kind, TokenKind::Number(n) if (n - 3.14).abs() < 0.001));
445    }
446
447    #[test]
448    fn lex_string_with_escapes() {
449        let mut lexer = lexer::Lexer::new(r#""hello\nworld""#, None);
450        let t = lexer.next_token().unwrap();
451        assert!(matches!(t.kind, TokenKind::String(ref s) if s == "hello\nworld"));
452    }
453
454    #[test]
455    fn lex_hex_color_6_and_8() {
456        let mut lexer = lexer::Lexer::new("#ff0000 #00ff0080", None);
457        let t1 = lexer.next_token().unwrap();
458        assert!(matches!(t1.kind, TokenKind::HexColor(255, 0, 0, 255)));
459        let t2 = lexer.next_token().unwrap();
460        assert!(matches!(t2.kind, TokenKind::HexColor(0, 255, 0, 128)));
461    }
462
463    #[test]
464    fn lex_two_char_operators() {
465        let mut lexer = lexer::Lexer::new("-> == != <= >= && ||", None);
466        let kinds: Vec<_> = std::iter::from_fn(|| {
467            let t = lexer.next_token().ok()?;
468            if matches!(t.kind, TokenKind::Eof) { None } else { Some(t.kind) }
469        }).collect();
470        assert_eq!(kinds, vec![
471            TokenKind::Arrow, TokenKind::EqEq, TokenKind::NotEq,
472            TokenKind::Le, TokenKind::Ge, TokenKind::And, TokenKind::Or,
473        ]);
474    }
475
476    #[test]
477    fn lex_keywords_vs_idents() {
478        let mut lexer = lexer::Lexer::new("let screen myVar", None);
479        let t1 = lexer.next_token().unwrap();
480        assert!(matches!(t1.kind, TokenKind::Let));
481        let t2 = lexer.next_token().unwrap();
482        assert!(matches!(t2.kind, TokenKind::Screen));
483        let t3 = lexer.next_token().unwrap();
484        assert!(matches!(t3.kind, TokenKind::Ident(ref s) if s == "myVar"));
485    }
486
487    #[test]
488    fn lex_unterminated_string_error() {
489        let mut lexer = lexer::Lexer::new(r#""hello"#, None);
490        let result = lexer.next_token();
491        assert!(result.is_err());
492    }
493
494    #[test]
495    fn lex_invalid_hex_color() {
496        let mut lexer = lexer::Lexer::new("#fff", None);
497        let result = lexer.next_token();
498        assert!(result.is_err());
499    }
500
501    #[test]
502    fn lex_comments_skipped() {
503        let mut lexer = lexer::Lexer::new("42 // comment\n7", None);
504        let t1 = lexer.next_token().unwrap();
505        assert!(matches!(t1.kind, TokenKind::Number(n) if (n - 42.0).abs() < f64::EPSILON));
506        let t2 = lexer.next_token().unwrap();
507        assert!(matches!(t2.kind, TokenKind::Number(n) if (n - 7.0).abs() < f64::EPSILON));
508    }
509
510    // --- Parser Tests ---
511
512    #[test]
513    fn parse_variable_declaration() {
514        let program = parse("let x = 42; screen Main { box {} }", None).unwrap();
515        assert!(matches!(&program.items[0], ProgramItem::Variable(v) if v.name == "x"));
516    }
517
518    #[test]
519    fn parse_component_with_params() {
520        let source = "component Card(title, color) { box { fill: color } { text { content: title } } } screen Main { Card(\"Hi\", #ff0000) }";
521        let program = parse(source, None).unwrap();
522        if let ProgramItem::Component(c) = &program.items[0] {
523            assert_eq!(c.name, "Card");
524            assert_eq!(c.params, vec!["title", "color"]);
525        } else {
526            panic!("expected component");
527        }
528    }
529
530    #[test]
531    fn parse_theme_and_use() {
532        let source = r#"
533theme Dark {
534    let bg = #1a1a1a;
535    let fg = #ffffff;
536}
537use theme Dark;
538screen Main { box { fill: bg } }
539"#;
540        let program = parse(source.trim(), None).unwrap();
541        assert!(matches!(&program.items[0], ProgramItem::Theme(t) if t.name == "Dark"));
542        assert!(matches!(&program.items[1], ProgramItem::UseTheme(n) if n == "Dark"));
543    }
544
545    #[test]
546    fn parse_if_else() {
547        let source = "screen Main { if true { box {} } else { text { content: \"no\" } } }";
548        let result = parse(source, None);
549        assert!(result.is_ok());
550    }
551
552    #[test]
553    fn parse_for_loop() {
554        let source = "screen Main { for i in range(3) { text { content: \"item\" } } }";
555        let result = parse(source, None);
556        assert!(result.is_ok());
557    }
558
559    #[test]
560    fn parse_error_suggestion() {
561        let result = parse("screan Main { box {} }", None);
562        assert!(result.is_err());
563        let err = result.unwrap_err();
564        assert!(err.suggestion().is_some());
565    }
566
567    #[test]
568    fn parse_nested_elements() {
569        let source = "screen Main { column { gap: 16 } { row { gap: 8 } { box {} box {} } } }";
570        let (_, layout) = compile(source, None, None).unwrap();
571        // Screen body is a Block with one child: the column
572        let col = &layout.children[0];
573        // Column has one child: the row
574        assert_eq!(col.children.len(), 1);
575        // Row has two children: the boxes
576        assert_eq!(col.children[0].children.len(), 2);
577    }
578
579    // --- Eval Tests ---
580
581    #[test]
582    fn eval_binary_ops() {
583        let source = "let x = 2 + 3; screen Main { box {} }";
584        let program = parse(source, None).unwrap();
585        let ctx = EvalContext::from_program(&program);
586        let val = ctx.variables.get("x").unwrap();
587        assert!(matches!(val, Value::Number(n) if (*n - 5.0).abs() < f64::EPSILON));
588    }
589
590    #[test]
591    fn eval_comparison_ops() {
592        let source = "let a = 5 > 3; let b = 2 == 2; screen Main { box {} }";
593        let program = parse(source, None).unwrap();
594        let ctx = EvalContext::from_program(&program);
595        assert!(matches!(ctx.variables.get("a"), Some(Value::Bool(true))));
596        assert!(matches!(ctx.variables.get("b"), Some(Value::Bool(true))));
597    }
598
599    #[test]
600    fn eval_block_scoped_variables() {
601        let source = "screen Main { box {} }";
602        let program = parse(source, None).unwrap();
603        let ctx = EvalContext::from_program(&program);
604        let block_expr = crate::ast::Expr::Block {
605            stmts: vec![
606                crate::ast::Stmt::Let {
607                    name: "x".to_string(),
608                    value: crate::ast::Expr::Literal(crate::ast::Literal::Number(42.0)),
609                    span: Span::new(0, 0, 1, 1),
610                },
611                crate::ast::Stmt::Expr(crate::ast::Expr::Ident("x".to_string(), Span::new(0, 0, 1, 1))),
612            ],
613            span: Span::new(0, 0, 1, 1),
614        };
615        let result = value::eval_expr(&ctx, &block_expr).unwrap();
616        assert!(matches!(result, Value::Number(n) if (n - 42.0).abs() < f64::EPSILON));
617    }
618
619    #[test]
620    fn eval_for_loop() {
621        let source = "screen Main { box {} }";
622        let program = parse(source, None).unwrap();
623        let ctx = EvalContext::from_program(&program);
624        let for_expr = crate::ast::Expr::For {
625            var: "i".to_string(),
626            iter: Box::new(crate::ast::Expr::Call {
627                callee: "range".to_string(),
628                args: vec![crate::ast::Expr::Literal(crate::ast::Literal::Number(3.0))],
629                slot_args: None,
630                span: Span::new(0, 0, 1, 1),
631            }),
632            body: Box::new(crate::ast::Expr::Ident("i".to_string(), Span::new(0, 0, 1, 1))),
633            span: Span::new(0, 0, 1, 1),
634        };
635        let result = value::eval_expr(&ctx, &for_expr).unwrap();
636        if let Value::Array(arr) = result {
637            assert_eq!(arr.len(), 3);
638            assert!(matches!(&arr[0], Value::Number(n) if (*n - 0.0).abs() < f64::EPSILON));
639            assert!(matches!(&arr[2], Value::Number(n) if (*n - 2.0).abs() < f64::EPSILON));
640        } else {
641            panic!("expected array from for loop");
642        }
643    }
644
645    #[test]
646    fn eval_theme_variables() {
647        let source = r#"
648theme Light {
649    let bg = #ffffff;
650    let text_color = #000000;
651}
652use theme Light;
653let x = bg;
654screen Main { box {} }
655"#;
656        let program = parse(source.trim(), None).unwrap();
657        let ctx = EvalContext::from_program(&program);
658        assert!(matches!(ctx.variables.get("bg"), Some(Value::Color { r: 255, g: 255, b: 255, a: 255 })));
659        assert!(ctx.variables.get("x").is_some());
660    }
661
662    #[test]
663    fn eval_if_else_expr() {
664        let source = "screen Main { box {} }";
665        let program = parse(source, None).unwrap();
666        let ctx = EvalContext::from_program(&program);
667        let if_expr = crate::ast::Expr::If {
668            cond: Box::new(crate::ast::Expr::Literal(crate::ast::Literal::Bool(false))),
669            then_branch: Box::new(crate::ast::Expr::Literal(crate::ast::Literal::Number(1.0))),
670            else_branch: Some(Box::new(crate::ast::Expr::Literal(crate::ast::Literal::Number(2.0)))),
671            span: Span::new(0, 0, 1, 1),
672        };
673        let result = value::eval_expr(&ctx, &if_expr).unwrap();
674        assert!(matches!(result, Value::Number(n) if (n - 2.0).abs() < f64::EPSILON));
675    }
676
677    // --- Layout Tests ---
678
679    #[test]
680    fn layout_row_splits_width() {
681        let source = "screen Main { row { gap: 0 } { box {} box {} } }";
682        let (_, layout) = compile(source, None, None).unwrap();
683        let row = &layout.children[0];
684        assert_eq!(row.children.len(), 2);
685        let w1 = row.children[0].rect.w;
686        let w2 = row.children[1].rect.w;
687        assert!((w1 - w2).abs() < 1.0, "row children should have equal width");
688    }
689
690    #[test]
691    fn layout_column_splits_height() {
692        let source = "screen Main { column { gap: 0 } { box {} box {} box {} } }";
693        let (_, layout) = compile(source, None, None).unwrap();
694        let col = &layout.children[0];
695        assert_eq!(col.children.len(), 3);
696        let h1 = col.children[0].rect.h;
697        let h2 = col.children[1].rect.h;
698        assert!((h1 - h2).abs() < 1.0, "column children should have equal height");
699    }
700
701    #[test]
702    fn layout_respects_padding() {
703        let source = "screen Main { box { padding: 20 } { text { content: \"hi\" } } }";
704        let (_, layout) = compile(source, None, None).unwrap();
705        let child = &layout.children[0].children[0];
706        assert!(child.rect.x >= 20.0);
707        assert!(child.rect.y >= 20.0);
708    }
709
710    #[test]
711    fn layout_fixed_width_child() {
712        let source = "screen Main { row { gap: 0 } { box { width: 100 } box {} } }";
713        let (_, layout) = compile(source, None, None).unwrap();
714        let row = &layout.children[0];
715        let w0 = row.children[0].rect.w;
716        assert!((w0 - 100.0).abs() < 1.0, "fixed width child should be 100px");
717    }
718
719    #[test]
720    fn layout_for_loop_produces_children() {
721        let source = "screen Main { for i in range(4) { box {} } }";
722        let (_, layout) = compile(source, None, None).unwrap();
723        assert_eq!(layout.children[0].children.len(), 4);
724    }
725
726    #[test]
727    fn layout_visibility_min_width() {
728        let source = "screen Main { box { minWidth: 2000 } { text { content: \"hidden\" } } }";
729        let (_, layout) = compile(source, None, None).unwrap();
730        // Should produce an empty node (hidden because viewport < 2000)
731        assert!(layout.children[0].children.is_empty());
732    }
733
734    #[test]
735    fn layout_visibility_min_height() {
736        let source = "screen Main { box { minHeight: 2000 } { text { content: \"hidden\" } } }";
737        let (_, layout) = compile(source, None, None).unwrap();
738        assert!(layout.children[0].children.is_empty());
739    }
740
741    // --- HTML Export Tests ---
742
743    #[test]
744    fn html_export_contains_text() {
745        let source = "screen Main { text { content: \"Hello World\" } }";
746        let (program, layout) = compile(source, None, None).unwrap();
747        let vars = theme_css_vars(&program);
748        let html = layout_to_html(&layout, 960, 640, if vars.is_empty() { None } else { Some(&vars) });
749        assert!(html.contains("Hello World"));
750        assert!(html.contains("<!DOCTYPE html>"));
751    }
752
753    #[test]
754    fn html_export_escapes_entities() {
755        let source = r#"screen Main { text { content: "<script>alert('xss')</script>" } }"#;
756        let (_, layout) = compile(source, None, None).unwrap();
757        let html = layout_to_html(&layout, 960, 640, None);
758        assert!(!html.contains("<script>"));
759        assert!(html.contains("&lt;script&gt;"));
760    }
761
762    #[test]
763    fn html_export_theme_vars() {
764        let source = r#"
765theme MyTheme { let primary = #ff0000; }
766use theme MyTheme;
767screen Main { box { fill: primary } }
768"#;
769        let (program, layout) = compile(source.trim(), None, None).unwrap();
770        let vars = theme_css_vars(&program);
771        let html = layout_to_html(&layout, 960, 640, Some(&vars));
772        assert!(html.contains("--primary"));
773    }
774
775    // --- Compile / End-to-End Tests ---
776
777    #[test]
778    fn compile_full_pipeline() {
779        let source = r#"
780let accent = #2563eb;
781screen Main {
782    column { gap: 16, padding: 24 } {
783        text { content: "Title", fontSize: 28 }
784        row { gap: 12 } {
785            button("Click Me", fill: accent, radius: 8)
786            button("Cancel", fill: #e5e7eb, radius: 8)
787        }
788    }
789}
790"#;
791        let result = compile(source.trim(), None, None);
792        assert!(result.is_ok());
793    }
794
795    #[test]
796    fn compile_missing_screen_error() {
797        let source = "let x = 5;";
798        let result = compile(source, None, None);
799        assert!(result.is_err());
800    }
801
802    #[test]
803    fn compile_named_screen_selection() {
804        let source = r#"
805screen Home { box { fill: #ff0000 } }
806screen About { box { fill: #00ff00 } }
807"#;
808        let (_, layout) = compile(source.trim(), None, Some("About")).unwrap();
809        // The About screen has green fill
810        assert!(matches!(layout.children[0].fill, Some((0, 255, 0, 255))));
811    }
812
813    #[test]
814    fn compile_component_call() {
815        let source = r#"
816component Badge(label) {
817    box { fill: #ff0000, radius: 4 } {
818        text { content: label }
819    }
820}
821screen Main { Badge("New") }
822"#;
823        let result = compile(source.trim(), None, None);
824        assert!(result.is_ok());
825    }
826
827    // --- Error System Tests ---
828
829    #[test]
830    fn error_has_span_info() {
831        let _result = parse("screen Main { invalid_stuff }", None);
832        // Test a guaranteed parse error with span info
833        let result = parse("{{{{", None);
834        assert!(result.is_err());
835    }
836
837    #[test]
838    fn error_format_pretty() {
839        let source = "screan Main { box {} }";
840        let src = Source::new(source.to_string(), Some("test.newt".to_string()));
841        let err = parse(source, Some("test.newt")).unwrap_err();
842        let formatted = format_error(&src, &err);
843        assert!(formatted.contains("test.newt"));
844        assert!(formatted.contains("error:"));
845    }
846
847    // --- String Interpolation Tests ---
848
849    /// Walk layout tree and collect all text content strings.
850    fn collect_texts(node: &LayoutNode) -> Vec<String> {
851        let mut out = Vec::new();
852        if let Some(ref t) = node.text {
853            out.push(t.clone());
854        }
855        for child in &node.children {
856            out.extend(collect_texts(child));
857        }
858        out
859    }
860
861    #[test]
862    fn test_interp_basic() {
863        let source = r#"
864let name = "World";
865screen Main { text("Hello {name}") }
866"#;
867        let (_, layout) = compile(source.trim(), None, None).unwrap();
868        let texts = collect_texts(&layout);
869        assert!(texts.iter().any(|t| t == "Hello World"), "texts: {:?}", texts);
870    }
871
872    #[test]
873    fn test_interp_expr() {
874        let source = r#"
875let count = 3;
876screen Main { text("total: {count * 2}") }
877"#;
878        let (_, layout) = compile(source.trim(), None, None).unwrap();
879        let texts = collect_texts(&layout);
880        assert!(texts.iter().any(|t| t == "total: 6"), "texts: {:?}", texts);
881    }
882
883    #[test]
884    fn test_interp_plain_string_unchanged() {
885        let source = r#"screen Main { text("no braces here") }"#;
886        let (_, layout) = compile(source, None, None).unwrap();
887        let texts = collect_texts(&layout);
888        assert!(texts.iter().any(|t| t == "no braces here"), "texts: {:?}", texts);
889    }
890
891    #[test]
892    fn test_interp_escaped_brace() {
893        let source = r#"screen Main { text("literal \{brace\}") }"#;
894        let (_, layout) = compile(source, None, None).unwrap();
895        let texts = collect_texts(&layout);
896        assert!(texts.iter().any(|t| t == "literal {brace}"), "texts: {:?}", texts);
897    }
898
899    // --- State Management Tests ---
900
901    #[test]
902    fn lex_state_keyword() {
903        let mut lexer = lexer::Lexer::new("state", None);
904        let t = lexer.next_token().unwrap();
905        assert!(matches!(t.kind, TokenKind::State));
906    }
907
908    #[test]
909    fn parse_state_declaration() {
910        let source = "state count = 0; screen Main { box {} }";
911        let program = parse(source, None).unwrap();
912        assert!(matches!(&program.items[0], ProgramItem::StateDecl(sd) if sd.name == "count"));
913    }
914
915    #[test]
916    fn eval_state_initial_value() {
917        let source = "state count = 0; screen Main { box {} }";
918        let program = parse(source, None).unwrap();
919        let ctx = EvalContext::from_program(&program);
920        let val = ctx.variables.get("count").unwrap();
921        assert!(matches!(val, Value::Number(n) if (*n - 0.0).abs() < f64::EPSILON));
922    }
923
924    #[test]
925    fn state_var_in_interpolation() {
926        let source = r#"
927state count = 5;
928screen Main { text("Count: {count}") }
929"#;
930        let (_, layout) = compile(source.trim(), None, None).unwrap();
931        let texts = collect_texts(&layout);
932        assert!(texts.iter().any(|t| t == "Count: 5"), "texts: {:?}", texts);
933    }
934
935    #[test]
936    fn onclick_handler_serialized() {
937        let source = r#"
938state count = 0;
939screen Main { button("Add", onClick: { count = count + 1 }) }
940"#;
941        let (_, layout) = compile(source.trim(), None, None).unwrap();
942        // The button is inside the screen block
943        fn find_onclick(node: &LayoutNode) -> Option<String> {
944            if node.on_click.is_some() {
945                return node.on_click.clone();
946            }
947            for child in &node.children {
948                if let Some(v) = find_onclick(child) {
949                    return Some(v);
950                }
951            }
952            None
953        }
954        let handler = find_onclick(&layout).expect("should have on_click");
955        assert!(handler.contains("count"), "handler: {}", handler);
956        assert!(handler.contains("1"), "handler: {}", handler);
957    }
958
959    #[test]
960    fn parse_assignment_expr() {
961        let source = "state count = 0; screen Main { button(\"Add\", onClick: { count = count + 1 }) }";
962        let program = parse(source, None).unwrap();
963        // If it parses without error, the assignment was handled
964        assert!(!program.items.is_empty());
965    }
966
967    // --- Reactive HTML Tests ---
968
969    #[test]
970    fn reactive_html_contains_state_script() {
971        let source = r#"
972state count = 0;
973screen Main { text("Count: {count}") }
974"#;
975        let (program, layout) = compile(source.trim(), None, None).unwrap();
976        let html = layout_to_reactive_html(&program, &layout, 960, 640, None);
977        assert!(html.contains("_state"), "should contain _state");
978        assert!(html.contains("count"), "should contain count");
979        assert!(html.contains("_render"), "should contain _render");
980    }
981
982    #[test]
983    fn reactive_html_has_data_onclick() {
984        let source = r#"
985state count = 0;
986screen Main { button("Add", onClick: { count = count + 1 }) }
987"#;
988        let (program, layout) = compile(source.trim(), None, None).unwrap();
989        let html = layout_to_reactive_html(&program, &layout, 960, 640, None);
990        assert!(html.contains("data-onclick"), "should contain data-onclick attribute");
991    }
992
993    #[test]
994    fn reactive_html_has_data_content() {
995        let source = r#"
996state count = 0;
997screen Main { text("Count: {count}") }
998"#;
999        let (program, layout) = compile(source.trim(), None, None).unwrap();
1000        let html = layout_to_reactive_html(&program, &layout, 960, 640, None);
1001        assert!(html.contains("data-content"), "should contain data-content attribute");
1002        assert!(html.contains("Count: {count}"), "should contain template pattern");
1003    }
1004
1005    #[test]
1006    fn reactive_html_static_fallback() {
1007        let source = r#"screen Main { text("Hello") }"#;
1008        let (program, layout) = compile(source, None, None).unwrap();
1009        let html = layout_to_reactive_html(&program, &layout, 960, 640, None);
1010        assert!(html.contains("<!DOCTYPE html>"));
1011        assert!(html.contains("Hello"));
1012    }
1013
1014    // --- Error Recovery Tests ---
1015
1016    #[test]
1017    fn test_multiple_errors_reported() {
1018        let src = r#"
1019            screan Main { }
1020            componnt Card() { }
1021            scren Other { }
1022        "#;
1023        let errors = check_all(src, None);
1024        assert!(errors.len() >= 2, "should report multiple errors, got {}", errors.len());
1025    }
1026
1027    #[test]
1028    fn test_single_error_backward_compat() {
1029        let src = "screan Main { }";
1030        let result = parse(src, None);
1031        assert!(result.is_err(), "should still return Err for backward compat");
1032    }
1033
1034    #[test]
1035    fn test_parse_all_returns_all_errors() {
1036        let src = r#"
1037            screan Main { }
1038            componnt Card() { }
1039        "#;
1040        let result = parse_all(src.trim(), None);
1041        assert!(result.is_err());
1042        let errors = result.unwrap_err();
1043        assert!(errors.len() >= 2, "parse_all should return multiple errors, got {}", errors.len());
1044    }
1045
1046    #[test]
1047    fn test_error_recovery_valid_items_still_parse() {
1048        // Mix of valid and invalid: valid items before/after errors should not cause issues
1049        let src = r#"
1050            let x = 42;
1051            screan Bad { }
1052            screen Main { box {} }
1053        "#;
1054        // check_all should find the "screan" error but still be able to see "screen Main"
1055        let errors = check_all(src, None);
1056        assert!(!errors.is_empty(), "should find at least one error");
1057    }
1058}