on master help sign in
Add 10 production improvements: CodegenOptions refactoring, modular CLI, column-precise diagnostics, HashSet watch optimization, import cycle chains, VLQ source maps, AVIF/SVG dimensions, variable/function suggestions, CSS value validation
commitdd95bf6f1874f548b7d09bbfe158016ebe5d1104 authoriko <mail@iko.soy> dateThu 16 Apr 2026, 01:39 (6 months ago) parent9ec4406
5 files changed+799−391
src/bin/htmlang_lsp/main.rs+11−1 src/cli.rs+308 src/codegen.rs+173−43 src/main.rs+30−343 src/parser.rs+277−4
src/bin/htmlang_lsp/main.rs+11−1
@@ -42,8 +42,18 @@ impl Backend {
4242 htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
4343 };
4444 let line = d.line.saturating_sub(1) as u32;
45+ let col_start = d.column.unwrap_or(0) as u32;
46+ let col_end = if d.column.is_some() {
47+ // Highlight a reasonable span from the column
48+ let lines_vec: Vec<&str> = text.lines().collect();
49+ lines_vec.get(line as usize)
50+ .map(|l| l.len() as u32)
51+ .unwrap_or(col_start + 1)
52+ } else {
53+ 1000 // Highlight entire line when no column info
54+ };
4555 Diagnostic {
46− range: Range::new(Position::new(line, 0), Position::new(line, 1000)),
56+ range: Range::new(Position::new(line, col_start), Position::new(line, col_end)),
4757 severity: Some(severity),
4858 source: Some("htmlang".into()),
4959 message: d.message.clone(),
src/cli.rsnew file+308
@@ -0,0 +1,308 @@
1+use std::env;
2+use std::process;
3+
4+pub fn print_help() {
5+ eprintln!(
6+ "\
7+htmlang {} - a minimalist layout language that compiles to static HTML
8+
9+Usage: htmlang [options] <file.hl | directory>
10+
11+Commands:
12+ init [dir] [--template blog|docs|portfolio]
13+ Create a new project (defaults to current directory)
14+ new <page-name> Create a new .hl page from a template
15+ test [dir|file] Run @assert directives across a project
16+ build <dir> [-o <out>] [--minify] Compile all .hl files (parallel)
17+ serve [dir|file] [-p N] [--open] Start dev server with hot reload
18+ watch [dir|file] [-o out] Watch for changes and recompile
19+ check <file.hl | dir> Check for errors without writing output
20+ convert <file.html> Convert an HTML file to .hl format (stdout)
21+ fmt <file.hl> Format a .hl file (normalizes indentation)
22+ sitemap <dir> Generate sitemap.xml from .hl files
23+ lint <file.hl | dir> Stricter lint checks (accessibility, nesting, etc.)
24+ stats <file.hl | dir> Show file statistics (elements, CSS rules, colors)
25+ preview <file.hl> Compile and open in browser (one-shot, no server)
26+ diff <a.hl> <b.hl> Show differences between two .hl files
27+ export <dir> [-o f] Compile and bundle into an archive
28+ repl Interactive REPL (type .hl, get HTML)
29+ feed <dir> [-b URL] Generate RSS feed from @page metadata
30+ components <dir> List all @fn definitions across a project
31+ deps <dir> Show file dependency graph (@include/@import)
32+ dead-code <dir> Find unused @fn, @define, @let across project
33+ deploy <dir> [--provider github-pages|netlify|vercel|cloudflare]
34+ Build and deploy
35+ playground [out.html] Generate a self-contained HTML playground
36+ clean [dir] Remove generated .html files
37+ upgrade [dir|file] Auto-upgrade syntax to latest conventions
38+ create-component <name> [params...] Scaffold a new component file
39+ outline <file.hl> Show document structure tree
40+ doctor Check toolchain health
41+ migrate [dir|file] Auto-upgrade deprecated syntax
42+ bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
43+ size <file|dir> Report output sizes (raw, minified, ~gzip)
44+ benchmark <file|dir> Measure compile time and output size
45+ explain <file.hl> Show what CSS each source line produces
46+ lsp Start the Language Server Protocol server
47+ completions <shell> Generate shell completions (bash, zsh, fish)
48+
49+Options:
50+ -w, --watch Watch for changes and recompile
51+ -s, --serve Start dev server with hot reload (implies --watch)
52+ -p, --port <N> Port for dev server (default: 3000)
53+ --open Open browser automatically (with --serve)
54+ -o, --output <path> Output file/directory path
55+ -d, --dev Development mode
56+ -c, --check Check for errors without writing output
57+ --compat Add vendor prefixes for broader browser support
58+ --strict Treat warnings as errors (useful for CI)
59+ --partial Output HTML fragment without document wrapper
60+ --format json Output diagnostics as JSON to stdout
61+ -h, --help Show this help
62+ -V, --version Show version
63+
64+Examples:
65+ htmlang init Scaffold a new project
66+ htmlang init my-site Scaffold in a new directory
67+ htmlang new about-us Create about-us.hl from template
68+ htmlang page.hl Compile page.hl to page.html
69+ htmlang site/ Compile all .hl files in directory
70+ htmlang -w page.hl Recompile on file changes
71+ htmlang -s page.hl Start dev server with hot reload
72+ htmlang -s --open site/ Serve and open browser
73+ htmlang -s site/ Serve a multi-page site
74+ htmlang -s -p 8080 page.hl
75+ htmlang -c page.hl Lint without writing output
76+ htmlang check src/ Check all files in a directory
77+ htmlang convert page.html Convert HTML to .hl format
78+ htmlang --format json page.hl Get diagnostics as JSON
79+ htmlang fmt page.hl Format a file
80+ htmlang build src/ -o dist/ Compile all .hl files to dist/
81+ htmlang sitemap src/ Generate sitemap.xml
82+ htmlang lint src/ Lint all files in directory
83+ htmlang stats page.hl Show file statistics",
84+ env!("CARGO_PKG_VERSION")
85+ );
86+}
87+
88+pub fn run_lsp() {
89+ let self_exe = env::current_exe().ok();
90+ let lsp_name = if cfg!(windows) { "htmlang-lsp.exe" } else { "htmlang-lsp" };
91+
92+ let lsp_path = self_exe
93+ .as_ref()
94+ .and_then(|exe| exe.parent())
95+ .map(|dir| dir.join(lsp_name))
96+ .filter(|p| p.exists());
97+
98+ let lsp_cmd = lsp_path
99+ .map(|p| p.to_string_lossy().to_string())
100+ .unwrap_or_else(|| lsp_name.to_string());
101+
102+ let status = std::process::Command::new(&lsp_cmd)
103+ .stdin(std::process::Stdio::inherit())
104+ .stdout(std::process::Stdio::inherit())
105+ .stderr(std::process::Stdio::inherit())
106+ .status();
107+
108+ match status {
109+ Ok(s) => {
110+ if !s.success() {
111+ process::exit(s.code().unwrap_or(1));
112+ }
113+ }
114+ Err(_) => {
115+ eprintln!("error: could not find htmlang-lsp binary");
116+ eprintln!("hint: ensure htmlang-lsp is in the same directory as htmlang, or in your PATH");
117+ process::exit(1);
118+ }
119+ }
120+}
121+
122+pub fn print_shell_completions(shell: &str) {
123+ match shell {
124+ "bash" => print_bash_completions(),
125+ "zsh" => print_zsh_completions(),
126+ "fish" => print_fish_completions(),
127+ _ => {
128+ eprintln!("unknown shell: {}. Supported: bash, zsh, fish", shell);
129+ process::exit(1);
130+ }
131+ }
132+}
133+
134+fn print_bash_completions() {
135+ println!(r#"_htmlang() {{
136+ local cur prev commands
137+ cur="${{COMP_WORDS[COMP_CWORD]}}"
138+ prev="${{COMP_WORDS[COMP_CWORD-1]}}"
139+ commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor migrate bundle size benchmark explain test lsp completions"
140+
141+ if [ "$COMP_CWORD" -eq 1 ]; then
142+ COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
143+ COMPREPLY+=( $(compgen -f -X '!*.hl' -- "$cur") )
144+ COMPREPLY+=( $(compgen -d -- "$cur") )
145+ return
146+ fi
147+
148+ case "$prev" in
149+ -o|--output) COMPREPLY=( $(compgen -f -- "$cur") ) ;;
150+ -p|--port) COMPREPLY=() ;;
151+ --format) COMPREPLY=( $(compgen -W "json" -- "$cur") ) ;;
152+ --template|-t) COMPREPLY=( $(compgen -W "blog docs portfolio" -- "$cur") ) ;;
153+ --provider) COMPREPLY=( $(compgen -W "github-pages netlify vercel cloudflare" -- "$cur") ) ;;
154+ completions) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
155+ *)
156+ COMPREPLY=( $(compgen -W "-w --watch -s --serve -d --dev -c --check --compat --strict --partial --open --format --minify -o --output -p --port -h --help -V --version" -- "$cur") )
157+ COMPREPLY+=( $(compgen -f -X '!*.hl' -- "$cur") )
158+ COMPREPLY+=( $(compgen -d -- "$cur") )
159+ ;;
160+ esac
161+}}
162+complete -F _htmlang htmlang"#);
163+}
164+
165+fn print_zsh_completions() {
166+ println!(r#"#compdef htmlang
167+
168+_htmlang() {{
169+ local -a commands=(
170+ 'init:Create a new project'
171+ 'new:Create a new .hl page'
172+ 'build:Compile all .hl files'
173+ 'serve:Start dev server'
174+ 'watch:Watch and recompile'
175+ 'check:Check for errors'
176+ 'convert:Convert HTML to .hl'
177+ 'fmt:Format a .hl file'
178+ 'sitemap:Generate sitemap.xml'
179+ 'lint:Lint checks'
180+ 'stats:Show file statistics'
181+ 'preview:Compile and open in browser'
182+ 'diff:Compare two .hl files'
183+ 'export:Bundle into archive'
184+ 'repl:Interactive REPL'
185+ 'feed:Generate RSS feed'
186+ 'components:List @fn definitions'
187+ 'deps:Show dependency graph'
188+ 'dead-code:Find unused definitions'
189+ 'deploy:Build and deploy'
190+ 'playground:Generate playground'
191+ 'clean:Remove generated files'
192+ 'upgrade:Auto-upgrade syntax'
193+ 'create-component:Scaffold component'
194+ 'outline:Show document structure'
195+ 'doctor:Check toolchain health'
196+ 'migrate:Upgrade deprecated syntax'
197+ 'bundle:Inline all assets'
198+ 'size:Report output sizes'
199+ 'benchmark:Measure compile time'
200+ 'explain:Show CSS mapping per source line'
201+ 'test:Run assertions'
202+ 'lsp:Start LSP server'
203+ 'completions:Generate shell completions'
204+ )
205+
206+ local -a flags=(
207+ '-w[Watch for changes]'
208+ '--watch[Watch for changes]'
209+ '-s[Start dev server]'
210+ '--serve[Start dev server]'
211+ '-d[Development mode]'
212+ '--dev[Development mode]'
213+ '-c[Check only]'
214+ '--check[Check only]'
215+ '--compat[Vendor prefixes]'
216+ '--strict[Warnings as errors]'
217+ '--partial[Output HTML fragment]'
218+ '--open[Open browser]'
219+ '--minify[Minify output]'
220+ '-o[Output path]:path:_files'
221+ '--output[Output path]:path:_files'
222+ '-p[Port]:port:'
223+ '--port[Port]:port:'
224+ '--format[Output format]:format:(json)'
225+ '-h[Show help]'
226+ '--help[Show help]'
227+ '-V[Show version]'
228+ '--version[Show version]'
229+ )
230+
231+ if (( CURRENT == 2 )); then
232+ _alternative 'commands:command:compadd -a commands' 'files:file:_files -g "*.hl"' 'dirs:directory:_directories'
233+ else
234+ _alternative 'flags:flag:compadd -a flags' 'files:file:_files -g "*.hl"' 'dirs:directory:_directories'
235+ fi
236+}}
237+
238+_htmlang "$@""#);
239+}
240+
241+fn print_fish_completions() {
242+ let commands = [
243+ ("init", "Create a new project"),
244+ ("new", "Create a new .hl page"),
245+ ("build", "Compile all .hl files"),
246+ ("serve", "Start dev server with hot reload"),
247+ ("watch", "Watch for changes and recompile"),
248+ ("check", "Check for errors"),
249+ ("convert", "Convert HTML to .hl"),
250+ ("fmt", "Format a .hl file"),
251+ ("sitemap", "Generate sitemap.xml"),
252+ ("lint", "Strict lint checks"),
253+ ("stats", "Show file statistics"),
254+ ("preview", "Compile and open in browser"),
255+ ("diff", "Compare two .hl files"),
256+ ("export", "Bundle into archive"),
257+ ("repl", "Interactive REPL"),
258+ ("feed", "Generate RSS feed"),
259+ ("components", "List @fn definitions"),
260+ ("deps", "Show dependency graph"),
261+ ("dead-code", "Find unused definitions"),
262+ ("deploy", "Build and deploy"),
263+ ("playground", "Generate playground"),
264+ ("clean", "Remove generated files"),
265+ ("upgrade", "Auto-upgrade syntax"),
266+ ("create-component", "Scaffold component"),
267+ ("outline", "Show document structure"),
268+ ("doctor", "Check toolchain health"),
269+ ("migrate", "Upgrade deprecated syntax"),
270+ ("bundle", "Inline all assets as data URIs"),
271+ ("size", "Report output sizes"),
272+ ("benchmark", "Measure compile time"),
273+ ("explain", "Show CSS mapping per source line"),
274+ ("test", "Run assertions"),
275+ ("lsp", "Start LSP server"),
276+ ("completions", "Generate shell completions"),
277+ ];
278+
279+ for (cmd, desc) in &commands {
280+ println!("complete -c htmlang -n '__fish_use_subcommand' -a '{}' -d '{}'", cmd, desc);
281+ }
282+
283+ let flags = [
284+ ("-w", "--watch", "Watch for changes"),
285+ ("-s", "--serve", "Start dev server"),
286+ ("-d", "--dev", "Development mode"),
287+ ("-c", "--check", "Check only"),
288+ ("", "--compat", "Add vendor prefixes"),
289+ ("", "--strict", "Treat warnings as errors"),
290+ ("", "--partial", "Output HTML fragment without document wrapper"),
291+ ("", "--open", "Open browser"),
292+ ("", "--minify", "Minify output"),
293+ ("-o", "--output", "Output path"),
294+ ("-p", "--port", "Dev server port"),
295+ ("-h", "--help", "Show help"),
296+ ("-V", "--version", "Show version"),
297+ ];
298+
299+ for (short, long, desc) in &flags {
300+ if short.is_empty() {
301+ println!("complete -c htmlang -l '{}' -d '{}'", long.trim_start_matches('-'), desc);
302+ } else {
303+ println!("complete -c htmlang -s '{}' -l '{}' -d '{}'", short.trim_start_matches('-'), long.trim_start_matches('-'), desc);
304+ }
305+ }
306+
307+ println!("complete -c htmlang -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish'");
308+}
src/codegen.rs+173−43
@@ -372,48 +372,60 @@ impl GenContext {
372372// Public API
373373// ---------------------------------------------------------------------------
374374
375+#[derive(Default)]
376+pub struct CodegenOptions {
377+ pub dev: bool,
378+ pub partial: bool,
379+ pub minify: bool,
380+ pub compat: bool,
381+}
382+
383+/// Generate HTML from a parsed document using the given options.
384+pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
385+ let mut html = if opts.partial {
386+ generate_partial_inner(doc, opts.dev)
387+ } else {
388+ generate_full_inner(doc, opts.dev)
389+ };
390+ if opts.minify {
391+ html = minify_html(&html);
392+ }
393+ if opts.compat {
394+ html = add_vendor_prefixes(&html);
395+ }
396+ html
397+}
398+
375399pub fn generate(doc: &Document) -> String {
376− generate_with_options(doc, false)
400+ generate_with(doc, &CodegenOptions::default())
377401}
378402
379403pub fn generate_dev(doc: &Document) -> String {
380− generate_with_options(doc, true)
404+ generate_with(doc, &CodegenOptions { dev: true, ..Default::default() })
381405}
382406
383−/// Generate an HTML fragment — body content + inline style, no document wrapper.
384407pub fn generate_partial(doc: &Document) -> String {
385− generate_partial_with_options(doc, false)
408+ generate_with(doc, &CodegenOptions { partial: true, ..Default::default() })
386409}
387410
388−/// Generate a dev-mode HTML fragment.
389411pub fn generate_partial_dev(doc: &Document) -> String {
390− generate_partial_with_options(doc, true)
412+ generate_with(doc, &CodegenOptions { dev: true, partial: true, ..Default::default() })
391413}
392414
393−/// Generate with aggressive minification: collapse whitespace in text nodes,
394−/// strip HTML comments, and minimize output size.
395415pub fn generate_minified(doc: &Document) -> String {
396− let html = generate_with_options(doc, false);
397− minify_html(&html)
416+ generate_with(doc, &CodegenOptions { minify: true, ..Default::default() })
398417}
399418
400−/// Generate with vendor prefixes for broader browser compatibility.
401419pub fn generate_compat(doc: &Document) -> String {
402− let html = generate_with_options(doc, false);
403− add_vendor_prefixes(&html)
420+ generate_with(doc, &CodegenOptions { compat: true, ..Default::default() })
404421}
405422
406−/// Generate dev mode with vendor prefixes.
407423pub fn generate_dev_compat(doc: &Document) -> String {
408− let html = generate_with_options(doc, true);
409− add_vendor_prefixes(&html)
424+ generate_with(doc, &CodegenOptions { dev: true, compat: true, ..Default::default() })
410425}
411426
412−/// Generate minified with vendor prefixes.
413427pub fn generate_minified_compat(doc: &Document) -> String {
414− let html = generate_with_options(doc, false);
415− let html = minify_html(&html);
416− add_vendor_prefixes(&html)
428+ generate_with(doc, &CodegenOptions { minify: true, compat: true, ..Default::default() })
417429}
418430
419431fn minify_html(html: &str) -> String {
@@ -599,7 +611,7 @@ fn prefix_declarations(block: &str) -> String {
599611 out
600612}
601613
602−fn generate_with_options(doc: &Document, dev: bool) -> String {
614+fn generate_full_inner(doc: &Document, dev: bool) -> String {
603615 let mut styles = StyleCollector::new();
604616 let mut ctx = GenContext { dev, depth: 0 };
605617 let mut body = String::new();
@@ -1019,7 +1031,7 @@ img {{ display: block; }}
10191031}
10201032
10211033/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1022−fn generate_partial_with_options(doc: &Document, dev: bool) -> String {
1034+fn generate_partial_inner(doc: &Document, dev: bool) -> String {
10231035 let mut styles = StyleCollector::new();
10241036 let mut ctx = GenContext { dev, depth: 0 };
10251037 let mut body = String::new();
@@ -3008,15 +3020,21 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
30083020}
30093021
30103022/// Read image dimensions from a local file by parsing the header bytes.
3011−/// Supports PNG, JPEG, GIF, and WebP.
3023+/// Supports PNG, JPEG, GIF, WebP, AVIF, and SVG.
30123024fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
3025+ // SVG: parse as text for viewBox/width/height attributes
3026+ if path.ends_with(".svg") || path.ends_with(".SVG") {
3027+ let text = std::fs::read_to_string(path).ok()?;
3028+ return read_svg_dimensions(&text);
3029+ }
3030+
30133031 let data = std::fs::read(path).ok()?;
3014− if data.len() < 24 {
3032+ if data.len() < 12 {
30153033 return None;
30163034 }
30173035
30183036 // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
3019− if data.starts_with(b"\x89PNG\r\n\x1a\n") {
3037+ if data.len() >= 24 && data.starts_with(b"\x89PNG\r\n\x1a\n") {
30203038 let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
30213039 let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
30223040 return Some((w, h));
@@ -3043,7 +3061,6 @@ fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
30433061 let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
30443062 return Some((w, h));
30453063 }
3046− // Skip to next marker
30473064 if i + 3 < data.len() {
30483065 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
30493066 i += 2 + len;
@@ -3055,13 +3072,11 @@ fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
30553072
30563073 // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
30573074 if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
3058− // VP8 (lossy): dimensions at bytes 26-29
30593075 if &data[12..16] == b"VP8 " && data.len() >= 30 {
30603076 let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
30613077 let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
30623078 return Some((w, h));
30633079 }
3064− // VP8L (lossless): signature byte 0x2f at byte 21
30653080 if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
30663081 let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
30673082 let w = (bits & 0x3FFF) + 1;
@@ -3070,6 +3085,80 @@ fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
30703085 }
30713086 }
30723087
3088+ // AVIF: ISOBMFF container with "ftyp" box containing "avif"/"avis" brand,
3089+ // then "ispe" box with width/height
3090+ if data.len() >= 12 && &data[4..8] == b"ftyp" {
3091+ let brand = &data[8..12];
3092+ if brand == b"avif" || brand == b"avis" || brand == b"mif1" {
3093+ return read_avif_dimensions(&data);
3094+ }
3095+ }
3096+
3097+ None
3098+}
3099+
3100+/// Parse SVG viewBox or width/height attributes to get dimensions.
3101+fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
3102+ // Try viewBox first: viewBox="minX minY width height"
3103+ if let Some(vb_start) = text.find("viewBox=\"") {
3104+ let rest = &text[vb_start + 9..];
3105+ if let Some(end) = rest.find('"') {
3106+ let parts: Vec<&str> = rest[..end].split_whitespace().collect();
3107+ if parts.len() == 4 {
3108+ if let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>()) {
3109+ if w > 0.0 && h > 0.0 {
3110+ return Some((w.round() as u32, h.round() as u32));
3111+ }
3112+ }
3113+ }
3114+ }
3115+ }
3116+ // Fall back to width/height attributes on <svg>
3117+ let svg_tag = text.find("<svg")?;
3118+ let tag_end = text[svg_tag..].find('>')? + svg_tag;
3119+ let tag = &text[svg_tag..tag_end];
3120+ let w = extract_svg_attr(tag, "width")?;
3121+ let h = extract_svg_attr(tag, "height")?;
3122+ Some((w, h))
3123+}
3124+
3125+fn extract_svg_attr(tag: &str, attr: &str) -> Option<u32> {
3126+ let needle = format!("{}=\"", attr);
3127+ let start = tag.find(&needle)? + needle.len();
3128+ let rest = &tag[start..];
3129+ let end = rest.find('"')?;
3130+ let val = rest[..end].trim_end_matches("px");
3131+ val.parse::<f64>().ok().map(|v| v.round() as u32)
3132+}
3133+
3134+/// Parse AVIF (ISOBMFF) container to find ispe box with image dimensions.
3135+fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
3136+ // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
3137+ let mut i = 0;
3138+ while i + 8 <= data.len() {
3139+ let box_size = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
3140+ let box_type = &data[i + 4..i + 8];
3141+ if box_size < 8 {
3142+ break;
3143+ }
3144+ let box_end = (i + box_size).min(data.len());
3145+ // ispe box: 4 bytes version/flags + 4 bytes width + 4 bytes height
3146+ if box_type == b"ispe" && box_end >= i + 20 {
3147+ let w = u32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
3148+ let h = u32::from_be_bytes([data[i + 16], data[i + 17], data[i + 18], data[i + 19]]);
3149+ return Some((w, h));
3150+ }
3151+ // Recurse into container boxes (meta, iprp, ipco)
3152+ if matches!(box_type, b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia") {
3153+ let header_size = if box_type == b"meta" { 12 } else { 8 };
3154+ if i + header_size < box_end {
3155+ if let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end]) {
3156+ return Some(dims);
3157+ }
3158+ }
3159+ }
3160+ i = box_end;
3161+ }
30733162 None
30743163}
30753164
@@ -3105,29 +3194,70 @@ fn base64_encode(data: &[u8]) -> String {
31053194}
31063195
31073196// ---------------------------------------------------------------------------
3108−// Source map generation
3197+// Source map generation (standard v3 format with VLQ-encoded mappings)
31093198// ---------------------------------------------------------------------------
31103199
3111−/// Generate a JSON source map that maps HTML output lines to .hl source lines.
3112−/// Uses a simplified format (not full VLQ) for easy consumption.
3200+/// Generate a standard v3 source map with VLQ-encoded mappings.
3201+/// Compatible with browser devtools and source map tooling.
31133202pub fn generate_source_map(doc: &Document, source_file: &str) -> String {
31143203 let mut mappings: Vec<(usize, usize)> = Vec::new(); // (html_line, hl_line)
31153204 collect_source_lines(&doc.nodes, &mut mappings);
31163205 mappings.sort_by_key(|m| m.0);
31173206 mappings.dedup_by_key(|m| m.0);
31183207
3119− let mut json = String::from("{");
3120− json.push_str("\"version\":3,");
3121− json.push_str(&format!("\"file\":\"{}\",", source_file.replace(".hl", ".html").replace('\\', "\\\\")));
3122− json.push_str(&format!("\"sourceRoot\":\"\","));
3123− json.push_str(&format!("\"sources\":[\"{}\"],", source_file.replace('\\', "\\\\")));
3124− json.push_str("\"mappings\":[");
3125− for (i, (html_line, hl_line)) in mappings.iter().enumerate() {
3126− if i > 0 { json.push(','); }
3127− json.push_str(&format!("{{\"generated\":{},\"source\":{}}}", html_line, hl_line));
3128− }
3129− json.push_str("]}");
3130− json
3208+ // Build VLQ-encoded mappings string.
3209+ // Each generated line is separated by ';'. Each segment within a line is
3210+ // separated by ','. A segment has 4 fields: generated column, source index,
3211+ // source line, source column — all VLQ-encoded as deltas.
3212+ let max_gen_line = mappings.last().map(|m| m.0).unwrap_or(0);
3213+ let mut vlq = String::new();
3214+ let mut prev_source_line: i64 = 0;
3215+ let mut mapping_idx = 0;
3216+
3217+ for gen_line in 1..=max_gen_line {
3218+ if gen_line > 1 {
3219+ vlq.push(';');
3220+ }
3221+ if mapping_idx < mappings.len() && mappings[mapping_idx].0 == gen_line {
3222+ let source_line = mappings[mapping_idx].1 as i64 - 1; // 0-based
3223+ // Segment: gen_col=0, source_idx=0, source_line=delta, source_col=0
3224+ vlq_encode(0, &mut vlq); // generated column (always 0)
3225+ vlq_encode(0, &mut vlq); // source file index (always 0)
3226+ vlq_encode(source_line - prev_source_line, &mut vlq); // source line delta
3227+ vlq_encode(0, &mut vlq); // source column (always 0)
3228+ prev_source_line = source_line;
3229+ mapping_idx += 1;
3230+ }
3231+ }
3232+
3233+ let escaped_file = source_file.replace(".hl", ".html").replace('\\', "\\\\").replace('"', "\\\"");
3234+ let escaped_source = source_file.replace('\\', "\\\\").replace('"', "\\\"");
3235+
3236+ format!(
3237+ "{{\"version\":3,\"file\":\"{}\",\"sourceRoot\":\"\",\"sources\":[\"{}\"],\"names\":[],\"mappings\":\"{}\"}}",
3238+ escaped_file, escaped_source, vlq
3239+ )
3240+}
3241+
3242+/// Encode a single signed integer as a VLQ base64 string, appending to `out`.
3243+fn vlq_encode(value: i64, out: &mut String) {
3244+ const B64: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3245+ let mut v = if value < 0 {
3246+ ((-value) << 1) | 1
3247+ } else {
3248+ value << 1
3249+ } as u64;
3250+ loop {
3251+ let mut digit = (v & 0x1F) as u8; // 5-bit chunk
3252+ v >>= 5;
3253+ if v > 0 {
3254+ digit |= 0x20; // continuation bit
3255+ }
3256+ out.push(B64[digit as usize] as char);
3257+ if v == 0 {
3258+ break;
3259+ }
3260+ }
31313261}
31323262
31333263fn collect_source_lines(nodes: &[Node], mappings: &mut Vec<(usize, usize)>) {
src/main.rs+30−343
@@ -1,4 +1,6 @@
1−use std::collections::HashMap;
1+mod cli;
2+
3+use std::collections::{HashMap, HashSet};
24use std::env;
35use std::fs;
46use std::hash::{Hash, Hasher};
@@ -72,9 +74,16 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
7274 htmlang::parser::Severity::Error => "error",
7375 htmlang::parser::Severity::Warning => "warning",
7476 };
75− eprintln!("{}: line {}: {}", prefix, d.line, d.message);
77+ if let Some(col) = d.column {
78+ eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
79+ } else {
80+ eprintln!("{}: line {}: {}", prefix, d.line, d.message);
81+ }
7682 if let Some(ref src) = d.source_line {
7783 eprintln!(" | {}", src);
84+ if let Some(col) = d.column {
85+ eprintln!(" | {}^", " ".repeat(col));
86+ }
7887 }
7988 }
8089 }
@@ -97,22 +106,15 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
97106 let _ = fs::write(&out_path, &error_html);
98107 }
99108 } else {
100− let html = if cfg.partial {
101− if cfg.dev {
102− htmlang::codegen::generate_partial_dev(&result.document)
103− } else {
104− htmlang::codegen::generate_partial(&result.document)
105− }
106− } else {
107− match (cfg.minify, cfg.dev, cfg.compat) {
108− (true, _, true) => htmlang::codegen::generate_minified_compat(&result.document),
109− (true, _, false) => htmlang::codegen::generate_minified(&result.document),
110− (false, true, true) => htmlang::codegen::generate_dev_compat(&result.document),
111− (false, true, false) => htmlang::codegen::generate_dev(&result.document),
112− (false, false, true) => htmlang::codegen::generate_compat(&result.document),
113− (false, false, false) => htmlang::codegen::generate(&result.document),
114− }
115− };
109+ let html = htmlang::codegen::generate_with(
110+ &result.document,
111+ &htmlang::codegen::CodegenOptions {
112+ dev: cfg.dev,
113+ partial: cfg.partial,
114+ minify: cfg.minify,
115+ compat: cfg.compat,
116+ },
117+ );
116118 match fs::write(&out_path, &html) {
117119 Ok(()) => eprintln!("wrote {}", out_path.display()),
118120 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
@@ -340,90 +342,6 @@ h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
340342 )
341343}
342344
343−fn print_help() {
344− eprintln!(
345− "\
346−htmlang {} - a minimalist layout language that compiles to static HTML
347−
348−Usage: htmlang [options] <file.hl | directory>
349−
350−Commands:
351− init [dir] [--template blog|docs|portfolio]
352− Create a new project (defaults to current directory)
353− new <page-name> Create a new .hl page from a template
354− test [dir|file] Run @assert directives across a project
355− build <dir> [-o <out>] [--minify] Compile all .hl files (parallel)
356− serve [dir|file] [-p N] [--open] Start dev server with hot reload
357− watch [dir|file] [-o out] Watch for changes and recompile
358− check <file.hl | dir> Check for errors without writing output
359− convert <file.html> Convert an HTML file to .hl format (stdout)
360− fmt <file.hl> Format a .hl file (normalizes indentation)
361− sitemap <dir> Generate sitemap.xml from .hl files
362− lint <file.hl | dir> Stricter lint checks (accessibility, nesting, etc.)
363− stats <file.hl | dir> Show file statistics (elements, CSS rules, colors)
364− preview <file.hl> Compile and open in browser (one-shot, no server)
365− diff <a.hl> <b.hl> Show differences between two .hl files
366− export <dir> [-o f] Compile and bundle into an archive
367− repl Interactive REPL (type .hl, get HTML)
368− feed <dir> [-b URL] Generate RSS feed from @page metadata
369− components <dir> List all @fn definitions across a project
370− deps <dir> Show file dependency graph (@include/@import)
371− dead-code <dir> Find unused @fn, @define, @let across project
372− deploy <dir> [--provider github-pages|netlify|vercel|cloudflare]
373− Build and deploy
374− playground [out.html] Generate a self-contained HTML playground
375− clean [dir] Remove generated .html files
376− upgrade [dir|file] Auto-upgrade syntax to latest conventions
377− create-component <name> [params...] Scaffold a new component file
378− outline <file.hl> Show document structure tree
379− doctor Check toolchain health
380− migrate [dir|file] Auto-upgrade deprecated syntax
381− bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
382− size <file|dir> Report output sizes (raw, minified, ~gzip)
383− benchmark <file|dir> Measure compile time and output size
384− explain <file.hl> Show what CSS each source line produces
385− lsp Start the Language Server Protocol server
386− completions <shell> Generate shell completions (bash, zsh, fish)
387−
388−Options:
389− -w, --watch Watch for changes and recompile
390− -s, --serve Start dev server with hot reload (implies --watch)
391− -p, --port <N> Port for dev server (default: 3000)
392− --open Open browser automatically (with --serve)
393− -o, --output <path> Output file/directory path
394− -d, --dev Development mode
395− -c, --check Check for errors without writing output
396− --compat Add vendor prefixes for broader browser support
397− --strict Treat warnings as errors (useful for CI)
398− --partial Output HTML fragment without document wrapper
399− --format json Output diagnostics as JSON to stdout
400− -h, --help Show this help
401− -V, --version Show version
402−
403−Examples:
404− htmlang init Scaffold a new project
405− htmlang init my-site Scaffold in a new directory
406− htmlang new about-us Create about-us.hl from template
407− htmlang page.hl Compile page.hl to page.html
408− htmlang site/ Compile all .hl files in directory
409− htmlang -w page.hl Recompile on file changes
410− htmlang -s page.hl Start dev server with hot reload
411− htmlang -s --open site/ Serve and open browser
412− htmlang -s site/ Serve a multi-page site
413− htmlang -s -p 8080 page.hl
414− htmlang -c page.hl Lint without writing output
415− htmlang check src/ Check all files in a directory
416− htmlang convert page.html Convert HTML to .hl format
417− htmlang --format json page.hl Get diagnostics as JSON
418− htmlang fmt page.hl Format a file
419− htmlang build src/ -o dist/ Compile all .hl files to dist/
420− htmlang sitemap src/ Generate sitemap.xml
421− htmlang lint src/ Lint all files in directory
422− htmlang stats page.hl Show file statistics",
423− env!("CARGO_PKG_VERSION")
424− );
425−}
426−
427345fn init_project(dir: &str, template_name: Option<&str>) {
428346 let dir = Path::new(dir);
429347 if dir.to_str() != Some(".") {
@@ -1026,14 +944,14 @@ fn main() {
1026944
1027945 // Handle "lsp" subcommand — launch the LSP server from the main binary
1028946 if args.len() >= 2 && args[1] == "lsp" {
1029− run_lsp();
947+ cli::run_lsp();
1030948 return;
1031949 }
1032950
1033951 // Handle "completions" subcommand — generate shell completions
1034952 if args.len() >= 2 && args[1] == "completions" {
1035953 let shell = args.get(2).map(|s| s.as_str()).unwrap_or("bash");
1036− print_shell_completions(shell);
954+ cli::print_shell_completions(shell);
1037955 return;
1038956 }
1039957
@@ -3224,7 +3142,7 @@ compile();
32243142 while i < args.len() {
32253143 match args[i].as_str() {
32263144 "--help" | "-h" => {
3227− print_help();
3145+ cli::print_help();
32283146 process::exit(0);
32293147 }
32303148 "--version" | "-V" => {
@@ -3293,7 +3211,7 @@ compile();
32933211 let input_path = match input_path {
32943212 Some(p) => p,
32953213 None => {
3296− print_help();
3214+ cli::print_help();
32973215 process::exit(1);
32983216 }
32993217 };
@@ -3428,236 +3346,7 @@ compile();
34283346 );
34293347}
34303348
3431−// ---------------------------------------------------------------------------
3432−// LSP subcommand: launch the Language Server Protocol server
3433−// ---------------------------------------------------------------------------
3434−
3435−fn run_lsp() {
3436− // Try to find the htmlang-lsp binary alongside this binary, or in PATH
3437− let self_exe = env::current_exe().ok();
3438− let lsp_name = if cfg!(windows) { "htmlang-lsp.exe" } else { "htmlang-lsp" };
3439−
3440− let lsp_path = self_exe
3441− .as_ref()
3442− .and_then(|exe| exe.parent())
3443− .map(|dir| dir.join(lsp_name))
3444− .filter(|p| p.exists());
3445−
3446− let lsp_cmd = lsp_path
3447− .map(|p| p.to_string_lossy().to_string())
3448− .unwrap_or_else(|| lsp_name.to_string());
3449−
3450− let status = std::process::Command::new(&lsp_cmd)
3451− .stdin(std::process::Stdio::inherit())
3452− .stdout(std::process::Stdio::inherit())
3453− .stderr(std::process::Stdio::inherit())
3454− .status();
3455−
3456− match status {
3457− Ok(s) => {
3458− if !s.success() {
3459− process::exit(s.code().unwrap_or(1));
3460− }
3461− }
3462− Err(_) => {
3463− eprintln!("error: could not find htmlang-lsp binary");
3464− eprintln!("hint: ensure htmlang-lsp is in the same directory as htmlang, or in your PATH");
3465− process::exit(1);
3466− }
3467− }
3468−}
3469−
3470−// ---------------------------------------------------------------------------
3471−// Shell completion generation
3472−// ---------------------------------------------------------------------------
3473−
3474−fn print_shell_completions(shell: &str) {
3475− match shell {
3476− "bash" => print_bash_completions(),
3477− "zsh" => print_zsh_completions(),
3478− "fish" => print_fish_completions(),
3479− _ => {
3480− eprintln!("unknown shell: {}. Supported: bash, zsh, fish", shell);
3481− process::exit(1);
3482− }
3483− }
3484−}
3485−
3486−fn print_bash_completions() {
3487− println!(r#"_htmlang() {{
3488− local cur prev commands
3489− cur="${{COMP_WORDS[COMP_CWORD]}}"
3490− prev="${{COMP_WORDS[COMP_CWORD-1]}}"
3491− commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor migrate bundle size benchmark explain test lsp completions"
3492−
3493− if [ "$COMP_CWORD" -eq 1 ]; then
3494− COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
3495− COMPREPLY+=( $(compgen -f -X '!*.hl' -- "$cur") )
3496− COMPREPLY+=( $(compgen -d -- "$cur") )
3497− return
3498− fi
3499−
3500− case "$prev" in
3501− -o|--output) COMPREPLY=( $(compgen -f -- "$cur") ) ;;
3502− -p|--port) COMPREPLY=() ;;
3503− --format) COMPREPLY=( $(compgen -W "json" -- "$cur") ) ;;
3504− --template|-t) COMPREPLY=( $(compgen -W "blog docs portfolio" -- "$cur") ) ;;
3505− --provider) COMPREPLY=( $(compgen -W "github-pages netlify vercel cloudflare" -- "$cur") ) ;;
3506− completions) COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") ) ;;
3507− *)
3508− COMPREPLY=( $(compgen -W "-w --watch -s --serve -d --dev -c --check --compat --strict --partial --open --format --minify -o --output -p --port -h --help -V --version" -- "$cur") )
3509− COMPREPLY+=( $(compgen -f -X '!*.hl' -- "$cur") )
3510− COMPREPLY+=( $(compgen -d -- "$cur") )
3511− ;;
3512− esac
3513−}}
3514−complete -F _htmlang htmlang"#);
3515−}
3516−
3517−fn print_zsh_completions() {
3518− println!(r#"#compdef htmlang
3519−
3520−_htmlang() {{
3521− local -a commands=(
3522− 'init:Create a new project'
3523− 'new:Create a new .hl page'
3524− 'build:Compile all .hl files'
3525− 'serve:Start dev server'
3526− 'watch:Watch and recompile'
3527− 'check:Check for errors'
3528− 'convert:Convert HTML to .hl'
3529− 'fmt:Format a .hl file'
3530− 'sitemap:Generate sitemap.xml'
3531− 'lint:Lint checks'
3532− 'stats:Show file statistics'
3533− 'preview:Compile and open in browser'
3534− 'diff:Compare two .hl files'
3535− 'export:Bundle into archive'
3536− 'repl:Interactive REPL'
3537− 'feed:Generate RSS feed'
3538− 'components:List @fn definitions'
3539− 'deps:Show dependency graph'
3540− 'dead-code:Find unused definitions'
3541− 'deploy:Build and deploy'
3542− 'playground:Generate playground'
3543− 'clean:Remove generated files'
3544− 'upgrade:Auto-upgrade syntax'
3545− 'create-component:Scaffold component'
3546− 'outline:Show document structure'
3547− 'doctor:Check toolchain health'
3548− 'migrate:Upgrade deprecated syntax'
3549− 'bundle:Inline all assets'
3550− 'size:Report output sizes'
3551− 'benchmark:Measure compile time'
3552− 'explain:Show CSS mapping per source line'
3553− 'test:Run assertions'
3554− 'lsp:Start LSP server'
3555− 'completions:Generate shell completions'
3556− )
3557−
3558− local -a flags=(
3559− '-w[Watch for changes]'
3560− '--watch[Watch for changes]'
3561− '-s[Start dev server]'
3562− '--serve[Start dev server]'
3563− '-d[Development mode]'
3564− '--dev[Development mode]'
3565− '-c[Check only]'
3566− '--check[Check only]'
3567− '--compat[Vendor prefixes]'
3568− '--strict[Warnings as errors]'
3569− '--partial[Output HTML fragment]'
3570− '--open[Open browser]'
3571− '--minify[Minify output]'
3572− '-o[Output path]:path:_files'
3573− '--output[Output path]:path:_files'
3574− '-p[Port]:port:'
3575− '--port[Port]:port:'
3576− '--format[Output format]:format:(json)'
3577− '-h[Show help]'
3578− '--help[Show help]'
3579− '-V[Show version]'
3580− '--version[Show version]'
3581− )
3582−
3583− if (( CURRENT == 2 )); then
3584− _alternative 'commands:command:compadd -a commands' 'files:file:_files -g "*.hl"' 'dirs:directory:_directories'
3585− else
3586− _alternative 'flags:flag:compadd -a flags' 'files:file:_files -g "*.hl"' 'dirs:directory:_directories'
3587− fi
3588−}}
3589−
3590−_htmlang "$@""#);
3591−}
3592−
3593−fn print_fish_completions() {
3594− let commands = [
3595− ("init", "Create a new project"),
3596− ("new", "Create a new .hl page"),
3597− ("build", "Compile all .hl files"),
3598− ("serve", "Start dev server with hot reload"),
3599− ("watch", "Watch for changes and recompile"),
3600− ("check", "Check for errors"),
3601− ("convert", "Convert HTML to .hl"),
3602− ("fmt", "Format a .hl file"),
3603− ("sitemap", "Generate sitemap.xml"),
3604− ("lint", "Strict lint checks"),
3605− ("stats", "Show file statistics"),
3606− ("preview", "Compile and open in browser"),
3607− ("diff", "Compare two .hl files"),
3608− ("export", "Bundle into archive"),
3609− ("repl", "Interactive REPL"),
3610− ("feed", "Generate RSS feed"),
3611− ("components", "List @fn definitions"),
3612− ("deps", "Show dependency graph"),
3613− ("dead-code", "Find unused definitions"),
3614− ("deploy", "Build and deploy"),
3615− ("playground", "Generate playground"),
3616− ("clean", "Remove generated files"),
3617− ("upgrade", "Auto-upgrade syntax"),
3618− ("create-component", "Scaffold component"),
3619− ("outline", "Show document structure"),
3620− ("doctor", "Check toolchain health"),
3621− ("migrate", "Upgrade deprecated syntax"),
3622− ("bundle", "Inline all assets as data URIs"),
3623− ("size", "Report output sizes"),
3624− ("benchmark", "Measure compile time"),
3625− ("explain", "Show CSS mapping per source line"),
3626− ("test", "Run assertions"),
3627− ("lsp", "Start LSP server"),
3628− ("completions", "Generate shell completions"),
3629− ];
3630−
3631− for (cmd, desc) in &commands {
3632− println!("complete -c htmlang -n '__fish_use_subcommand' -a '{}' -d '{}'", cmd, desc);
3633− }
3634−
3635− let flags = [
3636− ("-w", "--watch", "Watch for changes"),
3637− ("-s", "--serve", "Start dev server"),
3638− ("-d", "--dev", "Development mode"),
3639− ("-c", "--check", "Check only"),
3640− ("", "--compat", "Add vendor prefixes"),
3641− ("", "--strict", "Treat warnings as errors"),
3642− ("", "--partial", "Output HTML fragment without document wrapper"),
3643− ("", "--open", "Open browser"),
3644− ("", "--minify", "Minify output"),
3645− ("-o", "--output", "Output path"),
3646− ("-p", "--port", "Dev server port"),
3647− ("-h", "--help", "Show help"),
3648− ("-V", "--version", "Show version"),
3649− ];
3650−
3651− for (short, long, desc) in &flags {
3652− if short.is_empty() {
3653− println!("complete -c htmlang -l '{}' -d '{}'", long.trim_start_matches('-'), desc);
3654− } else {
3655− println!("complete -c htmlang -s '{}' -l '{}' -d '{}'", short.trim_start_matches('-'), long.trim_start_matches('-'), desc);
3656− }
3657− }
3658−
3659− println!("complete -c htmlang -n '__fish_seen_subcommand_from completions' -a 'bash zsh fish'");
3660−}
3349+// (CLI help, LSP launcher, and shell completions moved to cli.rs)
36613350
36623351fn collect_hl_files(dir: &Path) -> Vec<PathBuf> {
36633352 let mut files = Vec::new();
@@ -3748,8 +3437,8 @@ fn watch_loop(
37483437 std::thread::sleep(std::time::Duration::from_millis(50));
37493438 while rx.try_recv().is_ok() {}
37503439
3751− // Collect which files actually changed
3752− let mut changed_files: Vec<PathBuf> = Vec::new();
3440+ // Collect which files actually changed (HashSet for O(1) lookups)
3441+ let mut changed_files: HashSet<PathBuf> = HashSet::new();
37533442 let check_path = |path: &Path,
37543443 hashes: &mut HashMap<PathBuf, u64>|
37553444 -> bool {
@@ -3765,7 +3454,7 @@ fn watch_loop(
37653454 for file in source_files {
37663455 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
37673456 if check_path(&canonical, &mut content_hashes) {
3768− changed_files.push(canonical);
3457+ changed_files.insert(canonical);
37693458 }
37703459 }
37713460
@@ -3773,9 +3462,7 @@ fn watch_loop(
37733462 for (_, deps) in &dep_map {
37743463 for dep in deps {
37753464 if check_path(dep, &mut content_hashes) {
3776− if !changed_files.contains(dep) {
3777− changed_files.push(dep.clone());
3778− }
3465+ changed_files.insert(dep.clone());
37793466 }
37803467 }
37813468 }
@@ -3786,7 +3473,7 @@ fn watch_loop(
37863473 for file in &current_files {
37873474 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
37883475 if check_path(&canonical, &mut content_hashes) {
3789− changed_files.push(canonical.clone());
3476+ changed_files.insert(canonical.clone());
37903477 let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
37913478 }
37923479 }
src/parser.rs+277−4
@@ -17,6 +17,7 @@ pub enum Severity {
1717#[derive(Debug, Clone)]
1818pub struct Diagnostic {
1919 pub line: usize,
20+ pub column: Option<usize>,
2021 pub message: String,
2122 pub severity: Severity,
2223 pub source_line: Option<String>,
@@ -374,6 +375,7 @@ impl Parser {
374375 };
375376 ctx.diagnostics.push(Diagnostic {
376377 line: e.line,
378+ column: None,
377379 message: e.message,
378380 severity: Severity::Error,
379381 source_line: source,
@@ -714,6 +716,7 @@ impl Parser {
714716 if !evaluate_condition(&condition) {
715717 ctx.diagnostics.push(Diagnostic {
716718 line: line_num,
719+ column: None,
717720 message: format!("assertion failed: {}", rest),
718721 severity: Severity::Error,
719722 source_line: Some(content.clone()),
@@ -731,9 +734,11 @@ impl Parser {
731734
732735 // Circular include check
733736 if ctx.include_stack.contains(&resolved) {
737+ let cycle_chain = format_include_chain(&ctx.include_stack);
734738 ctx.diagnostics.push(Diagnostic {
735739 line: line_num,
736− message: format!("circular include '{}'", filename),
740+ column: None,
741+ message: format!("circular include '{}' (cycle: {} → {})", filename, cycle_chain, filename),
737742 severity: Severity::Error,
738743 source_line: Some(content.clone()),
739744 });
@@ -752,6 +757,7 @@ impl Parser {
752757 Err(e) => {
753758 ctx.diagnostics.push(Diagnostic {
754759 line: line_num,
760+ column: None,
755761 message: format!("cannot include '{}': {}", filename, e),
756762 severity: Severity::Error,
757763 source_line: Some(content.clone()),
@@ -830,6 +836,7 @@ impl Parser {
830836 Err(e) => {
831837 ctx.diagnostics.push(Diagnostic {
832838 line: line_num,
839+ column: None,
833840 message: format!("cannot read directory for glob '{}': {}", filename, e),
834841 severity: Severity::Error,
835842 source_line: Some(content.clone()),
@@ -840,6 +847,7 @@ impl Parser {
840847 if matched_files.is_empty() {
841848 ctx.diagnostics.push(Diagnostic {
842849 line: line_num,
850+ column: None,
843851 message: format!("no files matched glob pattern '{}'", filename),
844852 severity: Severity::Warning,
845853 source_line: Some(content.clone()),
@@ -872,9 +880,11 @@ impl Parser {
872880 };
873881
874882 if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
883+ let cycle_chain = format_include_chain(&ctx.import_stack);
875884 ctx.diagnostics.push(Diagnostic {
876885 line: line_num,
877− message: format!("circular import '{}'", filename),
886+ column: None,
887+ message: format!("circular import '{}' (cycle: {} → {})", filename, cycle_chain, filename),
878888 severity: Severity::Error,
879889 source_line: Some(content.clone()),
880890 });
@@ -892,6 +902,7 @@ impl Parser {
892902 Err(e) => {
893903 ctx.diagnostics.push(Diagnostic {
894904 line: line_num,
905+ column: None,
895906 message: format!("cannot import '{}': {}", filename, e),
896907 severity: Severity::Error,
897908 source_line: Some(content.clone()),
@@ -1006,6 +1017,7 @@ impl Parser {
10061017 None => {
10071018 ctx.diagnostics.push(Diagnostic {
10081019 line: line_num,
1020+ column: None,
10091021 message: format!("environment variable '{}' is not set and no default provided", var_name),
10101022 severity: Severity::Warning,
10111023 source_line: Some(content.clone()),
@@ -1075,6 +1087,7 @@ impl Parser {
10751087 Err(e) => {
10761088 ctx.diagnostics.push(Diagnostic {
10771089 line: line_num,
1090+ column: None,
10781091 message: format!("@fetch failed for '{}': {}", url, e),
10791092 severity: Severity::Error,
10801093 source_line: Some(content.clone()),
@@ -1149,6 +1162,7 @@ impl Parser {
11491162 Err(e) => {
11501163 ctx.diagnostics.push(Diagnostic {
11511164 line: line_num,
1165+ column: None,
11521166 message: format!("cannot load SVG '{}': {}", filename, e),
11531167 severity: Severity::Error,
11541168 source_line: Some(content.clone()),
@@ -1227,6 +1241,7 @@ impl Parser {
12271241 Err(e) => {
12281242 ctx.diagnostics.push(Diagnostic {
12291243 line: line_num,
1244+ column: None,
12301245 message: format!("cannot load data '{}': {}", filename, e),
12311246 severity: Severity::Error,
12321247 source_line: Some(content.clone()),
@@ -1250,6 +1265,7 @@ impl Parser {
12501265 } else {
12511266 ctx.diagnostics.push(Diagnostic {
12521267 line: line_num,
1268+ column: None,
12531269 message: "@data without prefix requires a JSON object at top level".to_string(),
12541270 severity: Severity::Error,
12551271 source_line: Some(content.clone()),
@@ -1266,6 +1282,7 @@ impl Parser {
12661282 Err(detail) => {
12671283 ctx.diagnostics.push(Diagnostic {
12681284 line: line_num,
1285+ column: None,
12691286 message: format!("invalid JSON in '{}': {}", filename, detail),
12701287 severity: Severity::Error,
12711288 source_line: Some(content.clone()),
@@ -1317,6 +1334,7 @@ impl Parser {
13171334 if ctx.include_stack.contains(&resolved) {
13181335 ctx.diagnostics.push(Diagnostic {
13191336 line: line_num,
1337+ column: None,
13201338 message: format!("circular use '{}'", filename),
13211339 severity: Severity::Error,
13221340 source_line: Some(content.clone()),
@@ -1335,6 +1353,7 @@ impl Parser {
13351353 Err(e) => {
13361354 ctx.diagnostics.push(Diagnostic {
13371355 line: line_num,
1356+ column: None,
13381357 message: format!("cannot use '{}': {}", filename, e),
13391358 severity: Severity::Error,
13401359 source_line: Some(content.clone()),
@@ -2085,6 +2104,7 @@ impl Parser {
20852104 let msg = substitute_vars(rest.trim(), &ctx.variables);
20862105 ctx.diagnostics.push(Diagnostic {
20872106 line: line_num,
2107+ column: None,
20882108 message: msg,
20892109 severity: Severity::Warning,
20902110 source_line: Some(content.clone()),
@@ -2320,6 +2340,7 @@ impl Parser {
23202340 if ctx.include_stack.contains(&resolved) {
23212341 ctx.diagnostics.push(Diagnostic {
23222342 line: line_num,
2343+ column: None,
23232344 message: format!("circular extends '{}'", filename),
23242345 severity: Severity::Error,
23252346 source_line: Some(content.clone()),
@@ -2338,6 +2359,7 @@ impl Parser {
23382359 Err(e) => {
23392360 ctx.diagnostics.push(Diagnostic {
23402361 line: line_num,
2362+ column: None,
23412363 message: format!("cannot extend '{}': {}", filename, e),
23422364 severity: Severity::Error,
23432365 source_line: Some(content.clone()),
@@ -2418,6 +2440,7 @@ impl Parser {
24182440 if ctx.include_stack.contains(&resolved) {
24192441 ctx.diagnostics.push(Diagnostic {
24202442 line: line_num,
2443+ column: None,
24212444 message: format!("circular layout '{}'", filename),
24222445 severity: Severity::Error,
24232446 source_line: Some(content.clone()),
@@ -2436,6 +2459,7 @@ impl Parser {
24362459 Err(e) => {
24372460 ctx.diagnostics.push(Diagnostic {
24382461 line: line_num,
2462+ column: None,
24392463 message: format!("cannot load layout '{}': {}", filename, e),
24402464 severity: Severity::Error,
24412465 source_line: Some(content.clone()),
@@ -2583,6 +2607,7 @@ impl Parser {
25832607 if let Some(msg) = ctx.deprecated_fns.get(name) {
25842608 ctx.diagnostics.push(Diagnostic {
25852609 line: line_num,
2610+ column: None,
25862611 message: format!("@{} is deprecated: {}", name, msg),
25872612 severity: Severity::Warning,
25882613 source_line: Some(content.clone()),
@@ -2603,6 +2628,8 @@ impl Parser {
26032628
26042629 // --- Bare text ---
26052630
2631+ let var_warnings = check_undefined_vars(&content, &ctx.variables, line_num);
2632+ ctx.diagnostics.extend(var_warnings);
26062633 let segments = parse_text_segments(&content, ctx);
26072634 Ok(Some(vec![Node::Text(segments)]))
26082635 }
@@ -2819,9 +2846,26 @@ fn parse_single_element(
28192846 } else {
28202847 without_at[i + 1..].to_string()
28212848 };
2822− (parse_element_kind(kind_str, line_num)?, rest)
2849+ match parse_element_kind(kind_str, line_num) {
2850+ Ok(kind) => (kind, rest),
2851+ Err(mut e) => {
2852+ // Also suggest user-defined functions
2853+ if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) {
2854+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2855+ }
2856+ return Err(e);
2857+ }
2858+ }
28232859 }
2824− None => (parse_element_kind(without_at, line_num)?, String::new()),
2860+ None => match parse_element_kind(without_at, line_num) {
2861+ Ok(kind) => (kind, String::new()),
2862+ Err(mut e) => {
2863+ if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) {
2864+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2865+ }
2866+ return Err(e);
2867+ }
2868+ },
28252869 }
28262870 } else {
28272871 return Err(ParseError {
@@ -3046,6 +3090,78 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
30463090 best
30473091}
30483092
3093+/// Suggest the closest user-defined function name for typos.
3094+fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
3095+ let mut best: Option<String> = None;
3096+ let mut best_dist = usize::MAX;
3097+ for name in ctx.functions.keys() {
3098+ let dist = levenshtein(input, name);
3099+ if dist < best_dist && dist <= 2 && dist < input.len() {
3100+ best_dist = dist;
3101+ best = Some(name.clone());
3102+ }
3103+ }
3104+ best
3105+}
3106+
3107+/// Suggest the closest variable name for undefined `$var` references.
3108+fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<String> {
3109+ let mut best: Option<String> = None;
3110+ let mut best_dist = usize::MAX;
3111+ for name in vars.keys() {
3112+ let dist = levenshtein(input, name);
3113+ if dist < best_dist && dist <= 2 && dist < input.len() {
3114+ best_dist = dist;
3115+ best = Some(name.clone());
3116+ }
3117+ }
3118+ best
3119+}
3120+
3121+/// Check for undefined `$var` references and return "did you mean?" diagnostics.
3122+fn check_undefined_vars(input: &str, vars: &HashMap<String, String>, line_num: usize) -> Vec<Diagnostic> {
3123+ let mut warnings = Vec::new();
3124+ if !input.contains('$') {
3125+ return warnings;
3126+ }
3127+ let chars: Vec<char> = input.chars().collect();
3128+ let mut i = 0;
3129+ while i < chars.len() {
3130+ if chars[i] == '$'
3131+ && i + 1 < chars.len()
3132+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
3133+ {
3134+ let col = i;
3135+ let start = i + 1;
3136+ let mut end = start;
3137+ while end < chars.len()
3138+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_' || chars[end] == '.')
3139+ {
3140+ end += 1;
3141+ }
3142+ while end > start && chars[end - 1] == '.' {
3143+ end -= 1;
3144+ }
3145+ let name: String = chars[start..end].iter().collect();
3146+ if !name.is_empty() && !vars.contains_key(&name) {
3147+ if let Some(closest) = suggest_var_name(&name, vars) {
3148+ warnings.push(Diagnostic {
3149+ line: line_num,
3150+ column: Some(col),
3151+ message: format!("undefined variable '${}', did you mean '${}'?", name, closest),
3152+ severity: Severity::Warning,
3153+ source_line: Some(input.to_string()),
3154+ });
3155+ }
3156+ }
3157+ i = end;
3158+ } else {
3159+ i += 1;
3160+ }
3161+ }
3162+ warnings
3163+}
3164+
30493165// ---------------------------------------------------------------------------
30503166// Attribute parsing
30513167// ---------------------------------------------------------------------------
@@ -3228,6 +3344,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32283344 if part.parse::<f64>().is_err() && !has_css_unit(part) {
32293345 ctx.diagnostics.push(Diagnostic {
32303346 line: line_num,
3347+ column: None,
32313348 message: format!(
32323349 "'{}' expects a numeric value (with optional unit), got '{}'",
32333350 attr.key, val
@@ -3245,6 +3362,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32453362 if !is_keyword && !is_numeric && !has_unit {
32463363 ctx.diagnostics.push(Diagnostic {
32473364 line: line_num,
3365+ column: None,
32483366 message: format!(
32493367 "'{}' expects a number or one of [{}], got '{}'",
32503368 attr.key,
@@ -3260,6 +3378,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32603378 if !(0.0..=1.0).contains(&v) {
32613379 ctx.diagnostics.push(Diagnostic {
32623380 line: line_num,
3381+ column: None,
32633382 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
32643383 severity: Severity::Warning,
32653384 source_line: None,
@@ -3268,6 +3387,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32683387 } else {
32693388 ctx.diagnostics.push(Diagnostic {
32703389 line: line_num,
3390+ column: None,
32713391 message: format!("'opacity' expects a numeric value, got '{}'", val),
32723392 severity: Severity::Warning,
32733393 source_line: None,
@@ -3277,11 +3397,140 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32773397 if val.parse::<i32>().is_err() {
32783398 ctx.diagnostics.push(Diagnostic {
32793399 line: line_num,
3400+ column: None,
32803401 message: format!("'z-index' expects an integer, got '{}'", val),
32813402 severity: Severity::Warning,
32823403 source_line: None,
32833404 });
32843405 }
3406+ } else if base_key == "display" {
3407+ const DISPLAY_VALUES: &[&str] = &[
3408+ "none", "block", "inline", "inline-block", "flex", "inline-flex",
3409+ "grid", "inline-grid", "table", "table-row", "table-cell",
3410+ "contents", "flow-root", "list-item",
3411+ ];
3412+ if !DISPLAY_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3413+ let suggestion = suggest_closest(val, DISPLAY_VALUES);
3414+ let msg = match suggestion {
3415+ Some(s) => format!("unknown display value '{}', did you mean '{}'?", val, s),
3416+ None => format!("unknown display value '{}'", val),
3417+ };
3418+ ctx.diagnostics.push(Diagnostic {
3419+ line: line_num, column: None, message: msg,
3420+ severity: Severity::Warning, source_line: None,
3421+ });
3422+ }
3423+ } else if base_key == "position" {
3424+ const POSITION_VALUES: &[&str] = &[
3425+ "static", "relative", "absolute", "fixed", "sticky",
3426+ ];
3427+ if !POSITION_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3428+ let suggestion = suggest_closest(val, POSITION_VALUES);
3429+ let msg = match suggestion {
3430+ Some(s) => format!("unknown position value '{}', did you mean '{}'?", val, s),
3431+ None => format!("unknown position value '{}'", val),
3432+ };
3433+ ctx.diagnostics.push(Diagnostic {
3434+ line: line_num, column: None, message: msg,
3435+ severity: Severity::Warning, source_line: None,
3436+ });
3437+ }
3438+ } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" {
3439+ const OVERFLOW_VALUES: &[&str] = &[
3440+ "visible", "hidden", "scroll", "auto", "clip",
3441+ ];
3442+ if !OVERFLOW_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3443+ let suggestion = suggest_closest(val, OVERFLOW_VALUES);
3444+ let msg = match suggestion {
3445+ Some(s) => format!("unknown overflow value '{}', did you mean '{}'?", val, s),
3446+ None => format!("unknown overflow value '{}'", val),
3447+ };
3448+ ctx.diagnostics.push(Diagnostic {
3449+ line: line_num, column: None, message: msg,
3450+ severity: Severity::Warning, source_line: None,
3451+ });
3452+ }
3453+ } else if base_key == "text-align" {
3454+ const TEXT_ALIGN_VALUES: &[&str] = &[
3455+ "left", "right", "center", "justify", "start", "end",
3456+ ];
3457+ if !TEXT_ALIGN_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3458+ let suggestion = suggest_closest(val, TEXT_ALIGN_VALUES);
3459+ let msg = match suggestion {
3460+ Some(s) => format!("unknown text-align value '{}', did you mean '{}'?", val, s),
3461+ None => format!("unknown text-align value '{}'", val),
3462+ };
3463+ ctx.diagnostics.push(Diagnostic {
3464+ line: line_num, column: None, message: msg,
3465+ severity: Severity::Warning, source_line: None,
3466+ });
3467+ }
3468+ } else if base_key == "cursor" {
3469+ const CURSOR_VALUES: &[&str] = &[
3470+ "auto", "default", "none", "pointer", "wait", "text", "move",
3471+ "not-allowed", "crosshair", "grab", "grabbing", "help", "progress",
3472+ "col-resize", "row-resize", "n-resize", "s-resize", "e-resize", "w-resize",
3473+ "zoom-in", "zoom-out", "context-menu", "cell", "copy", "alias", "no-drop",
3474+ ];
3475+ if !CURSOR_VALUES.contains(&val.as_str()) && !val.starts_with("url(") && !val.starts_with("var(") {
3476+ let suggestion = suggest_closest(val, CURSOR_VALUES);
3477+ let msg = match suggestion {
3478+ Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s),
3479+ None => format!("unknown cursor value '{}'", val),
3480+ };
3481+ ctx.diagnostics.push(Diagnostic {
3482+ line: line_num, column: None, message: msg,
3483+ severity: Severity::Warning, source_line: None,
3484+ });
3485+ }
3486+ } else if base_key == "font-weight" {
3487+ const WEIGHT_VALUES: &[&str] = &[
3488+ "normal", "bold", "bolder", "lighter",
3489+ "100", "200", "300", "400", "500", "600", "700", "800", "900",
3490+ ];
3491+ if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3492+ ctx.diagnostics.push(Diagnostic {
3493+ line: line_num, column: None,
3494+ message: format!("'font-weight' expects a weight keyword or number 100-900, got '{}'", val),
3495+ severity: Severity::Warning, source_line: None,
3496+ });
3497+ }
3498+ } else if base_key == "color" || base_key == "background" {
3499+ // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.)
3500+ if !val.starts_with('#') && !val.starts_with("rgb") && !val.starts_with("hsl")
3501+ && !val.starts_with("var(") && !val.starts_with("linear-gradient")
3502+ && !val.starts_with("radial-gradient") && !val.starts_with("conic-gradient")
3503+ && !val.starts_with("oklch") && !val.starts_with("oklab")
3504+ && !val.starts_with("color(") && !val.starts_with("light-dark(")
3505+ && !val.contains("url(")
3506+ && !val.contains(' ') // skip shorthand multi-value
3507+ {
3508+ const NAMED_COLORS: &[&str] = &[
3509+ "transparent", "currentcolor", "inherit", "initial", "unset",
3510+ "black", "white", "red", "green", "blue", "yellow", "orange", "purple",
3511+ "pink", "brown", "gray", "grey", "cyan", "magenta", "lime", "olive",
3512+ "navy", "teal", "aqua", "fuchsia", "maroon", "silver",
3513+ "coral", "salmon", "tomato", "crimson", "firebrick", "darkred",
3514+ "indigo", "violet", "plum", "orchid", "thistle", "lavender",
3515+ "gold", "khaki", "wheat", "tan", "sienna", "chocolate", "peru",
3516+ "beige", "ivory", "linen", "snow", "seashell", "mintcream",
3517+ "skyblue", "steelblue", "royalblue", "dodgerblue", "cornflowerblue",
3518+ "slategray", "slategrey", "dimgray", "dimgrey", "lightgray", "lightgrey",
3519+ "darkgray", "darkgrey", "gainsboro", "whitesmoke",
3520+ ];
3521+ if !NAMED_COLORS.contains(&val.to_lowercase().as_str()) {
3522+ let lower = val.to_lowercase();
3523+ let suggestion = suggest_closest(&lower, NAMED_COLORS);
3524+ let msg = match suggestion {
3525+ Some(s) => format!("unknown color '{}', did you mean '{}'?", val, s),
3526+ None => format!("unknown color '{}'", val),
3527+ };
3528+ ctx.diagnostics.push(Diagnostic {
3529+ line: line_num, column: None, message: msg,
3530+ severity: Severity::Warning, source_line: None,
3531+ });
3532+ }
3533+ }
32853534 }
32863535 }
32873536}
@@ -3447,6 +3696,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
34473696 if seen_keys.contains(&stripped) {
34483697 ctx.diagnostics.push(Diagnostic {
34493698 line: line_num,
3699+ column: None,
34503700 message: format!("duplicate attribute '{}'", attr.key),
34513701 severity: Severity::Warning,
34523702 source_line: None,
@@ -3461,6 +3711,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
34613711 if val.starts_with('#') && !is_valid_hex_color(val) {
34623712 ctx.diagnostics.push(Diagnostic {
34633713 line: line_num,
3714+ column: None,
34643715 message: format!("invalid hex color '{}'", val),
34653716 severity: Severity::Warning,
34663717 source_line: None,
@@ -3486,6 +3737,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
34863737 };
34873738 ctx.diagnostics.push(Diagnostic {
34883739 line: line_num,
3740+ column: None,
34893741 message: msg,
34903742 severity: Severity::Warning,
34913743 source_line: None,
@@ -3911,6 +4163,7 @@ fn validate_tree(
39114163 if !matches!(parent_kind, Some(ElementKind::Row)) {
39124164 diagnostics.push(Diagnostic {
39134165 line: elem.line_num,
4166+ column: None,
39144167 message: "'width fill' works best inside @row; using 100% as fallback"
39154168 .to_string(),
39164169 severity: Severity::Warning,
@@ -3922,6 +4175,7 @@ fn validate_tree(
39224175 if !matches!(parent_kind, Some(ElementKind::Column)) {
39234176 diagnostics.push(Diagnostic {
39244177 line: elem.line_num,
4178+ column: None,
39254179 message:
39264180 "'height fill' works best inside @column; using 100% as fallback"
39274181 .to_string(),
@@ -3935,6 +4189,7 @@ fn validate_tree(
39354189 if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
39364190 diagnostics.push(Diagnostic {
39374191 line: elem.line_num,
4192+ column: None,
39384193 message: format!(
39394194 "'{}' has no effect on {} (only works on @row, @column, @el)",
39404195 base, element_kind_name(&elem.kind)
@@ -3950,6 +4205,7 @@ fn validate_tree(
39504205 {
39514206 diagnostics.push(Diagnostic {
39524207 line: elem.line_num,
4208+ column: None,
39534209 message: format!(
39544210 "'placeholder' has no effect on {} (only works on @input, @textarea)",
39554211 element_kind_name(&elem.kind)
@@ -3963,6 +4219,7 @@ fn validate_tree(
39634219 if base == "for" && !matches!(elem.kind, ElementKind::Label) {
39644220 diagnostics.push(Diagnostic {
39654221 line: elem.line_num,
4222+ column: None,
39664223 message: format!(
39674224 "'for' has no effect on {} (only works on @label)",
39684225 element_kind_name(&elem.kind)
@@ -3978,6 +4235,7 @@ fn validate_tree(
39784235 {
39794236 diagnostics.push(Diagnostic {
39804237 line: elem.line_num,
4238+ column: None,
39814239 message: format!(
39824240 "'{}' has no effect on {} (only works on @textarea)",
39834241 base, element_kind_name(&elem.kind)
@@ -3991,6 +4249,7 @@ fn validate_tree(
39914249 if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
39924250 diagnostics.push(Diagnostic {
39934251 line: elem.line_num,
4252+ column: None,
39944253 message: format!(
39954254 "'ordered' has no effect on {} (only works on @list)",
39964255 element_kind_name(&elem.kind)
@@ -4006,6 +4265,7 @@ fn validate_tree(
40064265 {
40074266 diagnostics.push(Diagnostic {
40084267 line: elem.line_num,
4268+ column: None,
40094269 message: format!(
40104270 "'{}' has no effect on {} (only works on @video, @audio)",
40114271 base, element_kind_name(&elem.kind)
@@ -4020,6 +4280,7 @@ fn validate_tree(
40204280 if !elem.attrs.iter().any(|a| a.key == "alt") {
40214281 diagnostics.push(Diagnostic {
40224282 line: elem.line_num,
4283+ column: None,
40234284 message: "@image missing 'alt' attribute (accessibility)".to_string(),
40244285 severity: Severity::Warning,
40254286 source_line: None,
@@ -4030,6 +4291,7 @@ fn validate_tree(
40304291 if !elem.attrs.iter().any(|a| a.key == "type") {
40314292 diagnostics.push(Diagnostic {
40324293 line: elem.line_num,
4294+ column: None,
40334295 message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
40344296 severity: Severity::Warning,
40354297 source_line: None,
@@ -4042,6 +4304,7 @@ fn validate_tree(
40424304 if !has_text && !elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "title") {
40434305 diagnostics.push(Diagnostic {
40444306 line: elem.line_num,
4307+ column: None,
40454308 message: "@link has no visible text or aria-label (accessibility)".to_string(),
40464309 severity: Severity::Warning,
40474310 source_line: None,
@@ -4063,6 +4326,7 @@ fn validate_tree(
40634326 if ratio < 4.5 {
40644327 diagnostics.push(Diagnostic {
40654328 line: elem.line_num,
4329+ column: None,
40664330 message: format!(
40674331 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
40684332 ratio, fg, bg
@@ -4084,6 +4348,7 @@ fn validate_tree(
40844348 if !has_id && !has_aria_label && !has_title && !in_label {
40854349 diagnostics.push(Diagnostic {
40864350 line: elem.line_num,
4351+ column: None,
40874352 message: format!(
40884353 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
40894354 element_kind_name(&elem.kind)
@@ -4099,6 +4364,7 @@ fn validate_tree(
40994364 if !elem.attrs.iter().any(|a| a.key == "title") {
41004365 diagnostics.push(Diagnostic {
41014366 line: elem.line_num,
4367+ column: None,
41024368 message: "@iframe missing 'title' attribute (accessibility)".to_string(),
41034369 severity: Severity::Warning,
41044370 source_line: None,
@@ -4113,6 +4379,7 @@ fn validate_tree(
41134379 if !has_text && !has_aria {
41144380 diagnostics.push(Diagnostic {
41154381 line: elem.line_num,
4382+ column: None,
41164383 message: "@button has no text content or aria-label (accessibility)".to_string(),
41174384 severity: Severity::Warning,
41184385 source_line: None,
@@ -4129,6 +4396,7 @@ fn validate_tree(
41294396 if !has_aria && !has_track {
41304397 diagnostics.push(Diagnostic {
41314398 line: elem.line_num,
4399+ column: None,
41324400 message: "@video should have aria-label or captions for accessibility".to_string(),
41334401 severity: Severity::Warning,
41344402 source_line: None,
@@ -4143,6 +4411,7 @@ fn validate_tree(
41434411 if n > 0 {
41444412 diagnostics.push(Diagnostic {
41454413 line: elem.line_num,
4414+ column: None,
41464415 message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
41474416 severity: Severity::Warning,
41484417 source_line: None,
@@ -4480,6 +4749,7 @@ fn check_unused(ctx: &mut ParseContext) {
44804749 if !ctx.used_variables.contains(name) {
44814750 ctx.diagnostics.push(Diagnostic {
44824751 line,
4752+ column: None,
44834753 message: format!("unused variable '${}' (defined but never referenced)", name),
44844754 severity: Severity::Warning,
44854755 source_line: None,
@@ -4492,6 +4762,7 @@ fn check_unused(ctx: &mut ParseContext) {
44924762 if !ctx.used_defines.contains(name) {
44934763 ctx.diagnostics.push(Diagnostic {
44944764 line,
4765+ column: None,
44954766 message: format!("unused define '${}' (defined but never referenced)", name),
44964767 severity: Severity::Warning,
44974768 source_line: None,
@@ -4504,6 +4775,7 @@ fn check_unused(ctx: &mut ParseContext) {
45044775 if !ctx.used_functions.contains(name) {
45054776 ctx.diagnostics.push(Diagnostic {
45064777 line,
4778+ column: None,
45074779 message: format!("unused function '@{}' (defined but never called)", name),
45084780 severity: Severity::Warning,
45094781 source_line: None,
@@ -4516,6 +4788,7 @@ fn check_unused(ctx: &mut ParseContext) {
45164788 if !ctx.used_mixins.contains(name) {
45174789 ctx.diagnostics.push(Diagnostic {
45184790 line,
4791+ column: None,
45194792 message: format!("unused mixin '{}' (defined but never referenced)", name),
45204793 severity: Severity::Warning,
45214794 source_line: None,