@@ -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 {
375 ctx.diagnostics.push(Diagnostic {
376 ctx.diagnostics.push(Diagnostic {
376 line: e.line,
377 line: e.line,
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,
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 {
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,
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()),
@@ -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,
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,
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,
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 {
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,
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()),
@@ -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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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 {
2604 // --- Bare text ---
2629 // --- Bare text ---
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)]))
@@ -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()
2822− (parse_element_kind(kind_str, line_num)?, rest)
2849+ match parse_element_kind(kind_str, line_num) {
2850+ Ok(kind) => (kind, rest),
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);
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()),
2863+ if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) {
2864+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
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> {
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() {
3101+ best = Some(name.clone());
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() {
3115+ best = Some(name.clone());
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('$') {
3127+ let chars: Vec<char> = input.chars().collect();
3129+ while i < chars.len() {
3131+ && i + 1 < chars.len()
3132+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
3136+ let mut end = start;
3137+ while end < chars.len()
3138+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_' || chars[end] == '.')
3142+ while end > start && chars[end - 1] == '.' {
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 {
3151+ message: format!("undefined variable '${}', did you mean '${}'?", name, closest),
3152+ severity: Severity::Warning,
3153+ source_line: Some(input.to_string()),
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,
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,
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,
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,
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,
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,
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",
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),
3418+ ctx.diagnostics.push(Diagnostic {
3419+ line: line_num, column: None, message: msg,
3420+ severity: Severity::Warning, source_line: None,
3423+ } else if base_key == "position" {
3424+ const POSITION_VALUES: &[&str] = &[
3425+ "static", "relative", "absolute", "fixed", "sticky",
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),
3433+ ctx.diagnostics.push(Diagnostic {
3434+ line: line_num, column: None, message: msg,
3435+ severity: Severity::Warning, source_line: None,
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",
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),
3448+ ctx.diagnostics.push(Diagnostic {
3449+ line: line_num, column: None, message: msg,
3450+ severity: Severity::Warning, source_line: None,
3453+ } else if base_key == "text-align" {
3454+ const TEXT_ALIGN_VALUES: &[&str] = &[
3455+ "left", "right", "center", "justify", "start", "end",
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),
3463+ ctx.diagnostics.push(Diagnostic {
3464+ line: line_num, column: None, message: msg,
3465+ severity: Severity::Warning, source_line: None,
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",
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),
3481+ ctx.diagnostics.push(Diagnostic {
3482+ line: line_num, column: None, message: msg,
3483+ severity: Severity::Warning, source_line: None,
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",
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,
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
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",
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),
3528+ ctx.diagnostics.push(Diagnostic {
3529+ line: line_num, column: None, message: msg,
3530+ severity: Severity::Warning, source_line: None,
@@ -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,
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,
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
3487 ctx.diagnostics.push(Diagnostic {
3738 ctx.diagnostics.push(Diagnostic {
3488 line: line_num,
3739 line: line_num,
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,
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,
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,
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(
3951 diagnostics.push(Diagnostic {
4206 diagnostics.push(Diagnostic {
3952 line: elem.line_num,
4207 line: elem.line_num,
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,
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(
3979 diagnostics.push(Diagnostic {
4236 diagnostics.push(Diagnostic {
3980 line: elem.line_num,
4237 line: elem.line_num,
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,
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(
4007 diagnostics.push(Diagnostic {
4266 diagnostics.push(Diagnostic {
4008 line: elem.line_num,
4267 line: elem.line_num,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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 {
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 {
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 {
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 {
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,