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
dd95bf6iko
src/bin/htmlang_lsp/main.rs+11−1
@@ -42,8 +42,18 @@ impl Backend {
42 htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
42 htmlang::parser::Severity::Warning => DiagnosticSeverity::WARNING,
43 };
43 };
44 let line = d.line.saturating_sub(1) as u32;
44 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+ };
45 Diagnostic {
55 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)),
47 severity: Some(severity),
57 severity: Some(severity),
48 source: Some("htmlang".into()),
58 source: Some("htmlang".into()),
49 message: d.message.clone(),
59 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 {
372// Public API
372// Public API
373// ---------------------------------------------------------------------------
373// ---------------------------------------------------------------------------
374
374
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+
375pub fn generate(doc: &Document) -> String {
399pub fn generate(doc: &Document) -> String {
376− generate_with_options(doc, false)
400+ generate_with(doc, &CodegenOptions::default())
377}
401}
378
402
379pub fn generate_dev(doc: &Document) -> String {
403pub fn generate_dev(doc: &Document) -> String {
380− generate_with_options(doc, true)
404+ generate_with(doc, &CodegenOptions { dev: true, ..Default::default() })
381}
405}
382
406
383−/// Generate an HTML fragment — body content + inline style, no document wrapper.
384pub fn generate_partial(doc: &Document) -> String {
407pub fn generate_partial(doc: &Document) -> String {
385− generate_partial_with_options(doc, false)
408+ generate_with(doc, &CodegenOptions { partial: true, ..Default::default() })
386}
409}
387
410
388−/// Generate a dev-mode HTML fragment.
389pub fn generate_partial_dev(doc: &Document) -> String {
411pub 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() })
391}
413}
392
414
393−/// Generate with aggressive minification: collapse whitespace in text nodes,
394−/// strip HTML comments, and minimize output size.
395pub fn generate_minified(doc: &Document) -> String {
415pub fn generate_minified(doc: &Document) -> String {
396− let html = generate_with_options(doc, false);
416+ generate_with(doc, &CodegenOptions { minify: true, ..Default::default() })
397− minify_html(&html)
398}
417}
399
418
400−/// Generate with vendor prefixes for broader browser compatibility.
401pub fn generate_compat(doc: &Document) -> String {
419pub fn generate_compat(doc: &Document) -> String {
402− let html = generate_with_options(doc, false);
420+ generate_with(doc, &CodegenOptions { compat: true, ..Default::default() })
403− add_vendor_prefixes(&html)
404}
421}
405
422
406−/// Generate dev mode with vendor prefixes.
407pub fn generate_dev_compat(doc: &Document) -> String {
423pub fn generate_dev_compat(doc: &Document) -> String {
408− let html = generate_with_options(doc, true);
424+ generate_with(doc, &CodegenOptions { dev: true, compat: true, ..Default::default() })
409− add_vendor_prefixes(&html)
410}
425}
411
426
412−/// Generate minified with vendor prefixes.
413pub fn generate_minified_compat(doc: &Document) -> String {
427pub fn generate_minified_compat(doc: &Document) -> String {
414− let html = generate_with_options(doc, false);
428+ generate_with(doc, &CodegenOptions { minify: true, compat: true, ..Default::default() })
415− let html = minify_html(&html);
416− add_vendor_prefixes(&html)
417}
429}
418
430
419fn minify_html(html: &str) -> String {
431fn minify_html(html: &str) -> String {
@@ -599,7 +611,7 @@ fn prefix_declarations(block: &str) -> String {
599 out
611 out
600}
612}
601
613
602−fn generate_with_options(doc: &Document, dev: bool) -> String {
614+fn generate_full_inner(doc: &Document, dev: bool) -> String {
603 let mut styles = StyleCollector::new();
615 let mut styles = StyleCollector::new();
604 let mut ctx = GenContext { dev, depth: 0 };
616 let mut ctx = GenContext { dev, depth: 0 };
605 let mut body = String::new();
617 let mut body = String::new();
@@ -1019,7 +1031,7 @@ img {{ display: block; }}
1019}
1031}
1020
1032
1021/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1033/// 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 {
1023 let mut styles = StyleCollector::new();
1035 let mut styles = StyleCollector::new();
1024 let mut ctx = GenContext { dev, depth: 0 };
1036 let mut ctx = GenContext { dev, depth: 0 };
1025 let mut body = String::new();
1037 let mut body = String::new();
@@ -3008,15 +3020,21 @@ fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
3008}
3020}
3009
3021
3010/// Read image dimensions from a local file by parsing the header bytes.
3022/// 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.
3012fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
3024fn 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+
3013 let data = std::fs::read(path).ok()?;
3031 let data = std::fs::read(path).ok()?;
3014− if data.len() < 24 {
3032+ if data.len() < 12 {
3015 return None;
3033 return None;
3016 }
3034 }
3017
3035
3018 // PNG: 8-byte signature, then IHDR chunk with width/height at bytes 16-23
3036 // 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") {
3020 let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
3038 let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
3021 let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
3039 let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
3022 return Some((w, h));
3040 return Some((w, h));
@@ -3043,7 +3061,6 @@ fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
3043 let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
3061 let w = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
3044 return Some((w, h));
3062 return Some((w, h));
3045 }
3063 }
3046− // Skip to next marker
3047 if i + 3 < data.len() {
3064 if i + 3 < data.len() {
3048 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
3065 let len = u16::from_be_bytes([data[i + 2], data[i + 3]]) as usize;
3049 i += 2 + len;
3066 i += 2 + len;
@@ -3055,13 +3072,11 @@ fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
3055
3072
3056 // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
3073 // WebP: "RIFF" ... "WEBP", VP8 header at byte 20
3057 if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
3074 if data.len() >= 30 && &data[..4] == b"RIFF" && &data[8..12] == b"WEBP" {
3058− // VP8 (lossy): dimensions at bytes 26-29
3059 if &data[12..16] == b"VP8 " && data.len() >= 30 {
3075 if &data[12..16] == b"VP8 " && data.len() >= 30 {
3060 let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
3076 let w = u16::from_le_bytes([data[26], data[27]]) as u32 & 0x3FFF;
3061 let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
3077 let h = u16::from_le_bytes([data[28], data[29]]) as u32 & 0x3FFF;
3062 return Some((w, h));
3078 return Some((w, h));
3063 }
3079 }
3064− // VP8L (lossless): signature byte 0x2f at byte 21
3065 if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
3080 if &data[12..16] == b"VP8L" && data.len() >= 25 && data[21] == 0x2F {
3066 let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
3081 let bits = u32::from_le_bytes([data[22], data[23], data[24], data[25]]);
3067 let w = (bits & 0x3FFF) + 1;
3082 let w = (bits & 0x3FFF) + 1;
@@ -3070,6 +3085,80 @@ fn read_image_dimensions(path: &str) -> Option<(u32, u32)> {
3070 }
3085 }
3071 }
3086 }
3072
3087
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+ }
3073 None
3162 None
3074}
3163}
3075
3164
@@ -3105,29 +3194,70 @@ fn base64_encode(data: &[u8]) -> String {
3105}
3194}
3106
3195
3107// ---------------------------------------------------------------------------
3196// ---------------------------------------------------------------------------
3108−// Source map generation
3197+// Source map generation (standard v3 format with VLQ-encoded mappings)
3109// ---------------------------------------------------------------------------
3198// ---------------------------------------------------------------------------
3110
3199
3111−/// Generate a JSON source map that maps HTML output lines to .hl source lines.
3200+/// Generate a standard v3 source map with VLQ-encoded mappings.
3112−/// Uses a simplified format (not full VLQ) for easy consumption.
3201+/// Compatible with browser devtools and source map tooling.
3113pub fn generate_source_map(doc: &Document, source_file: &str) -> String {
3202pub fn generate_source_map(doc: &Document, source_file: &str) -> String {
3114 let mut mappings: Vec<(usize, usize)> = Vec::new(); // (html_line, hl_line)
3203 let mut mappings: Vec<(usize, usize)> = Vec::new(); // (html_line, hl_line)
3115 collect_source_lines(&doc.nodes, &mut mappings);
3204 collect_source_lines(&doc.nodes, &mut mappings);
3116 mappings.sort_by_key(|m| m.0);
3205 mappings.sort_by_key(|m| m.0);
3117 mappings.dedup_by_key(|m| m.0);
3206 mappings.dedup_by_key(|m| m.0);
3118
3207
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);
3119− let mut json = String::from("{");
3213+ let mut vlq = String::new();
3120− json.push_str("\"version\":3,");
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+
3121− json.push_str(&format!("\"file\":\"{}\",", source_file.replace(".hl", ".html").replace('\\', "\\\\")));
3233+ let escaped_file = source_file.replace(".hl", ".html").replace('\\', "\\\\").replace('"', "\\\"");
3122− json.push_str(&format!("\"sourceRoot\":\"\","));
3234+ let escaped_source = source_file.replace('\\', "\\\\").replace('"', "\\\"");
3123− json.push_str(&format!("\"sources\":[\"{}\"],", source_file.replace('\\', "\\\\")));
3235+
3124− json.push_str("\"mappings\":[");
3236+ format!(
3125− for (i, (html_line, hl_line)) in mappings.iter().enumerate() {
3237+ "{{\"version\":3,\"file\":\"{}\",\"sourceRoot\":\"\",\"sources\":[\"{}\"],\"names\":[],\"mappings\":\"{}\"}}",
3126− if i > 0 { json.push(','); }
3238+ escaped_file, escaped_source, vlq
3127− json.push_str(&format!("{{\"generated\":{},\"source\":{}}}", html_line, hl_line));
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
3128− }
3255+ }
3129− json.push_str("]}");
3256+ out.push(B64[digit as usize] as char);
3130− json
3257+ if v == 0 {
3258+ break;
3259+ }
3260+ }
3131}
3261}
3132
3262
3133fn collect_source_lines(nodes: &[Node], mappings: &mut Vec<(usize, usize)>) {
3263fn collect_source_lines(nodes: &[Node], mappings: &mut Vec<(usize, usize)>) {
src/main.rs+30−343
@@ -1,4 +1,6 @@
1+mod cli;
2+
1−use std::collections::HashMap;
3+use std::collections::{HashMap, HashSet};
2use std::env;
4use std::env;
3use std::fs;
5use std::fs;
4use std::hash::{Hash, Hasher};
6use std::hash::{Hash, Hasher};
@@ -72,9 +74,16 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
72 htmlang::parser::Severity::Error => "error",
74 htmlang::parser::Severity::Error => "error",
73 htmlang::parser::Severity::Warning => "warning",
75 htmlang::parser::Severity::Warning => "warning",
74 };
76 };
77+ if let Some(col) = d.column {
78+ eprintln!("{}: line {}:{}: {}", prefix, d.line, col, d.message);
79+ } else {
75− eprintln!("{}: line {}: {}", prefix, d.line, d.message);
80+ eprintln!("{}: line {}: {}", prefix, d.line, d.message);
81+ }
76 if let Some(ref src) = d.source_line {
82 if let Some(ref src) = d.source_line {
77 eprintln!(" | {}", src);
83 eprintln!(" | {}", src);
84+ if let Some(col) = d.column {
85+ eprintln!(" | {}^", " ".repeat(col));
86+ }
78 }
87 }
79 }
88 }
80 }
89 }
@@ -97,22 +106,15 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
97 let _ = fs::write(&out_path, &error_html);
106 let _ = fs::write(&out_path, &error_html);
98 }
107 }
99 } else {
108 } else {
100− let html = if cfg.partial {
109+ let html = htmlang::codegen::generate_with(
101− if cfg.dev {
102− htmlang::codegen::generate_partial_dev(&result.document)
110+ &result.document,
103− } else {
111+ &htmlang::codegen::CodegenOptions {
104− htmlang::codegen::generate_partial(&result.document)
112+ dev: cfg.dev,
105− }
113+ partial: cfg.partial,
106− } else {
114+ minify: cfg.minify,
107− match (cfg.minify, cfg.dev, cfg.compat) {
115+ compat: 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− }
116+ },
115− };
117+ );
116 match fs::write(&out_path, &html) {
118 match fs::write(&out_path, &html) {
117 Ok(()) => eprintln!("wrote {}", out_path.display()),
119 Ok(()) => eprintln!("wrote {}", out_path.display()),
118 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
120 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
@@ -340,90 +342,6 @@ h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
340 )
342 )
341}
343}
342
344
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−
427fn init_project(dir: &str, template_name: Option<&str>) {
345fn init_project(dir: &str, template_name: Option<&str>) {
428 let dir = Path::new(dir);
346 let dir = Path::new(dir);
429 if dir.to_str() != Some(".") {
347 if dir.to_str() != Some(".") {
@@ -1026,14 +944,14 @@ fn main() {
1026
944
1027 // Handle "lsp" subcommand — launch the LSP server from the main binary
945 // Handle "lsp" subcommand — launch the LSP server from the main binary
1028 if args.len() >= 2 && args[1] == "lsp" {
946 if args.len() >= 2 && args[1] == "lsp" {
1029− run_lsp();
947+ cli::run_lsp();
1030 return;
948 return;
1031 }
949 }
1032
950
1033 // Handle "completions" subcommand — generate shell completions
951 // Handle "completions" subcommand — generate shell completions
1034 if args.len() >= 2 && args[1] == "completions" {
952 if args.len() >= 2 && args[1] == "completions" {
1035 let shell = args.get(2).map(|s| s.as_str()).unwrap_or("bash");
953 let shell = args.get(2).map(|s| s.as_str()).unwrap_or("bash");
1036− print_shell_completions(shell);
954+ cli::print_shell_completions(shell);
1037 return;
955 return;
1038 }
956 }
1039
957
@@ -3224,7 +3142,7 @@ compile();
3224 while i < args.len() {
3142 while i < args.len() {
3225 match args[i].as_str() {
3143 match args[i].as_str() {
3226 "--help" | "-h" => {
3144 "--help" | "-h" => {
3227− print_help();
3145+ cli::print_help();
3228 process::exit(0);
3146 process::exit(0);
3229 }
3147 }
3230 "--version" | "-V" => {
3148 "--version" | "-V" => {
@@ -3293,7 +3211,7 @@ compile();
3293 let input_path = match input_path {
3211 let input_path = match input_path {
3294 Some(p) => p,
3212 Some(p) => p,
3295 None => {
3213 None => {
3296− print_help();
3214+ cli::print_help();
3297 process::exit(1);
3215 process::exit(1);
3298 }
3216 }
3299 };
3217 };
@@ -3428,236 +3346,7 @@ compile();
3428 );
3346 );
3429}
3347}
3430
3348
3431−// ---------------------------------------------------------------------------
3349+// (CLI help, LSP launcher, and shell completions moved to cli.rs)
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−}
3661
3350
3662fn collect_hl_files(dir: &Path) -> Vec<PathBuf> {
3351fn collect_hl_files(dir: &Path) -> Vec<PathBuf> {
3663 let mut files = Vec::new();
3352 let mut files = Vec::new();
@@ -3748,8 +3437,8 @@ fn watch_loop(
3748 std::thread::sleep(std::time::Duration::from_millis(50));
3437 std::thread::sleep(std::time::Duration::from_millis(50));
3749 while rx.try_recv().is_ok() {}
3438 while rx.try_recv().is_ok() {}
3750
3439
3751− // Collect which files actually changed
3440+ // Collect which files actually changed (HashSet for O(1) lookups)
3752− let mut changed_files: Vec<PathBuf> = Vec::new();
3441+ let mut changed_files: HashSet<PathBuf> = HashSet::new();
3753 let check_path = |path: &Path,
3442 let check_path = |path: &Path,
3754 hashes: &mut HashMap<PathBuf, u64>|
3443 hashes: &mut HashMap<PathBuf, u64>|
3755 -> bool {
3444 -> bool {
@@ -3765,7 +3454,7 @@ fn watch_loop(
3765 for file in source_files {
3454 for file in source_files {
3766 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3455 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3767 if check_path(&canonical, &mut content_hashes) {
3456 if check_path(&canonical, &mut content_hashes) {
3768− changed_files.push(canonical);
3457+ changed_files.insert(canonical);
3769 }
3458 }
3770 }
3459 }
3771
3460
@@ -3773,9 +3462,7 @@ fn watch_loop(
3773 for (_, deps) in &dep_map {
3462 for (_, deps) in &dep_map {
3774 for dep in deps {
3463 for dep in deps {
3775 if check_path(dep, &mut content_hashes) {
3464 if check_path(dep, &mut content_hashes) {
3776− if !changed_files.contains(dep) {
3465+ changed_files.insert(dep.clone());
3777− changed_files.push(dep.clone());
3778− }
3779 }
3466 }
3780 }
3467 }
3781 }
3468 }
@@ -3786,7 +3473,7 @@ fn watch_loop(
3786 for file in &current_files {
3473 for file in &current_files {
3787 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3474 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3788 if check_path(&canonical, &mut content_hashes) {
3475 if check_path(&canonical, &mut content_hashes) {
3789− changed_files.push(canonical.clone());
3476+ changed_files.insert(canonical.clone());
3790 let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
3477 let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
3791 }
3478 }
3792 }
3479 }
src/parser.rs+277−4
@@ -17,6 +17,7 @@ pub enum Severity {
17#[derive(Debug, Clone)]
17#[derive(Debug, Clone)]
18pub struct Diagnostic {
18pub struct Diagnostic {
19 pub line: usize,
19 pub line: usize,
20+ pub column: Option<usize>,
20 pub message: String,
21 pub message: String,
21 pub severity: Severity,
22 pub severity: Severity,
22 pub source_line: Option<String>,
23 pub source_line: Option<String>,
@@ -374,6 +375,7 @@ impl Parser {
374 };
375 };
375 ctx.diagnostics.push(Diagnostic {
376 ctx.diagnostics.push(Diagnostic {
376 line: e.line,
377 line: e.line,
378+ column: None,
377 message: e.message,
379 message: e.message,
378 severity: Severity::Error,
380 severity: Severity::Error,
379 source_line: source,
381 source_line: source,
@@ -714,6 +716,7 @@ impl Parser {
714 if !evaluate_condition(&condition) {
716 if !evaluate_condition(&condition) {
715 ctx.diagnostics.push(Diagnostic {
717 ctx.diagnostics.push(Diagnostic {
716 line: line_num,
718 line: line_num,
719+ column: None,
717 message: format!("assertion failed: {}", rest),
720 message: format!("assertion failed: {}", rest),
718 severity: Severity::Error,
721 severity: Severity::Error,
719 source_line: Some(content.clone()),
722 source_line: Some(content.clone()),
@@ -731,9 +734,11 @@ impl Parser {
731
734
732 // Circular include check
735 // Circular include check
733 if ctx.include_stack.contains(&resolved) {
736 if ctx.include_stack.contains(&resolved) {
737+ let cycle_chain = format_include_chain(&ctx.include_stack);
734 ctx.diagnostics.push(Diagnostic {
738 ctx.diagnostics.push(Diagnostic {
735 line: line_num,
739 line: line_num,
740+ column: None,
736− message: format!("circular include '{}'", filename),
741+ message: format!("circular include '{}' (cycle: {} → {})", filename, cycle_chain, filename),
737 severity: Severity::Error,
742 severity: Severity::Error,
738 source_line: Some(content.clone()),
743 source_line: Some(content.clone()),
739 });
744 });
@@ -752,6 +757,7 @@ impl Parser {
752 Err(e) => {
757 Err(e) => {
753 ctx.diagnostics.push(Diagnostic {
758 ctx.diagnostics.push(Diagnostic {
754 line: line_num,
759 line: line_num,
760+ column: None,
755 message: format!("cannot include '{}': {}", filename, e),
761 message: format!("cannot include '{}': {}", filename, e),
756 severity: Severity::Error,
762 severity: Severity::Error,
757 source_line: Some(content.clone()),
763 source_line: Some(content.clone()),
@@ -830,6 +836,7 @@ impl Parser {
830 Err(e) => {
836 Err(e) => {
831 ctx.diagnostics.push(Diagnostic {
837 ctx.diagnostics.push(Diagnostic {
832 line: line_num,
838 line: line_num,
839+ column: None,
833 message: format!("cannot read directory for glob '{}': {}", filename, e),
840 message: format!("cannot read directory for glob '{}': {}", filename, e),
834 severity: Severity::Error,
841 severity: Severity::Error,
835 source_line: Some(content.clone()),
842 source_line: Some(content.clone()),
@@ -840,6 +847,7 @@ impl Parser {
840 if matched_files.is_empty() {
847 if matched_files.is_empty() {
841 ctx.diagnostics.push(Diagnostic {
848 ctx.diagnostics.push(Diagnostic {
842 line: line_num,
849 line: line_num,
850+ column: None,
843 message: format!("no files matched glob pattern '{}'", filename),
851 message: format!("no files matched glob pattern '{}'", filename),
844 severity: Severity::Warning,
852 severity: Severity::Warning,
845 source_line: Some(content.clone()),
853 source_line: Some(content.clone()),
@@ -872,9 +880,11 @@ impl Parser {
872 };
880 };
873
881
874 if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
882 if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
883+ let cycle_chain = format_include_chain(&ctx.import_stack);
875 ctx.diagnostics.push(Diagnostic {
884 ctx.diagnostics.push(Diagnostic {
876 line: line_num,
885 line: line_num,
886+ column: None,
877− message: format!("circular import '{}'", filename),
887+ message: format!("circular import '{}' (cycle: {} → {})", filename, cycle_chain, filename),
878 severity: Severity::Error,
888 severity: Severity::Error,
879 source_line: Some(content.clone()),
889 source_line: Some(content.clone()),
880 });
890 });
@@ -892,6 +902,7 @@ impl Parser {
892 Err(e) => {
902 Err(e) => {
893 ctx.diagnostics.push(Diagnostic {
903 ctx.diagnostics.push(Diagnostic {
894 line: line_num,
904 line: line_num,
905+ column: None,
895 message: format!("cannot import '{}': {}", filename, e),
906 message: format!("cannot import '{}': {}", filename, e),
896 severity: Severity::Error,
907 severity: Severity::Error,
897 source_line: Some(content.clone()),
908 source_line: Some(content.clone()),
@@ -1006,6 +1017,7 @@ impl Parser {
1006 None => {
1017 None => {
1007 ctx.diagnostics.push(Diagnostic {
1018 ctx.diagnostics.push(Diagnostic {
1008 line: line_num,
1019 line: line_num,
1020+ column: None,
1009 message: format!("environment variable '{}' is not set and no default provided", var_name),
1021 message: format!("environment variable '{}' is not set and no default provided", var_name),
1010 severity: Severity::Warning,
1022 severity: Severity::Warning,
1011 source_line: Some(content.clone()),
1023 source_line: Some(content.clone()),
@@ -1075,6 +1087,7 @@ impl Parser {
1075 Err(e) => {
1087 Err(e) => {
1076 ctx.diagnostics.push(Diagnostic {
1088 ctx.diagnostics.push(Diagnostic {
1077 line: line_num,
1089 line: line_num,
1090+ column: None,
1078 message: format!("@fetch failed for '{}': {}", url, e),
1091 message: format!("@fetch failed for '{}': {}", url, e),
1079 severity: Severity::Error,
1092 severity: Severity::Error,
1080 source_line: Some(content.clone()),
1093 source_line: Some(content.clone()),
@@ -1149,6 +1162,7 @@ impl Parser {
1149 Err(e) => {
1162 Err(e) => {
1150 ctx.diagnostics.push(Diagnostic {
1163 ctx.diagnostics.push(Diagnostic {
1151 line: line_num,
1164 line: line_num,
1165+ column: None,
1152 message: format!("cannot load SVG '{}': {}", filename, e),
1166 message: format!("cannot load SVG '{}': {}", filename, e),
1153 severity: Severity::Error,
1167 severity: Severity::Error,
1154 source_line: Some(content.clone()),
1168 source_line: Some(content.clone()),
@@ -1227,6 +1241,7 @@ impl Parser {
1227 Err(e) => {
1241 Err(e) => {
1228 ctx.diagnostics.push(Diagnostic {
1242 ctx.diagnostics.push(Diagnostic {
1229 line: line_num,
1243 line: line_num,
1244+ column: None,
1230 message: format!("cannot load data '{}': {}", filename, e),
1245 message: format!("cannot load data '{}': {}", filename, e),
1231 severity: Severity::Error,
1246 severity: Severity::Error,
1232 source_line: Some(content.clone()),
1247 source_line: Some(content.clone()),
@@ -1250,6 +1265,7 @@ impl Parser {
1250 } else {
1265 } else {
1251 ctx.diagnostics.push(Diagnostic {
1266 ctx.diagnostics.push(Diagnostic {
1252 line: line_num,
1267 line: line_num,
1268+ column: None,
1253 message: "@data without prefix requires a JSON object at top level".to_string(),
1269 message: "@data without prefix requires a JSON object at top level".to_string(),
1254 severity: Severity::Error,
1270 severity: Severity::Error,
1255 source_line: Some(content.clone()),
1271 source_line: Some(content.clone()),
@@ -1266,6 +1282,7 @@ impl Parser {
1266 Err(detail) => {
1282 Err(detail) => {
1267 ctx.diagnostics.push(Diagnostic {
1283 ctx.diagnostics.push(Diagnostic {
1268 line: line_num,
1284 line: line_num,
1285+ column: None,
1269 message: format!("invalid JSON in '{}': {}", filename, detail),
1286 message: format!("invalid JSON in '{}': {}", filename, detail),
1270 severity: Severity::Error,
1287 severity: Severity::Error,
1271 source_line: Some(content.clone()),
1288 source_line: Some(content.clone()),
@@ -1317,6 +1334,7 @@ impl Parser {
1317 if ctx.include_stack.contains(&resolved) {
1334 if ctx.include_stack.contains(&resolved) {
1318 ctx.diagnostics.push(Diagnostic {
1335 ctx.diagnostics.push(Diagnostic {
1319 line: line_num,
1336 line: line_num,
1337+ column: None,
1320 message: format!("circular use '{}'", filename),
1338 message: format!("circular use '{}'", filename),
1321 severity: Severity::Error,
1339 severity: Severity::Error,
1322 source_line: Some(content.clone()),
1340 source_line: Some(content.clone()),
@@ -1335,6 +1353,7 @@ impl Parser {
1335 Err(e) => {
1353 Err(e) => {
1336 ctx.diagnostics.push(Diagnostic {
1354 ctx.diagnostics.push(Diagnostic {
1337 line: line_num,
1355 line: line_num,
1356+ column: None,
1338 message: format!("cannot use '{}': {}", filename, e),
1357 message: format!("cannot use '{}': {}", filename, e),
1339 severity: Severity::Error,
1358 severity: Severity::Error,
1340 source_line: Some(content.clone()),
1359 source_line: Some(content.clone()),
@@ -2085,6 +2104,7 @@ impl Parser {
2085 let msg = substitute_vars(rest.trim(), &ctx.variables);
2104 let msg = substitute_vars(rest.trim(), &ctx.variables);
2086 ctx.diagnostics.push(Diagnostic {
2105 ctx.diagnostics.push(Diagnostic {
2087 line: line_num,
2106 line: line_num,
2107+ column: None,
2088 message: msg,
2108 message: msg,
2089 severity: Severity::Warning,
2109 severity: Severity::Warning,
2090 source_line: Some(content.clone()),
2110 source_line: Some(content.clone()),
@@ -2320,6 +2340,7 @@ impl Parser {
2320 if ctx.include_stack.contains(&resolved) {
2340 if ctx.include_stack.contains(&resolved) {
2321 ctx.diagnostics.push(Diagnostic {
2341 ctx.diagnostics.push(Diagnostic {
2322 line: line_num,
2342 line: line_num,
2343+ column: None,
2323 message: format!("circular extends '{}'", filename),
2344 message: format!("circular extends '{}'", filename),
2324 severity: Severity::Error,
2345 severity: Severity::Error,
2325 source_line: Some(content.clone()),
2346 source_line: Some(content.clone()),
@@ -2338,6 +2359,7 @@ impl Parser {
2338 Err(e) => {
2359 Err(e) => {
2339 ctx.diagnostics.push(Diagnostic {
2360 ctx.diagnostics.push(Diagnostic {
2340 line: line_num,
2361 line: line_num,
2362+ column: None,
2341 message: format!("cannot extend '{}': {}", filename, e),
2363 message: format!("cannot extend '{}': {}", filename, e),
2342 severity: Severity::Error,
2364 severity: Severity::Error,
2343 source_line: Some(content.clone()),
2365 source_line: Some(content.clone()),
@@ -2418,6 +2440,7 @@ impl Parser {
2418 if ctx.include_stack.contains(&resolved) {
2440 if ctx.include_stack.contains(&resolved) {
2419 ctx.diagnostics.push(Diagnostic {
2441 ctx.diagnostics.push(Diagnostic {
2420 line: line_num,
2442 line: line_num,
2443+ column: None,
2421 message: format!("circular layout '{}'", filename),
2444 message: format!("circular layout '{}'", filename),
2422 severity: Severity::Error,
2445 severity: Severity::Error,
2423 source_line: Some(content.clone()),
2446 source_line: Some(content.clone()),
@@ -2436,6 +2459,7 @@ impl Parser {
2436 Err(e) => {
2459 Err(e) => {
2437 ctx.diagnostics.push(Diagnostic {
2460 ctx.diagnostics.push(Diagnostic {
2438 line: line_num,
2461 line: line_num,
2462+ column: None,
2439 message: format!("cannot load layout '{}': {}", filename, e),
2463 message: format!("cannot load layout '{}': {}", filename, e),
2440 severity: Severity::Error,
2464 severity: Severity::Error,
2441 source_line: Some(content.clone()),
2465 source_line: Some(content.clone()),
@@ -2583,6 +2607,7 @@ impl Parser {
2583 if let Some(msg) = ctx.deprecated_fns.get(name) {
2607 if let Some(msg) = ctx.deprecated_fns.get(name) {
2584 ctx.diagnostics.push(Diagnostic {
2608 ctx.diagnostics.push(Diagnostic {
2585 line: line_num,
2609 line: line_num,
2610+ column: None,
2586 message: format!("@{} is deprecated: {}", name, msg),
2611 message: format!("@{} is deprecated: {}", name, msg),
2587 severity: Severity::Warning,
2612 severity: Severity::Warning,
2588 source_line: Some(content.clone()),
2613 source_line: Some(content.clone()),
@@ -2603,6 +2628,8 @@ impl Parser {
2603
2628
2604 // --- Bare text ---
2629 // --- Bare text ---
2605
2630
2631+ let var_warnings = check_undefined_vars(&content, &ctx.variables, line_num);
2632+ ctx.diagnostics.extend(var_warnings);
2606 let segments = parse_text_segments(&content, ctx);
2633 let segments = parse_text_segments(&content, ctx);
2607 Ok(Some(vec![Node::Text(segments)]))
2634 Ok(Some(vec![Node::Text(segments)]))
2608 }
2635 }
@@ -2819,9 +2846,26 @@ fn parse_single_element(
2819 } else {
2846 } else {
2820 without_at[i + 1..].to_string()
2847 without_at[i + 1..].to_string()
2821 };
2848 };
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+ }
2823 }
2859 }
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+ },
2825 }
2869 }
2826 } else {
2870 } else {
2827 return Err(ParseError {
2871 return Err(ParseError {
@@ -3046,6 +3090,78 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
3046 best
3090 best
3047}
3091}
3048
3092
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+
3049// ---------------------------------------------------------------------------
3165// ---------------------------------------------------------------------------
3050// Attribute parsing
3166// Attribute parsing
3051// ---------------------------------------------------------------------------
3167// ---------------------------------------------------------------------------
@@ -3228,6 +3344,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
3228 if part.parse::<f64>().is_err() && !has_css_unit(part) {
3344 if part.parse::<f64>().is_err() && !has_css_unit(part) {
3229 ctx.diagnostics.push(Diagnostic {
3345 ctx.diagnostics.push(Diagnostic {
3230 line: line_num,
3346 line: line_num,
3347+ column: None,
3231 message: format!(
3348 message: format!(
3232 "'{}' expects a numeric value (with optional unit), got '{}'",
3349 "'{}' expects a numeric value (with optional unit), got '{}'",
3233 attr.key, val
3350 attr.key, val
@@ -3245,6 +3362,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
3245 if !is_keyword && !is_numeric && !has_unit {
3362 if !is_keyword && !is_numeric && !has_unit {
3246 ctx.diagnostics.push(Diagnostic {
3363 ctx.diagnostics.push(Diagnostic {
3247 line: line_num,
3364 line: line_num,
3365+ column: None,
3248 message: format!(
3366 message: format!(
3249 "'{}' expects a number or one of [{}], got '{}'",
3367 "'{}' expects a number or one of [{}], got '{}'",
3250 attr.key,
3368 attr.key,
@@ -3260,6 +3378,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
3260 if !(0.0..=1.0).contains(&v) {
3378 if !(0.0..=1.0).contains(&v) {
3261 ctx.diagnostics.push(Diagnostic {
3379 ctx.diagnostics.push(Diagnostic {
3262 line: line_num,
3380 line: line_num,
3381+ column: None,
3263 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
3382 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
3264 severity: Severity::Warning,
3383 severity: Severity::Warning,
3265 source_line: None,
3384 source_line: None,
@@ -3268,6 +3387,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
3268 } else {
3387 } else {
3269 ctx.diagnostics.push(Diagnostic {
3388 ctx.diagnostics.push(Diagnostic {
3270 line: line_num,
3389 line: line_num,
3390+ column: None,
3271 message: format!("'opacity' expects a numeric value, got '{}'", val),
3391 message: format!("'opacity' expects a numeric value, got '{}'", val),
3272 severity: Severity::Warning,
3392 severity: Severity::Warning,
3273 source_line: None,
3393 source_line: None,
@@ -3277,11 +3397,140 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
3277 if val.parse::<i32>().is_err() {
3397 if val.parse::<i32>().is_err() {
3278 ctx.diagnostics.push(Diagnostic {
3398 ctx.diagnostics.push(Diagnostic {
3279 line: line_num,
3399 line: line_num,
3400+ column: None,
3280 message: format!("'z-index' expects an integer, got '{}'", val),
3401 message: format!("'z-index' expects an integer, got '{}'", val),
3281 severity: Severity::Warning,
3402 severity: Severity::Warning,
3282 source_line: None,
3403 source_line: None,
3283 });
3404 });
3284 }
3405 }
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+ }
3285 }
3534 }
3286 }
3535 }
3287}
3536}
@@ -3447,6 +3696,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
3447 if seen_keys.contains(&stripped) {
3696 if seen_keys.contains(&stripped) {
3448 ctx.diagnostics.push(Diagnostic {
3697 ctx.diagnostics.push(Diagnostic {
3449 line: line_num,
3698 line: line_num,
3699+ column: None,
3450 message: format!("duplicate attribute '{}'", attr.key),
3700 message: format!("duplicate attribute '{}'", attr.key),
3451 severity: Severity::Warning,
3701 severity: Severity::Warning,
3452 source_line: None,
3702 source_line: None,
@@ -3461,6 +3711,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
3461 if val.starts_with('#') && !is_valid_hex_color(val) {
3711 if val.starts_with('#') && !is_valid_hex_color(val) {
3462 ctx.diagnostics.push(Diagnostic {
3712 ctx.diagnostics.push(Diagnostic {
3463 line: line_num,
3713 line: line_num,
3714+ column: None,
3464 message: format!("invalid hex color '{}'", val),
3715 message: format!("invalid hex color '{}'", val),
3465 severity: Severity::Warning,
3716 severity: Severity::Warning,
3466 source_line: None,
3717 source_line: None,
@@ -3486,6 +3737,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
3486 };
3737 };
3487 ctx.diagnostics.push(Diagnostic {
3738 ctx.diagnostics.push(Diagnostic {
3488 line: line_num,
3739 line: line_num,
3740+ column: None,
3489 message: msg,
3741 message: msg,
3490 severity: Severity::Warning,
3742 severity: Severity::Warning,
3491 source_line: None,
3743 source_line: None,
@@ -3911,6 +4163,7 @@ fn validate_tree(
3911 if !matches!(parent_kind, Some(ElementKind::Row)) {
4163 if !matches!(parent_kind, Some(ElementKind::Row)) {
3912 diagnostics.push(Diagnostic {
4164 diagnostics.push(Diagnostic {
3913 line: elem.line_num,
4165 line: elem.line_num,
4166+ column: None,
3914 message: "'width fill' works best inside @row; using 100% as fallback"
4167 message: "'width fill' works best inside @row; using 100% as fallback"
3915 .to_string(),
4168 .to_string(),
3916 severity: Severity::Warning,
4169 severity: Severity::Warning,
@@ -3922,6 +4175,7 @@ fn validate_tree(
3922 if !matches!(parent_kind, Some(ElementKind::Column)) {
4175 if !matches!(parent_kind, Some(ElementKind::Column)) {
3923 diagnostics.push(Diagnostic {
4176 diagnostics.push(Diagnostic {
3924 line: elem.line_num,
4177 line: elem.line_num,
4178+ column: None,
3925 message:
4179 message:
3926 "'height fill' works best inside @column; using 100% as fallback"
4180 "'height fill' works best inside @column; using 100% as fallback"
3927 .to_string(),
4181 .to_string(),
@@ -3935,6 +4189,7 @@ fn validate_tree(
3935 if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
4189 if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
3936 diagnostics.push(Diagnostic {
4190 diagnostics.push(Diagnostic {
3937 line: elem.line_num,
4191 line: elem.line_num,
4192+ column: None,
3938 message: format!(
4193 message: format!(
3939 "'{}' has no effect on {} (only works on @row, @column, @el)",
4194 "'{}' has no effect on {} (only works on @row, @column, @el)",
3940 base, element_kind_name(&elem.kind)
4195 base, element_kind_name(&elem.kind)
@@ -3950,6 +4205,7 @@ fn validate_tree(
3950 {
4205 {
3951 diagnostics.push(Diagnostic {
4206 diagnostics.push(Diagnostic {
3952 line: elem.line_num,
4207 line: elem.line_num,
4208+ column: None,
3953 message: format!(
4209 message: format!(
3954 "'placeholder' has no effect on {} (only works on @input, @textarea)",
4210 "'placeholder' has no effect on {} (only works on @input, @textarea)",
3955 element_kind_name(&elem.kind)
4211 element_kind_name(&elem.kind)
@@ -3963,6 +4219,7 @@ fn validate_tree(
3963 if base == "for" && !matches!(elem.kind, ElementKind::Label) {
4219 if base == "for" && !matches!(elem.kind, ElementKind::Label) {
3964 diagnostics.push(Diagnostic {
4220 diagnostics.push(Diagnostic {
3965 line: elem.line_num,
4221 line: elem.line_num,
4222+ column: None,
3966 message: format!(
4223 message: format!(
3967 "'for' has no effect on {} (only works on @label)",
4224 "'for' has no effect on {} (only works on @label)",
3968 element_kind_name(&elem.kind)
4225 element_kind_name(&elem.kind)
@@ -3978,6 +4235,7 @@ fn validate_tree(
3978 {
4235 {
3979 diagnostics.push(Diagnostic {
4236 diagnostics.push(Diagnostic {
3980 line: elem.line_num,
4237 line: elem.line_num,
4238+ column: None,
3981 message: format!(
4239 message: format!(
3982 "'{}' has no effect on {} (only works on @textarea)",
4240 "'{}' has no effect on {} (only works on @textarea)",
3983 base, element_kind_name(&elem.kind)
4241 base, element_kind_name(&elem.kind)
@@ -3991,6 +4249,7 @@ fn validate_tree(
3991 if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
4249 if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
3992 diagnostics.push(Diagnostic {
4250 diagnostics.push(Diagnostic {
3993 line: elem.line_num,
4251 line: elem.line_num,
4252+ column: None,
3994 message: format!(
4253 message: format!(
3995 "'ordered' has no effect on {} (only works on @list)",
4254 "'ordered' has no effect on {} (only works on @list)",
3996 element_kind_name(&elem.kind)
4255 element_kind_name(&elem.kind)
@@ -4006,6 +4265,7 @@ fn validate_tree(
4006 {
4265 {
4007 diagnostics.push(Diagnostic {
4266 diagnostics.push(Diagnostic {
4008 line: elem.line_num,
4267 line: elem.line_num,
4268+ column: None,
4009 message: format!(
4269 message: format!(
4010 "'{}' has no effect on {} (only works on @video, @audio)",
4270 "'{}' has no effect on {} (only works on @video, @audio)",
4011 base, element_kind_name(&elem.kind)
4271 base, element_kind_name(&elem.kind)
@@ -4020,6 +4280,7 @@ fn validate_tree(
4020 if !elem.attrs.iter().any(|a| a.key == "alt") {
4280 if !elem.attrs.iter().any(|a| a.key == "alt") {
4021 diagnostics.push(Diagnostic {
4281 diagnostics.push(Diagnostic {
4022 line: elem.line_num,
4282 line: elem.line_num,
4283+ column: None,
4023 message: "@image missing 'alt' attribute (accessibility)".to_string(),
4284 message: "@image missing 'alt' attribute (accessibility)".to_string(),
4024 severity: Severity::Warning,
4285 severity: Severity::Warning,
4025 source_line: None,
4286 source_line: None,
@@ -4030,6 +4291,7 @@ fn validate_tree(
4030 if !elem.attrs.iter().any(|a| a.key == "type") {
4291 if !elem.attrs.iter().any(|a| a.key == "type") {
4031 diagnostics.push(Diagnostic {
4292 diagnostics.push(Diagnostic {
4032 line: elem.line_num,
4293 line: elem.line_num,
4294+ column: None,
4033 message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
4295 message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
4034 severity: Severity::Warning,
4296 severity: Severity::Warning,
4035 source_line: None,
4297 source_line: None,
@@ -4042,6 +4304,7 @@ fn validate_tree(
4042 if !has_text && !elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "title") {
4304 if !has_text && !elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "title") {
4043 diagnostics.push(Diagnostic {
4305 diagnostics.push(Diagnostic {
4044 line: elem.line_num,
4306 line: elem.line_num,
4307+ column: None,
4045 message: "@link has no visible text or aria-label (accessibility)".to_string(),
4308 message: "@link has no visible text or aria-label (accessibility)".to_string(),
4046 severity: Severity::Warning,
4309 severity: Severity::Warning,
4047 source_line: None,
4310 source_line: None,
@@ -4063,6 +4326,7 @@ fn validate_tree(
4063 if ratio < 4.5 {
4326 if ratio < 4.5 {
4064 diagnostics.push(Diagnostic {
4327 diagnostics.push(Diagnostic {
4065 line: elem.line_num,
4328 line: elem.line_num,
4329+ column: None,
4066 message: format!(
4330 message: format!(
4067 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
4331 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
4068 ratio, fg, bg
4332 ratio, fg, bg
@@ -4084,6 +4348,7 @@ fn validate_tree(
4084 if !has_id && !has_aria_label && !has_title && !in_label {
4348 if !has_id && !has_aria_label && !has_title && !in_label {
4085 diagnostics.push(Diagnostic {
4349 diagnostics.push(Diagnostic {
4086 line: elem.line_num,
4350 line: elem.line_num,
4351+ column: None,
4087 message: format!(
4352 message: format!(
4088 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
4353 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
4089 element_kind_name(&elem.kind)
4354 element_kind_name(&elem.kind)
@@ -4099,6 +4364,7 @@ fn validate_tree(
4099 if !elem.attrs.iter().any(|a| a.key == "title") {
4364 if !elem.attrs.iter().any(|a| a.key == "title") {
4100 diagnostics.push(Diagnostic {
4365 diagnostics.push(Diagnostic {
4101 line: elem.line_num,
4366 line: elem.line_num,
4367+ column: None,
4102 message: "@iframe missing 'title' attribute (accessibility)".to_string(),
4368 message: "@iframe missing 'title' attribute (accessibility)".to_string(),
4103 severity: Severity::Warning,
4369 severity: Severity::Warning,
4104 source_line: None,
4370 source_line: None,
@@ -4113,6 +4379,7 @@ fn validate_tree(
4113 if !has_text && !has_aria {
4379 if !has_text && !has_aria {
4114 diagnostics.push(Diagnostic {
4380 diagnostics.push(Diagnostic {
4115 line: elem.line_num,
4381 line: elem.line_num,
4382+ column: None,
4116 message: "@button has no text content or aria-label (accessibility)".to_string(),
4383 message: "@button has no text content or aria-label (accessibility)".to_string(),
4117 severity: Severity::Warning,
4384 severity: Severity::Warning,
4118 source_line: None,
4385 source_line: None,
@@ -4129,6 +4396,7 @@ fn validate_tree(
4129 if !has_aria && !has_track {
4396 if !has_aria && !has_track {
4130 diagnostics.push(Diagnostic {
4397 diagnostics.push(Diagnostic {
4131 line: elem.line_num,
4398 line: elem.line_num,
4399+ column: None,
4132 message: "@video should have aria-label or captions for accessibility".to_string(),
4400 message: "@video should have aria-label or captions for accessibility".to_string(),
4133 severity: Severity::Warning,
4401 severity: Severity::Warning,
4134 source_line: None,
4402 source_line: None,
@@ -4143,6 +4411,7 @@ fn validate_tree(
4143 if n > 0 {
4411 if n > 0 {
4144 diagnostics.push(Diagnostic {
4412 diagnostics.push(Diagnostic {
4145 line: elem.line_num,
4413 line: elem.line_num,
4414+ column: None,
4146 message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
4415 message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
4147 severity: Severity::Warning,
4416 severity: Severity::Warning,
4148 source_line: None,
4417 source_line: None,
@@ -4480,6 +4749,7 @@ fn check_unused(ctx: &mut ParseContext) {
4480 if !ctx.used_variables.contains(name) {
4749 if !ctx.used_variables.contains(name) {
4481 ctx.diagnostics.push(Diagnostic {
4750 ctx.diagnostics.push(Diagnostic {
4482 line,
4751 line,
4752+ column: None,
4483 message: format!("unused variable '${}' (defined but never referenced)", name),
4753 message: format!("unused variable '${}' (defined but never referenced)", name),
4484 severity: Severity::Warning,
4754 severity: Severity::Warning,
4485 source_line: None,
4755 source_line: None,
@@ -4492,6 +4762,7 @@ fn check_unused(ctx: &mut ParseContext) {
4492 if !ctx.used_defines.contains(name) {
4762 if !ctx.used_defines.contains(name) {
4493 ctx.diagnostics.push(Diagnostic {
4763 ctx.diagnostics.push(Diagnostic {
4494 line,
4764 line,
4765+ column: None,
4495 message: format!("unused define '${}' (defined but never referenced)", name),
4766 message: format!("unused define '${}' (defined but never referenced)", name),
4496 severity: Severity::Warning,
4767 severity: Severity::Warning,
4497 source_line: None,
4768 source_line: None,
@@ -4504,6 +4775,7 @@ fn check_unused(ctx: &mut ParseContext) {
4504 if !ctx.used_functions.contains(name) {
4775 if !ctx.used_functions.contains(name) {
4505 ctx.diagnostics.push(Diagnostic {
4776 ctx.diagnostics.push(Diagnostic {
4506 line,
4777 line,
4778+ column: None,
4507 message: format!("unused function '@{}' (defined but never called)", name),
4779 message: format!("unused function '@{}' (defined but never called)", name),
4508 severity: Severity::Warning,
4780 severity: Severity::Warning,
4509 source_line: None,
4781 source_line: None,
@@ -4516,6 +4788,7 @@ fn check_unused(ctx: &mut ParseContext) {
4516 if !ctx.used_mixins.contains(name) {
4788 if !ctx.used_mixins.contains(name) {
4517 ctx.diagnostics.push(Diagnostic {
4789 ctx.diagnostics.push(Diagnostic {
4518 line,
4790 line,
4791+ column: None,
4519 message: format!("unused mixin '{}' (defined but never referenced)", name),
4792 message: format!("unused mixin '{}' (defined but never referenced)", name),
4520 severity: Severity::Warning,
4793 severity: Severity::Warning,
4521 source_line: None,
4794 source_line: None,