1pub 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
31pub 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}
47pub 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
54pub 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
70pub 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
80pub 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
89pub 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 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
122pub 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#[derive(Debug, Clone)]
130pub enum SymbolKind {
131 Variable,
132 Component { params: Vec<String> },
133 Screen,
134 Theme,
135}
136
137pub 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
170pub fn completion_keywords() -> Vec<&'static str> {
172 vec![
173 "screen", "let", "state", "component", "theme", "use", "import", "if", "for", "else", "in",
174 ]
175}
176
177pub 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
190pub 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
200pub 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
212pub 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
225pub const DEFAULT_VIEWPORT_W: f32 = 960.0;
227pub const DEFAULT_VIEWPORT_H: f32 = 640.0;
228
229pub const DEFAULT_SERVE_PORT: u16 = 3333;
231
232pub 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
267pub 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 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 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 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
333pub 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 #[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 #[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 let col = &layout.children[0];
573 assert_eq!(col.children.len(), 1);
575 assert_eq!(col.children[0].children.len(), 2);
577 }
578
579 #[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 #[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 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 #[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("<script>"));
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 #[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 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 #[test]
830 fn error_has_span_info() {
831 let _result = parse("screen Main { invalid_stuff }", None);
832 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 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 #[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 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 assert!(!program.items.is_empty());
965 }
966
967 #[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 #[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 let src = r#"
1050 let x = 42;
1051 screan Bad { }
1052 screen Main { box {} }
1053 "#;
1054 let errors = check_all(src, None);
1056 assert!(!errors.is_empty(), "should find at least one error");
1057 }
1058}