use clap::Args;
use nativ_compiler::ir::node::*;
use nativ_compiler::ir::transform::lower_ast;
use nativ_compiler::ir::types::*;
use notify::{RecursiveMode, Watcher};
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};
const DEBOUNCE: Duration = Duration::from_millis(300);
#[derive(Args)]
pub struct PreviewArgs {
#[arg(default_value = ".")]
pub path: String,
#[arg(short, long)]
pub out: Option<String>,
#[arg(long)]
pub open: bool,
#[arg(long)]
pub watch: bool,
}
pub fn run(args: PreviewArgs) -> Result<(), Box<dyn std::error::Error>> {
let input = resolve_input(Path::new(&args.path))?;
let out_path = match &args.out {
Some(p) => PathBuf::from(p),
None => default_out_path(&input),
};
let abs = render_to_path(&input, &out_path)?;
println!("Preview written to {}", abs.display());
if args.open {
open_in_browser(&abs)?;
}
if args.watch {
watch_loop(&input, &out_path, args.open)?;
}
Ok(())
}
fn render_to_path(input: &Path, out_path: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
let (html, _) = render_to_string(input)?;
std::fs::write(out_path, html)
.map_err(|e| format!("failed to write {}: {e}", out_path.display()))?;
Ok(std::fs::canonicalize(out_path).unwrap_or_else(|_| out_path.to_path_buf()))
}
fn render_to_string(input: &Path) -> Result<(String, usize), Box<dyn std::error::Error>> {
if input.is_file() {
render_single_file(input)
} else if input.is_dir() {
render_project_directory(input)
} else {
Err(format!("path not found: {}", input.display()).into())
}
}
fn render_single_file(input: &Path) -> Result<(String, usize), Box<dyn std::error::Error>> {
let source = std::fs::read_to_string(input)
.map_err(|e| format!("failed to read {}: {e}", input.display()))?;
let ast = nativ_compiler::parse(&source)
.map_err(|e| format!("failed to parse {}: {e}", input.display()))?;
let program = lower_ast(&ast).map_err(|errs| {
let mut joined = String::new();
for err in &errs {
let _ = writeln!(joined, " {err}");
}
format!("failed to lower {}:\n{joined}", input.display())
})?;
let title = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "preview".to_string());
let html = render_preview(&title, &program);
let screens = program.screens.len();
Ok((html, screens))
}
fn render_project_directory(dir: &Path) -> Result<(String, usize), Box<dyn std::error::Error>> {
let sources = discover_nativ_files(dir)?;
if sources.is_empty() {
return Err(format!("no .nativ files found in {}", dir.display()).into());
}
let mut files = Vec::new();
for source in &sources {
let content = std::fs::read_to_string(source)
.map_err(|e| format!("failed to read {}: {e}", source.display()))?;
let ast = nativ_compiler::parse(&content)
.map_err(|e| format!("failed to parse {}: {e}", source.display()))?;
files.push(ast);
}
let merged = nativ_compiler::ast::merge_nativ_files(&files);
let program = lower_ast(&merged).map_err(|errs| {
let mut joined = String::new();
for err in &errs {
let _ = writeln!(joined, " {err}");
}
format!("failed to lower project:\n{joined}")
})?;
let title = dir
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".to_string());
let html = render_preview(&title, &program);
let screens = program.screens.len();
Ok((html, screens))
}
fn discover_nativ_files(dir: &Path) -> Result<Vec<PathBuf>, Box<dyn std::error::Error>> {
match nativ_pipeline::discover_sources(dir) {
Ok(sources) if !sources.is_empty() => return Ok(sources),
_ => {}
}
let mut files = Vec::new();
collect_nativ_files(dir, &mut files)?;
files.sort();
Ok(files)
}
fn collect_nativ_files(
dir: &Path,
out: &mut Vec<PathBuf>,
) -> Result<(), Box<dyn std::error::Error>> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_nativ_files(&path, out)?;
} else if path.extension().is_some_and(|ext| ext == "nativ") {
out.push(path);
}
}
Ok(())
}
fn watch_loop(
input: &Path,
out_path: &Path,
opened: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let watch_root: PathBuf = if input.is_file() {
input
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
} else {
let src = input.join("src");
if src.is_dir() {
src
} else {
input.to_path_buf()
}
};
let display_path = input.display().to_string();
let (tx, rx) = mpsc::channel();
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
if let Ok(event) = res {
for path in event.paths {
let _ = tx.send(path);
}
}
})?;
watcher.watch(&watch_root, RecursiveMode::Recursive)?;
if opened {
println!(
"Watching {} for changes... (the browser shows the file in place; refresh the tab to see updates)",
display_path
);
} else {
println!("Watching {} for changes... (Ctrl+C to stop)", display_path);
}
while let Ok(first) = rx.recv() {
let mut batch = vec![first];
let deadline = Instant::now() + DEBOUNCE;
while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
match rx.recv_timeout(remaining) {
Ok(path) => batch.push(path),
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => return Ok(()),
}
}
if !batch.iter().any(|p| triggers_rerender(p)) {
continue;
}
print!("\r\x1b[2K");
match render_to_string(input) {
Ok((html, screens)) => match std::fs::write(out_path, &html) {
Ok(()) => {
let abs =
std::fs::canonicalize(out_path).unwrap_or_else(|_| out_path.to_path_buf());
let plural = if screens == 1 { "screen" } else { "screens" };
println!(
"Re-rendered {} ({screens} {plural}) at change",
abs.display()
);
}
Err(e) => eprintln!(
"Preview re-render failed: failed to write {}: {e}",
out_path.display()
),
},
Err(e) => eprintln!("Preview re-render failed: {e}"),
}
}
Ok(())
}
fn triggers_rerender(path: &Path) -> bool {
path.extension().is_some_and(|ext| ext == "nativ")
}
fn resolve_input(path: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
if path.is_file() || path.is_dir() {
return Ok(path.to_path_buf());
}
Err(format!("path not found: {}", path.display()).into())
}
fn default_out_path(input: &Path) -> PathBuf {
if input.is_file() {
let stem = input
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "preview".to_string());
input.with_file_name(format!("{stem}.preview.html"))
} else {
let dir_name = input
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "project".to_string());
input.join(format!("{dir_name}.preview.html"))
}
}
fn open_in_browser(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let path_str = path.to_string_lossy().into_owned();
let result = if cfg!(target_os = "windows") {
std::process::Command::new("cmd")
.args(["/C", "start", "", &path_str])
.status()
} else if cfg!(target_os = "macos") {
std::process::Command::new("open").arg(&path_str).status()
} else {
std::process::Command::new("xdg-open")
.arg(&path_str)
.status()
};
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("could not open browser: {e}").into()),
}
}
fn render_preview(title: &str, program: &IrProgram) -> String {
let mut html = String::new();
html.push_str("<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n");
html.push_str("<meta charset=\"utf-8\">\n");
html.push_str("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
let _ = writeln!(html, "<title>{} · Nativ preview</title>", esc(title));
html.push_str("<style>\n");
html.push_str(CSS);
html.push_str("</style>\n</head>\n<body>\n");
let app_name = program
.app_config
.as_ref()
.and_then(|a| a.display_name.clone().or_else(|| Some(a.name.clone())))
.unwrap_or_else(|| title.to_string());
html.push_str("<header class=\"page-head\">\n");
let _ = writeln!(html, " <h1>{}</h1>", esc(&app_name));
html.push_str(
" <p class=\"subtitle\">Static preview generated by <code>nativ preview</code></p>\n",
);
html.push_str("</header>\n");
html.push_str("<main class=\"frames\">\n");
if program.screens.is_empty() {
html.push_str("<p class=\"empty\">No screens found in this file.</p>\n");
}
for screen in &program.screens {
render_screen(&mut html, screen, program);
}
html.push_str("</main>\n</body>\n</html>\n");
html
}
fn render_screen(html: &mut String, screen: &IrScreen, program: &IrProgram) {
html.push_str("<section class=\"phone\">\n");
html.push_str(" <div class=\"notch\"></div>\n");
let _ = writeln!(
html,
" <div class=\"screen-bar\">{}</div>",
esc(&screen.name)
);
html.push_str(" <div class=\"screen-body\">\n");
for node in &screen.body {
render_node(html, node, program, 2);
}
html.push_str(" </div>\n");
html.push_str("</section>\n");
}
fn render_node(html: &mut String, node: &IrNode, program: &IrProgram, depth: usize) {
let pad = " ".repeat(depth);
match node {
IrNode::Text(t) => {
let style = text_style(t);
let style_attr = if style.is_empty() {
String::new()
} else {
format!(" style=\"{style}\"")
};
let _ = writeln!(
html,
"{pad}<p class=\"el text\"{style_attr}>{}</p>",
esc(&expr_to_display(&t.value))
);
}
IrNode::Button(b) => {
let style = b
.color
.as_ref()
.map(|c| format!(" style=\"background:{};\"", ir_css_color(c)))
.unwrap_or_default();
let _ = writeln!(
html,
"{pad}<button class=\"el btn\"{style}>{}</button>",
esc(&expr_to_display(&b.label))
);
}
IrNode::Image(img) => {
let label = expr_to_display(&img.source);
let round = matches!(img.shape, Some(Shape::Circle)) || img.border_width.is_some();
let mut style = String::new();
if let Some(size) = img.size {
let _ = write!(style, "width:{size}px;height:{size}px;");
}
if round {
style.push_str("border-radius:50%;");
} else if let Some(Shape::RoundedRect(r)) = img.shape {
let _ = write!(style, "border-radius:{r}px;");
}
let _ = writeln!(
html,
"{pad}<div class=\"el image\" style=\"{style}\">{}</div>",
esc(&label)
);
}
IrNode::Toggle(t) => {
let label = t
.label
.as_ref()
.map(expr_to_display)
.unwrap_or_else(|| expr_to_display(&t.binding));
let _ = writeln!(
html,
"{pad}<label class=\"el toggle\"><span>{}</span><span class=\"switch\"></span></label>",
esc(&label)
);
}
IrNode::TextField(tf) => {
let _ = writeln!(
html,
"{pad}<input class=\"el field\" type=\"text\" placeholder=\"{}\" disabled>",
esc(&expr_to_display(&tf.placeholder))
);
}
IrNode::Spinner(_) => {
let _ = writeln!(html, "{pad}<div class=\"el spinner\"></div>");
}
IrNode::Divider(_) => {
let _ = writeln!(html, "{pad}<hr class=\"el divider\">");
}
IrNode::Spacer(_) => {
let _ = writeln!(html, "{pad}<div class=\"el spacer\"></div>");
}
IrNode::Column(c) => {
let gap = c.spacing.unwrap_or(8);
let pad_px = c.padding.unwrap_or(0);
let _ = writeln!(
html,
"{pad}<div class=\"el column\" style=\"gap:{gap}px;padding:{pad_px}px;\">"
);
for child in &c.children {
render_node(html, child, program, depth + 1);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Row(r) => {
let gap = r.spacing.unwrap_or(8);
let _ = writeln!(html, "{pad}<div class=\"el row\" style=\"gap:{gap}px;\">");
for child in &r.children {
render_node(html, child, program, depth + 1);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Scroll(s) => {
let _ = writeln!(html, "{pad}<div class=\"el scroll\">");
for child in &s.children {
render_node(html, child, program, depth + 1);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Card(c) => {
let pad_px = c.padding.unwrap_or(16);
let radius = c.corner_radius.unwrap_or(12);
let _ = writeln!(
html,
"{pad}<div class=\"el card\" style=\"padding:{pad_px}px;border-radius:{radius}px;\">"
);
for child in &c.children {
render_node(html, child, program, depth + 1);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Section(s) => {
let _ = writeln!(html, "{pad}<div class=\"el section\">");
let _ = writeln!(
html,
"{pad} <div class=\"section-title\">{}</div>",
esc(&s.title)
);
for child in &s.children {
render_node(html, child, program, depth + 1);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::Conditional(c) => {
for child in &c.then_body {
render_node(html, child, program, depth);
}
}
IrNode::Each(e) => {
for _ in 0..3 {
for child in &e.body {
render_node(html, child, program, depth);
}
}
}
IrNode::ComponentCall(call) => {
match program.components.iter().find(|c| c.name == call.name) {
Some(component) => {
for child in &component.body {
render_node(html, child, program, depth);
}
}
None => {
let _ = writeln!(
html,
"{pad}<div class=\"el placeholder\">{}</div>",
esc(&call.name)
);
}
}
}
IrNode::Form(f) => {
let _ = writeln!(html, "{pad}<div class=\"el form\">");
for input in &f.inputs {
let _ = writeln!(
html,
"{pad} <input class=\"el field\" type=\"text\" placeholder=\"{}\" disabled>",
esc(&input.label)
);
}
if let Some(submit) = &f.submit {
let _ = writeln!(
html,
"{pad} <button class=\"el btn\">{}</button>",
esc(&expr_to_display(&submit.label))
);
}
let _ = writeln!(html, "{pad}</div>");
}
IrNode::TapHandler { target, .. } => {
render_node(html, target, program, depth);
}
IrNode::Load(_) | IrNode::Animate(_) | IrNode::Raw(_) => {
}
}
}
fn text_style(t: &IrText) -> String {
let mut style = String::new();
if let Some(size) = t.font_size {
let _ = write!(style, "font-size:{size}px;");
}
if matches!(t.font_weight, Some(FontWeight::Bold)) {
style.push_str("font-weight:700;");
}
if let Some(color) = &t.color {
let _ = write!(style, "color:{};", ir_css_color(color));
}
if let Some(align) = t.align {
let a = match align {
TextAlign::Left => "left",
TextAlign::Center => "center",
TextAlign::Right => "right",
};
let _ = write!(style, "text-align:{a};");
}
if let Some(lines) = t.max_lines {
let _ = write!(
style,
"display:-webkit-box;-webkit-line-clamp:{lines};-webkit-box-orient:vertical;overflow:hidden;"
);
}
style
}
fn ir_css_color(color: &IrColor) -> String {
match color {
IrColor::Solid(c) => css_color(c),
IrColor::Conditional { then_color, .. } => css_color(then_color),
}
}
fn css_color(color: &Color) -> String {
match color {
Color::Hex(hex) => format!("#{hex}"),
Color::Named(name) => match name.as_str() {
"gray" | "grey" => "#8e8e93".to_string(),
"primary" => "#0a84ff".to_string(),
"secondary" => "#5e5ce6".to_string(),
other => other.to_string(),
},
}
}
fn expr_to_display(expr: &IrExpr) -> String {
match expr {
IrExpr::Literal(lit) => match &lit.value {
LiteralValue::Number(n) => {
if *n == (*n as i64) as f64 {
format!("{}", *n as i64)
} else {
format!("{n}")
}
}
LiteralValue::Text(s) => s.clone(),
LiteralValue::Boolean(b) => b.to_string(),
LiteralValue::Null => String::new(),
},
IrExpr::Variable { name, .. } => format!("{{{name}}}"),
IrExpr::PropertyAccess {
object, property, ..
} => format!("{{{object}.{property}}}"),
IrExpr::PropertyChain { parts, .. } => format!("{{{}}}", parts.join(".")),
IrExpr::StringInterpolation { parts, .. } => {
let mut out = String::new();
for part in parts {
match part {
StringPart::Literal(s) => out.push_str(s),
StringPart::Expr(e) => out.push_str(&expr_to_display(e)),
}
}
out
}
IrExpr::BinaryOp {
op, left, right, ..
} => format!("{} {op} {}", expr_to_display(left), expr_to_display(right)),
IrExpr::UnaryOp { op, operand, .. } => {
let e = expr_to_display(operand);
match op {
UnaryOp::Negate => format!("-{e}"),
UnaryOp::Not => format!("!{e}"),
}
}
IrExpr::FunctionCall { method, .. } => format!("{{{method}(…)}}"),
IrExpr::ConstructorCall { type_name, .. } => format!("{{{type_name}}}"),
IrExpr::ListLiteral { .. } => "[…]".to_string(),
IrExpr::Translation { key, .. } => format!("{{{key}}}"),
IrExpr::Conditional {
condition,
then_expr,
else_expr,
..
} => format!(
"{} ? {} : {}",
expr_to_display(condition),
expr_to_display(then_expr),
expr_to_display(else_expr)
),
IrExpr::IndexAccess { target, index, .. } => {
format!("{}[{}]", expr_to_display(target), expr_to_display(index))
}
}
}
fn esc(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
const CSS: &str = r#"
:root {
--bg: #0d0f14;
--frame: #16181f;
--frame-edge: #2a2d37;
--bar: #1d2029;
--text: #f2f3f5;
--muted: #8e8e93;
--accent: #0a84ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.page-head {
text-align: center;
padding: 32px 16px 8px;
}
.page-head h1 { margin: 0; font-size: 26px; font-weight: 700; }
.subtitle { color: var(--muted); font-size: 13px; margin: 6px 0 0; }
.subtitle code { color: var(--accent); }
.frames {
display: flex;
flex-wrap: wrap;
gap: 32px;
justify-content: center;
align-items: flex-start;
padding: 32px 16px 64px;
}
.empty { color: var(--muted); text-align: center; width: 100%; }
.phone {
position: relative;
width: 390px;
background: var(--frame);
border: 1px solid var(--frame-edge);
border-radius: 40px;
box-shadow: 0 24px 60px rgba(0,0,0,0.55);
overflow: hidden;
padding-top: 12px;
}
.notch {
width: 140px;
height: 26px;
background: #000;
border-radius: 0 0 16px 16px;
margin: 0 auto 4px;
}
.screen-bar {
background: var(--bar);
text-align: center;
font-weight: 600;
font-size: 15px;
padding: 12px;
border-bottom: 1px solid var(--frame-edge);
}
.screen-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 18px;
min-height: 520px;
}
.el { color: var(--text); }
.text { margin: 0; font-size: 16px; line-height: 1.35; }
.btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 12px;
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
cursor: default;
text-align: center;
}
.image {
display: flex;
align-items: center;
justify-content: center;
min-width: 64px;
min-height: 64px;
background: #23262f;
border: 1px dashed var(--frame-edge);
border-radius: 12px;
color: var(--muted);
font-size: 12px;
padding: 8px;
}
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 16px;
}
.switch {
display: inline-block;
width: 44px;
height: 26px;
background: var(--accent);
border-radius: 13px;
position: relative;
flex: 0 0 auto;
}
.switch::after {
content: "";
position: absolute;
top: 3px;
right: 3px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
}
.field {
background: #23262f;
border: 1px solid var(--frame-edge);
border-radius: 10px;
padding: 12px;
font-size: 16px;
color: var(--text);
width: 100%;
}
.field::placeholder { color: var(--muted); }
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--frame-edge);
border-top-color: var(--accent);
border-radius: 50%;
align-self: center;
}
.divider { border: none; border-top: 1px solid var(--frame-edge); width: 100%; margin: 4px 0; }
.spacer { flex: 1 1 auto; min-height: 16px; }
.column { display: flex; flex-direction: column; }
.row { display: flex; flex-direction: row; align-items: center; }
.scroll { display: flex; flex-direction: column; gap: 10px; max-height: 360px; overflow-y: auto; }
.card {
background: #1b1e27;
border: 1px solid var(--frame-edge);
box-shadow: 0 4px 16px rgba(0,0,0,0.35);
display: flex;
flex-direction: column;
gap: 8px;
}
.section {
display: flex;
flex-direction: column;
gap: 6px;
background: #1b1e27;
border: 1px solid var(--frame-edge);
border-radius: 12px;
padding: 12px;
}
.section-title {
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 12px;
color: var(--muted);
}
.form { display: flex; flex-direction: column; gap: 10px; }
.placeholder {
border: 1px dashed var(--accent);
border-radius: 10px;
padding: 12px;
color: var(--muted);
font-size: 13px;
text-align: center;
}
"#;
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../fixtures/valid")
.join(name)
}
fn render_fixture(name: &str) -> String {
let source = std::fs::read_to_string(fixture(name)).expect("fixture readable");
let ast = nativ_compiler::parse(&source).expect("fixture parses");
let program = lower_ast(&ast).expect("fixture lowers");
render_preview(name, &program)
}
#[test]
fn renders_self_contained_document_with_one_frame_per_screen() {
let html = render_fixture("ui_simple_elements.nativ");
assert!(html.starts_with("<!DOCTYPE html>"), "doc preamble missing");
assert!(html.contains("<style>"), "inline CSS missing");
assert_eq!(html.matches("class=\"phone\"").count(), 1);
assert!(html.contains("SimpleElements"), "screen name missing");
assert!(html.contains("class=\"el spinner\""));
assert!(html.contains("class=\"el divider\""));
assert!(html.contains("class=\"el spacer\""));
}
#[test]
fn renders_card_with_text_content_and_modifiers() {
let html = render_fixture("layout_card.nativ");
assert!(html.contains("class=\"el card\""), "card missing");
assert!(html.contains("Styled card"), "card text missing");
assert!(html.contains("font-weight:700"), "bold modifier missing");
}
#[test]
fn renders_each_loop_as_repeated_placeholders() {
let html = render_fixture("control_for_each.nativ");
assert_eq!(html.matches("class=\"el row\"").count(), 3);
assert_eq!(html.matches("class=\"el toggle\"").count(), 3);
assert!(html.contains("{todo.title}"), "loop item text missing");
}
#[test]
fn renders_button_with_label() {
let html = render_fixture("ui_button_basic.nativ");
assert!(html.contains("<button class=\"el btn\""), "button missing");
assert!(html.contains("Increment"), "button label missing");
}
#[test]
fn escapes_html_special_characters() {
assert_eq!(esc("a<b>&\"'"), "a<b>&"'");
}
#[test]
fn resolves_a_file_path_directly() {
let path = fixture("ui_simple_elements.nativ");
let resolved = resolve_input(&path).expect("file resolves");
assert_eq!(resolved, path);
}
#[test]
fn resolves_a_directory_path() {
let tmp = tempfile::tempdir().unwrap();
let resolved = resolve_input(tmp.path()).expect("directory resolves");
assert_eq!(resolved, tmp.path());
}
#[test]
fn missing_path_is_an_error() {
let err = resolve_input(Path::new("does-not-exist.nativ")).unwrap_err();
assert!(err.to_string().contains("not found"), "{err}");
}
#[test]
fn missing_directory_is_an_error() {
let err = resolve_input(Path::new("nonexistent-dir")).unwrap_err();
assert!(err.to_string().contains("not found"), "{err}");
}
#[test]
fn render_to_string_returns_html_and_screen_count() {
let (html, screens) = render_to_string(&fixture("ui_simple_elements.nativ")).unwrap();
assert!(html.starts_with("<!DOCTYPE html>"));
assert_eq!(screens, 1, "expected one screen");
assert_eq!(html.matches("class=\"phone\"").count(), screens);
}
#[test]
fn render_to_path_writes_a_self_contained_file() {
let tmp = tempfile::tempdir().unwrap();
let out = tmp.path().join("out.preview.html");
let abs = render_to_path(&fixture("ui_simple_elements.nativ"), &out).unwrap();
let written = std::fs::read_to_string(&out).expect("preview written");
assert!(
written.starts_with("<!DOCTYPE html>"),
"preview not written"
);
assert!(written.contains("<style>"), "inline CSS missing");
assert!(
abs.ends_with("out.preview.html"),
"unexpected path: {abs:?}"
);
}
#[test]
fn render_to_path_reports_source_location_on_parse_error() {
let tmp = tempfile::tempdir().unwrap();
let bad = tmp.path().join("bad.nativ");
std::fs::write(&bad, "screen Bad:\n\ttext \"tabs\"\n").unwrap();
let out = tmp.path().join("out.html");
let err = render_to_path(&bad, &out).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("bad.nativ"),
"error should name the file: {msg}"
);
assert!(
!out.exists(),
"no output should be written on a parse error"
);
}
#[test]
fn default_out_path_for_file_uses_stem() {
let tmp = tempfile::tempdir().unwrap();
let file = tmp.path().join("app.nativ");
std::fs::write(&file, "screen A:\n text \"a\"\n").unwrap();
let out = default_out_path(&file);
assert_eq!(out, tmp.path().join("app.preview.html"));
}
#[test]
fn default_out_path_for_directory_uses_dir_name() {
let out = default_out_path(Path::new("my-project"));
assert_eq!(out, PathBuf::from("my-project/my-project.preview.html"));
}
fn scaffold_project(tmp: &Path) {
let src = tmp.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("app.nativ"),
"app MyApp:\n name: \"MyApp\"\n display: \"My App\"\n",
)
.unwrap();
std::fs::write(
src.join("home.nativ"),
"screen Home:\n text \"Hello from Home\"\n",
)
.unwrap();
std::fs::write(
src.join("profile.nativ"),
"screen Profile:\n text \"Hello from Profile\"\n",
)
.unwrap();
}
#[test]
fn discover_nativ_files_finds_all_nativ_files_in_src() {
let tmp = tempfile::tempdir().unwrap();
scaffold_project(tmp.path());
let files = discover_nativ_files(tmp.path()).expect("discover succeeds");
assert_eq!(files.len(), 3, "expected 3 .nativ files");
assert!(files[0].ends_with("app.nativ"));
assert!(files[1].ends_with("home.nativ"));
assert!(files[2].ends_with("profile.nativ"));
}
#[test]
fn discover_nativ_files_returns_empty_for_empty_directory() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("src")).unwrap();
let files = discover_nativ_files(tmp.path()).expect("discover succeeds");
assert!(files.is_empty(), "expected no files");
}
#[test]
fn render_to_string_with_directory_renders_merged_screens() {
let tmp = tempfile::tempdir().unwrap();
scaffold_project(tmp.path());
let (html, screens) = render_to_string(tmp.path()).expect("directory renders successfully");
assert!(html.starts_with("<!DOCTYPE html>"), "doc preamble missing");
assert_eq!(screens, 2, "expected 2 screens from merged project");
assert_eq!(html.matches("class=\"phone\"").count(), 2);
assert!(html.contains("Home"), "Home screen name missing");
assert!(html.contains("Profile"), "Profile screen name missing");
assert!(
html.contains("Hello from Home"),
"Home text content missing"
);
assert!(
html.contains("Hello from Profile"),
"Profile text content missing"
);
let dir_name = tmp.path().file_name().unwrap().to_string_lossy();
assert!(
html.contains(&*dir_name),
"directory name should appear in title"
);
}
#[test]
fn render_to_string_with_directory_errors_on_no_nativ_files() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir(tmp.path().join("src")).unwrap();
let err = render_to_string(tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("no .nativ files found"),
"error should mention missing files: {msg}"
);
}
#[test]
fn render_to_string_with_directory_errors_on_parse_failure() {
let tmp = tempfile::tempdir().unwrap();
let src = tmp.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("bad.nativ"), "screen Bad:\n\ttext \"tabs\"\n").unwrap();
let err = render_to_string(tmp.path()).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("bad.nativ"),
"error should name the bad file: {msg}"
);
}
#[test]
fn render_to_path_with_directory_writes_preview_file() {
let tmp = tempfile::tempdir().unwrap();
scaffold_project(tmp.path());
let out = tmp.path().join("project.preview.html");
let abs = render_to_path(tmp.path(), &out).expect("directory renders");
assert!(
abs.ends_with("project.preview.html"),
"unexpected path: {abs:?}"
);
let written = std::fs::read_to_string(&out).expect("preview written");
assert!(written.starts_with("<!DOCTYPE html>"));
assert!(written.contains("Hello from Home"));
}
#[test]
fn collect_nativ_files_walks_nested_directories() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("src/screens")).unwrap();
std::fs::write(
tmp.path().join("src/app.nativ"),
"screen A:\n text \"a\"\n",
)
.unwrap();
std::fs::write(
tmp.path().join("src/screens/home.nativ"),
"screen B:\n text \"b\"\n",
)
.unwrap();
let mut files = Vec::new();
collect_nativ_files(tmp.path(), &mut files).unwrap();
assert_eq!(files.len(), 2, "expected 2 .nativ files nested");
}
#[test]
fn nativ_sources_trigger_rerender() {
for path in ["app.nativ", "src/app.nativ", "src/screens/Home.nativ"] {
assert!(triggers_rerender(Path::new(path)), "{path} should trigger");
}
}
#[test]
fn output_and_temp_files_do_not_trigger_rerender() {
for path in [
"app.preview.html", "src/app.nativ~", "src/.app.nativ.swp", "src/app.nativ.tmp", "README.md",
"nativ.toml", ] {
assert!(
!triggers_rerender(Path::new(path)),
"{path} should not trigger"
);
}
}
#[test]
fn debounced_batch_rerenders_only_when_a_nativ_path_is_present() {
let save_burst = [
PathBuf::from("src/.app.nativ.tmp123"),
PathBuf::from("src/app.nativ"),
];
assert!(save_burst.iter().any(|p| triggers_rerender(p)));
let output_noise = [PathBuf::from("app.preview.html")];
assert!(!output_noise.iter().any(|p| triggers_rerender(p)));
}
fn parse_preview_args(argv: &[&str]) -> PreviewArgs {
use clap::Parser;
#[derive(clap::Parser)]
struct Probe {
#[command(flatten)]
args: PreviewArgs,
}
let mut full = vec!["preview"];
full.extend_from_slice(argv);
Probe::parse_from(full).args
}
#[test]
fn watch_flag_defaults_off_and_path_defaults_to_cwd() {
let args = parse_preview_args(&[]);
assert!(!args.watch);
assert!(!args.open);
assert_eq!(args.path, ".");
assert!(args.out.is_none());
}
#[test]
fn parses_watch_open_and_out_flags_together() {
let args = parse_preview_args(&["app.nativ", "--watch", "--open", "--out", "p.html"]);
assert_eq!(args.path, "app.nativ");
assert!(args.watch);
assert!(args.open);
assert_eq!(args.out.as_deref(), Some("p.html"));
}
}