@@ -17,6 +17,7 @@ pub enum Severity {
1717#[derive(Debug, Clone)]
1818pub struct Diagnostic {
1919 pub line: usize,
20+ pub column: Option<usize>,
2021 pub message: String,
2122 pub severity: Severity,
2223 pub source_line: Option<String>,
@@ -374,6 +375,7 @@ impl Parser {
374375 };
375376 ctx.diagnostics.push(Diagnostic {
376377 line: e.line,
378+ column: None,
377379 message: e.message,
378380 severity: Severity::Error,
379381 source_line: source,
@@ -714,6 +716,7 @@ impl Parser {
714716 if !evaluate_condition(&condition) {
715717 ctx.diagnostics.push(Diagnostic {
716718 line: line_num,
719+ column: None,
717720 message: format!("assertion failed: {}", rest),
718721 severity: Severity::Error,
719722 source_line: Some(content.clone()),
@@ -731,9 +734,11 @@ impl Parser {
731734
732735 // Circular include check
733736 if ctx.include_stack.contains(&resolved) {
737+ let cycle_chain = format_include_chain(&ctx.include_stack);
734738 ctx.diagnostics.push(Diagnostic {
735739 line: line_num,
736− message: format!("circular include '{}'", filename),
740+ column: None,
741+ message: format!("circular include '{}' (cycle: {} → {})", filename, cycle_chain, filename),
737742 severity: Severity::Error,
738743 source_line: Some(content.clone()),
739744 });
@@ -752,6 +757,7 @@ impl Parser {
752757 Err(e) => {
753758 ctx.diagnostics.push(Diagnostic {
754759 line: line_num,
760+ column: None,
755761 message: format!("cannot include '{}': {}", filename, e),
756762 severity: Severity::Error,
757763 source_line: Some(content.clone()),
@@ -830,6 +836,7 @@ impl Parser {
830836 Err(e) => {
831837 ctx.diagnostics.push(Diagnostic {
832838 line: line_num,
839+ column: None,
833840 message: format!("cannot read directory for glob '{}': {}", filename, e),
834841 severity: Severity::Error,
835842 source_line: Some(content.clone()),
@@ -840,6 +847,7 @@ impl Parser {
840847 if matched_files.is_empty() {
841848 ctx.diagnostics.push(Diagnostic {
842849 line: line_num,
850+ column: None,
843851 message: format!("no files matched glob pattern '{}'", filename),
844852 severity: Severity::Warning,
845853 source_line: Some(content.clone()),
@@ -872,9 +880,11 @@ impl Parser {
872880 };
873881
874882 if ctx.include_stack.contains(&resolved) || ctx.import_stack.contains(&resolved) {
883+ let cycle_chain = format_include_chain(&ctx.import_stack);
875884 ctx.diagnostics.push(Diagnostic {
876885 line: line_num,
877− message: format!("circular import '{}'", filename),
886+ column: None,
887+ message: format!("circular import '{}' (cycle: {} → {})", filename, cycle_chain, filename),
878888 severity: Severity::Error,
879889 source_line: Some(content.clone()),
880890 });
@@ -892,6 +902,7 @@ impl Parser {
892902 Err(e) => {
893903 ctx.diagnostics.push(Diagnostic {
894904 line: line_num,
905+ column: None,
895906 message: format!("cannot import '{}': {}", filename, e),
896907 severity: Severity::Error,
897908 source_line: Some(content.clone()),
@@ -1006,6 +1017,7 @@ impl Parser {
10061017 None => {
10071018 ctx.diagnostics.push(Diagnostic {
10081019 line: line_num,
1020+ column: None,
10091021 message: format!("environment variable '{}' is not set and no default provided", var_name),
10101022 severity: Severity::Warning,
10111023 source_line: Some(content.clone()),
@@ -1075,6 +1087,7 @@ impl Parser {
10751087 Err(e) => {
10761088 ctx.diagnostics.push(Diagnostic {
10771089 line: line_num,
1090+ column: None,
10781091 message: format!("@fetch failed for '{}': {}", url, e),
10791092 severity: Severity::Error,
10801093 source_line: Some(content.clone()),
@@ -1149,6 +1162,7 @@ impl Parser {
11491162 Err(e) => {
11501163 ctx.diagnostics.push(Diagnostic {
11511164 line: line_num,
1165+ column: None,
11521166 message: format!("cannot load SVG '{}': {}", filename, e),
11531167 severity: Severity::Error,
11541168 source_line: Some(content.clone()),
@@ -1227,6 +1241,7 @@ impl Parser {
12271241 Err(e) => {
12281242 ctx.diagnostics.push(Diagnostic {
12291243 line: line_num,
1244+ column: None,
12301245 message: format!("cannot load data '{}': {}", filename, e),
12311246 severity: Severity::Error,
12321247 source_line: Some(content.clone()),
@@ -1250,6 +1265,7 @@ impl Parser {
12501265 } else {
12511266 ctx.diagnostics.push(Diagnostic {
12521267 line: line_num,
1268+ column: None,
12531269 message: "@data without prefix requires a JSON object at top level".to_string(),
12541270 severity: Severity::Error,
12551271 source_line: Some(content.clone()),
@@ -1266,6 +1282,7 @@ impl Parser {
12661282 Err(detail) => {
12671283 ctx.diagnostics.push(Diagnostic {
12681284 line: line_num,
1285+ column: None,
12691286 message: format!("invalid JSON in '{}': {}", filename, detail),
12701287 severity: Severity::Error,
12711288 source_line: Some(content.clone()),
@@ -1317,6 +1334,7 @@ impl Parser {
13171334 if ctx.include_stack.contains(&resolved) {
13181335 ctx.diagnostics.push(Diagnostic {
13191336 line: line_num,
1337+ column: None,
13201338 message: format!("circular use '{}'", filename),
13211339 severity: Severity::Error,
13221340 source_line: Some(content.clone()),
@@ -1335,6 +1353,7 @@ impl Parser {
13351353 Err(e) => {
13361354 ctx.diagnostics.push(Diagnostic {
13371355 line: line_num,
1356+ column: None,
13381357 message: format!("cannot use '{}': {}", filename, e),
13391358 severity: Severity::Error,
13401359 source_line: Some(content.clone()),
@@ -2085,6 +2104,7 @@ impl Parser {
20852104 let msg = substitute_vars(rest.trim(), &ctx.variables);
20862105 ctx.diagnostics.push(Diagnostic {
20872106 line: line_num,
2107+ column: None,
20882108 message: msg,
20892109 severity: Severity::Warning,
20902110 source_line: Some(content.clone()),
@@ -2320,6 +2340,7 @@ impl Parser {
23202340 if ctx.include_stack.contains(&resolved) {
23212341 ctx.diagnostics.push(Diagnostic {
23222342 line: line_num,
2343+ column: None,
23232344 message: format!("circular extends '{}'", filename),
23242345 severity: Severity::Error,
23252346 source_line: Some(content.clone()),
@@ -2338,6 +2359,7 @@ impl Parser {
23382359 Err(e) => {
23392360 ctx.diagnostics.push(Diagnostic {
23402361 line: line_num,
2362+ column: None,
23412363 message: format!("cannot extend '{}': {}", filename, e),
23422364 severity: Severity::Error,
23432365 source_line: Some(content.clone()),
@@ -2418,6 +2440,7 @@ impl Parser {
24182440 if ctx.include_stack.contains(&resolved) {
24192441 ctx.diagnostics.push(Diagnostic {
24202442 line: line_num,
2443+ column: None,
24212444 message: format!("circular layout '{}'", filename),
24222445 severity: Severity::Error,
24232446 source_line: Some(content.clone()),
@@ -2436,6 +2459,7 @@ impl Parser {
24362459 Err(e) => {
24372460 ctx.diagnostics.push(Diagnostic {
24382461 line: line_num,
2462+ column: None,
24392463 message: format!("cannot load layout '{}': {}", filename, e),
24402464 severity: Severity::Error,
24412465 source_line: Some(content.clone()),
@@ -2583,6 +2607,7 @@ impl Parser {
25832607 if let Some(msg) = ctx.deprecated_fns.get(name) {
25842608 ctx.diagnostics.push(Diagnostic {
25852609 line: line_num,
2610+ column: None,
25862611 message: format!("@{} is deprecated: {}", name, msg),
25872612 severity: Severity::Warning,
25882613 source_line: Some(content.clone()),
@@ -2603,6 +2628,8 @@ impl Parser {
26032628
26042629 // --- Bare text ---
26052630
2631+ let var_warnings = check_undefined_vars(&content, &ctx.variables, line_num);
2632+ ctx.diagnostics.extend(var_warnings);
26062633 let segments = parse_text_segments(&content, ctx);
26072634 Ok(Some(vec![Node::Text(segments)]))
26082635 }
@@ -2819,9 +2846,26 @@ fn parse_single_element(
28192846 } else {
28202847 without_at[i + 1..].to_string()
28212848 };
2822− (parse_element_kind(kind_str, line_num)?, rest)
2849+ match parse_element_kind(kind_str, line_num) {
2850+ Ok(kind) => (kind, rest),
2851+ Err(mut e) => {
2852+ // Also suggest user-defined functions
2853+ if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) {
2854+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2855+ }
2856+ return Err(e);
2857+ }
2858+ }
28232859 }
2824− None => (parse_element_kind(without_at, line_num)?, String::new()),
2860+ None => match parse_element_kind(without_at, line_num) {
2861+ Ok(kind) => (kind, String::new()),
2862+ Err(mut e) => {
2863+ if let Some(fn_suggestion) = suggest_fn_name(without_at, ctx) {
2864+ e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
2865+ }
2866+ return Err(e);
2867+ }
2868+ },
28252869 }
28262870 } else {
28272871 return Err(ParseError {
@@ -3046,6 +3090,78 @@ fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
30463090 best
30473091}
30483092
3093+/// Suggest the closest user-defined function name for typos.
3094+fn suggest_fn_name(input: &str, ctx: &ParseContext) -> Option<String> {
3095+ let mut best: Option<String> = None;
3096+ let mut best_dist = usize::MAX;
3097+ for name in ctx.functions.keys() {
3098+ let dist = levenshtein(input, name);
3099+ if dist < best_dist && dist <= 2 && dist < input.len() {
3100+ best_dist = dist;
3101+ best = Some(name.clone());
3102+ }
3103+ }
3104+ best
3105+}
3106+
3107+/// Suggest the closest variable name for undefined `$var` references.
3108+fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<String> {
3109+ let mut best: Option<String> = None;
3110+ let mut best_dist = usize::MAX;
3111+ for name in vars.keys() {
3112+ let dist = levenshtein(input, name);
3113+ if dist < best_dist && dist <= 2 && dist < input.len() {
3114+ best_dist = dist;
3115+ best = Some(name.clone());
3116+ }
3117+ }
3118+ best
3119+}
3120+
3121+/// Check for undefined `$var` references and return "did you mean?" diagnostics.
3122+fn check_undefined_vars(input: &str, vars: &HashMap<String, String>, line_num: usize) -> Vec<Diagnostic> {
3123+ let mut warnings = Vec::new();
3124+ if !input.contains('$') {
3125+ return warnings;
3126+ }
3127+ let chars: Vec<char> = input.chars().collect();
3128+ let mut i = 0;
3129+ while i < chars.len() {
3130+ if chars[i] == '$'
3131+ && i + 1 < chars.len()
3132+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
3133+ {
3134+ let col = i;
3135+ let start = i + 1;
3136+ let mut end = start;
3137+ while end < chars.len()
3138+ && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_' || chars[end] == '.')
3139+ {
3140+ end += 1;
3141+ }
3142+ while end > start && chars[end - 1] == '.' {
3143+ end -= 1;
3144+ }
3145+ let name: String = chars[start..end].iter().collect();
3146+ if !name.is_empty() && !vars.contains_key(&name) {
3147+ if let Some(closest) = suggest_var_name(&name, vars) {
3148+ warnings.push(Diagnostic {
3149+ line: line_num,
3150+ column: Some(col),
3151+ message: format!("undefined variable '${}', did you mean '${}'?", name, closest),
3152+ severity: Severity::Warning,
3153+ source_line: Some(input.to_string()),
3154+ });
3155+ }
3156+ }
3157+ i = end;
3158+ } else {
3159+ i += 1;
3160+ }
3161+ }
3162+ warnings
3163+}
3164+
30493165// ---------------------------------------------------------------------------
30503166// Attribute parsing
30513167// ---------------------------------------------------------------------------
@@ -3228,6 +3344,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32283344 if part.parse::<f64>().is_err() && !has_css_unit(part) {
32293345 ctx.diagnostics.push(Diagnostic {
32303346 line: line_num,
3347+ column: None,
32313348 message: format!(
32323349 "'{}' expects a numeric value (with optional unit), got '{}'",
32333350 attr.key, val
@@ -3245,6 +3362,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32453362 if !is_keyword && !is_numeric && !has_unit {
32463363 ctx.diagnostics.push(Diagnostic {
32473364 line: line_num,
3365+ column: None,
32483366 message: format!(
32493367 "'{}' expects a number or one of [{}], got '{}'",
32503368 attr.key,
@@ -3260,6 +3378,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32603378 if !(0.0..=1.0).contains(&v) {
32613379 ctx.diagnostics.push(Diagnostic {
32623380 line: line_num,
3381+ column: None,
32633382 message: format!("'opacity' should be between 0 and 1, got '{}'", val),
32643383 severity: Severity::Warning,
32653384 source_line: None,
@@ -3268,6 +3387,7 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32683387 } else {
32693388 ctx.diagnostics.push(Diagnostic {
32703389 line: line_num,
3390+ column: None,
32713391 message: format!("'opacity' expects a numeric value, got '{}'", val),
32723392 severity: Severity::Warning,
32733393 source_line: None,
@@ -3277,11 +3397,140 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
32773397 if val.parse::<i32>().is_err() {
32783398 ctx.diagnostics.push(Diagnostic {
32793399 line: line_num,
3400+ column: None,
32803401 message: format!("'z-index' expects an integer, got '{}'", val),
32813402 severity: Severity::Warning,
32823403 source_line: None,
32833404 });
32843405 }
3406+ } else if base_key == "display" {
3407+ const DISPLAY_VALUES: &[&str] = &[
3408+ "none", "block", "inline", "inline-block", "flex", "inline-flex",
3409+ "grid", "inline-grid", "table", "table-row", "table-cell",
3410+ "contents", "flow-root", "list-item",
3411+ ];
3412+ if !DISPLAY_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3413+ let suggestion = suggest_closest(val, DISPLAY_VALUES);
3414+ let msg = match suggestion {
3415+ Some(s) => format!("unknown display value '{}', did you mean '{}'?", val, s),
3416+ None => format!("unknown display value '{}'", val),
3417+ };
3418+ ctx.diagnostics.push(Diagnostic {
3419+ line: line_num, column: None, message: msg,
3420+ severity: Severity::Warning, source_line: None,
3421+ });
3422+ }
3423+ } else if base_key == "position" {
3424+ const POSITION_VALUES: &[&str] = &[
3425+ "static", "relative", "absolute", "fixed", "sticky",
3426+ ];
3427+ if !POSITION_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3428+ let suggestion = suggest_closest(val, POSITION_VALUES);
3429+ let msg = match suggestion {
3430+ Some(s) => format!("unknown position value '{}', did you mean '{}'?", val, s),
3431+ None => format!("unknown position value '{}'", val),
3432+ };
3433+ ctx.diagnostics.push(Diagnostic {
3434+ line: line_num, column: None, message: msg,
3435+ severity: Severity::Warning, source_line: None,
3436+ });
3437+ }
3438+ } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" {
3439+ const OVERFLOW_VALUES: &[&str] = &[
3440+ "visible", "hidden", "scroll", "auto", "clip",
3441+ ];
3442+ if !OVERFLOW_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3443+ let suggestion = suggest_closest(val, OVERFLOW_VALUES);
3444+ let msg = match suggestion {
3445+ Some(s) => format!("unknown overflow value '{}', did you mean '{}'?", val, s),
3446+ None => format!("unknown overflow value '{}'", val),
3447+ };
3448+ ctx.diagnostics.push(Diagnostic {
3449+ line: line_num, column: None, message: msg,
3450+ severity: Severity::Warning, source_line: None,
3451+ });
3452+ }
3453+ } else if base_key == "text-align" {
3454+ const TEXT_ALIGN_VALUES: &[&str] = &[
3455+ "left", "right", "center", "justify", "start", "end",
3456+ ];
3457+ if !TEXT_ALIGN_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3458+ let suggestion = suggest_closest(val, TEXT_ALIGN_VALUES);
3459+ let msg = match suggestion {
3460+ Some(s) => format!("unknown text-align value '{}', did you mean '{}'?", val, s),
3461+ None => format!("unknown text-align value '{}'", val),
3462+ };
3463+ ctx.diagnostics.push(Diagnostic {
3464+ line: line_num, column: None, message: msg,
3465+ severity: Severity::Warning, source_line: None,
3466+ });
3467+ }
3468+ } else if base_key == "cursor" {
3469+ const CURSOR_VALUES: &[&str] = &[
3470+ "auto", "default", "none", "pointer", "wait", "text", "move",
3471+ "not-allowed", "crosshair", "grab", "grabbing", "help", "progress",
3472+ "col-resize", "row-resize", "n-resize", "s-resize", "e-resize", "w-resize",
3473+ "zoom-in", "zoom-out", "context-menu", "cell", "copy", "alias", "no-drop",
3474+ ];
3475+ if !CURSOR_VALUES.contains(&val.as_str()) && !val.starts_with("url(") && !val.starts_with("var(") {
3476+ let suggestion = suggest_closest(val, CURSOR_VALUES);
3477+ let msg = match suggestion {
3478+ Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s),
3479+ None => format!("unknown cursor value '{}'", val),
3480+ };
3481+ ctx.diagnostics.push(Diagnostic {
3482+ line: line_num, column: None, message: msg,
3483+ severity: Severity::Warning, source_line: None,
3484+ });
3485+ }
3486+ } else if base_key == "font-weight" {
3487+ const WEIGHT_VALUES: &[&str] = &[
3488+ "normal", "bold", "bolder", "lighter",
3489+ "100", "200", "300", "400", "500", "600", "700", "800", "900",
3490+ ];
3491+ if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
3492+ ctx.diagnostics.push(Diagnostic {
3493+ line: line_num, column: None,
3494+ message: format!("'font-weight' expects a weight keyword or number 100-900, got '{}'", val),
3495+ severity: Severity::Warning, source_line: None,
3496+ });
3497+ }
3498+ } else if base_key == "color" || base_key == "background" {
3499+ // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.)
3500+ if !val.starts_with('#') && !val.starts_with("rgb") && !val.starts_with("hsl")
3501+ && !val.starts_with("var(") && !val.starts_with("linear-gradient")
3502+ && !val.starts_with("radial-gradient") && !val.starts_with("conic-gradient")
3503+ && !val.starts_with("oklch") && !val.starts_with("oklab")
3504+ && !val.starts_with("color(") && !val.starts_with("light-dark(")
3505+ && !val.contains("url(")
3506+ && !val.contains(' ') // skip shorthand multi-value
3507+ {
3508+ const NAMED_COLORS: &[&str] = &[
3509+ "transparent", "currentcolor", "inherit", "initial", "unset",
3510+ "black", "white", "red", "green", "blue", "yellow", "orange", "purple",
3511+ "pink", "brown", "gray", "grey", "cyan", "magenta", "lime", "olive",
3512+ "navy", "teal", "aqua", "fuchsia", "maroon", "silver",
3513+ "coral", "salmon", "tomato", "crimson", "firebrick", "darkred",
3514+ "indigo", "violet", "plum", "orchid", "thistle", "lavender",
3515+ "gold", "khaki", "wheat", "tan", "sienna", "chocolate", "peru",
3516+ "beige", "ivory", "linen", "snow", "seashell", "mintcream",
3517+ "skyblue", "steelblue", "royalblue", "dodgerblue", "cornflowerblue",
3518+ "slategray", "slategrey", "dimgray", "dimgrey", "lightgray", "lightgrey",
3519+ "darkgray", "darkgrey", "gainsboro", "whitesmoke",
3520+ ];
3521+ if !NAMED_COLORS.contains(&val.to_lowercase().as_str()) {
3522+ let lower = val.to_lowercase();
3523+ let suggestion = suggest_closest(&lower, NAMED_COLORS);
3524+ let msg = match suggestion {
3525+ Some(s) => format!("unknown color '{}', did you mean '{}'?", val, s),
3526+ None => format!("unknown color '{}'", val),
3527+ };
3528+ ctx.diagnostics.push(Diagnostic {
3529+ line: line_num, column: None, message: msg,
3530+ severity: Severity::Warning, source_line: None,
3531+ });
3532+ }
3533+ }
32853534 }
32863535 }
32873536}
@@ -3447,6 +3696,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
34473696 if seen_keys.contains(&stripped) {
34483697 ctx.diagnostics.push(Diagnostic {
34493698 line: line_num,
3699+ column: None,
34503700 message: format!("duplicate attribute '{}'", attr.key),
34513701 severity: Severity::Warning,
34523702 source_line: None,
@@ -3461,6 +3711,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
34613711 if val.starts_with('#') && !is_valid_hex_color(val) {
34623712 ctx.diagnostics.push(Diagnostic {
34633713 line: line_num,
3714+ column: None,
34643715 message: format!("invalid hex color '{}'", val),
34653716 severity: Severity::Warning,
34663717 source_line: None,
@@ -3486,6 +3737,7 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
34863737 };
34873738 ctx.diagnostics.push(Diagnostic {
34883739 line: line_num,
3740+ column: None,
34893741 message: msg,
34903742 severity: Severity::Warning,
34913743 source_line: None,
@@ -3911,6 +4163,7 @@ fn validate_tree(
39114163 if !matches!(parent_kind, Some(ElementKind::Row)) {
39124164 diagnostics.push(Diagnostic {
39134165 line: elem.line_num,
4166+ column: None,
39144167 message: "'width fill' works best inside @row; using 100% as fallback"
39154168 .to_string(),
39164169 severity: Severity::Warning,
@@ -3922,6 +4175,7 @@ fn validate_tree(
39224175 if !matches!(parent_kind, Some(ElementKind::Column)) {
39234176 diagnostics.push(Diagnostic {
39244177 line: elem.line_num,
4178+ column: None,
39254179 message:
39264180 "'height fill' works best inside @column; using 100% as fallback"
39274181 .to_string(),
@@ -3935,6 +4189,7 @@ fn validate_tree(
39354189 if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
39364190 diagnostics.push(Diagnostic {
39374191 line: elem.line_num,
4192+ column: None,
39384193 message: format!(
39394194 "'{}' has no effect on {} (only works on @row, @column, @el)",
39404195 base, element_kind_name(&elem.kind)
@@ -3950,6 +4205,7 @@ fn validate_tree(
39504205 {
39514206 diagnostics.push(Diagnostic {
39524207 line: elem.line_num,
4208+ column: None,
39534209 message: format!(
39544210 "'placeholder' has no effect on {} (only works on @input, @textarea)",
39554211 element_kind_name(&elem.kind)
@@ -3963,6 +4219,7 @@ fn validate_tree(
39634219 if base == "for" && !matches!(elem.kind, ElementKind::Label) {
39644220 diagnostics.push(Diagnostic {
39654221 line: elem.line_num,
4222+ column: None,
39664223 message: format!(
39674224 "'for' has no effect on {} (only works on @label)",
39684225 element_kind_name(&elem.kind)
@@ -3978,6 +4235,7 @@ fn validate_tree(
39784235 {
39794236 diagnostics.push(Diagnostic {
39804237 line: elem.line_num,
4238+ column: None,
39814239 message: format!(
39824240 "'{}' has no effect on {} (only works on @textarea)",
39834241 base, element_kind_name(&elem.kind)
@@ -3991,6 +4249,7 @@ fn validate_tree(
39914249 if base == "ordered" && !matches!(elem.kind, ElementKind::List) {
39924250 diagnostics.push(Diagnostic {
39934251 line: elem.line_num,
4252+ column: None,
39944253 message: format!(
39954254 "'ordered' has no effect on {} (only works on @list)",
39964255 element_kind_name(&elem.kind)
@@ -4006,6 +4265,7 @@ fn validate_tree(
40064265 {
40074266 diagnostics.push(Diagnostic {
40084267 line: elem.line_num,
4268+ column: None,
40094269 message: format!(
40104270 "'{}' has no effect on {} (only works on @video, @audio)",
40114271 base, element_kind_name(&elem.kind)
@@ -4020,6 +4280,7 @@ fn validate_tree(
40204280 if !elem.attrs.iter().any(|a| a.key == "alt") {
40214281 diagnostics.push(Diagnostic {
40224282 line: elem.line_num,
4283+ column: None,
40234284 message: "@image missing 'alt' attribute (accessibility)".to_string(),
40244285 severity: Severity::Warning,
40254286 source_line: None,
@@ -4030,6 +4291,7 @@ fn validate_tree(
40304291 if !elem.attrs.iter().any(|a| a.key == "type") {
40314292 diagnostics.push(Diagnostic {
40324293 line: elem.line_num,
4294+ column: None,
40334295 message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
40344296 severity: Severity::Warning,
40354297 source_line: None,
@@ -4042,6 +4304,7 @@ fn validate_tree(
40424304 if !has_text && !elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "title") {
40434305 diagnostics.push(Diagnostic {
40444306 line: elem.line_num,
4307+ column: None,
40454308 message: "@link has no visible text or aria-label (accessibility)".to_string(),
40464309 severity: Severity::Warning,
40474310 source_line: None,
@@ -4063,6 +4326,7 @@ fn validate_tree(
40634326 if ratio < 4.5 {
40644327 diagnostics.push(Diagnostic {
40654328 line: elem.line_num,
4329+ column: None,
40664330 message: format!(
40674331 "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
40684332 ratio, fg, bg
@@ -4084,6 +4348,7 @@ fn validate_tree(
40844348 if !has_id && !has_aria_label && !has_title && !in_label {
40854349 diagnostics.push(Diagnostic {
40864350 line: elem.line_num,
4351+ column: None,
40874352 message: format!(
40884353 "{} should have an 'id' (with matching @label[for]), 'aria-label', or be wrapped in @label (accessibility)",
40894354 element_kind_name(&elem.kind)
@@ -4099,6 +4364,7 @@ fn validate_tree(
40994364 if !elem.attrs.iter().any(|a| a.key == "title") {
41004365 diagnostics.push(Diagnostic {
41014366 line: elem.line_num,
4367+ column: None,
41024368 message: "@iframe missing 'title' attribute (accessibility)".to_string(),
41034369 severity: Severity::Warning,
41044370 source_line: None,
@@ -4113,6 +4379,7 @@ fn validate_tree(
41134379 if !has_text && !has_aria {
41144380 diagnostics.push(Diagnostic {
41154381 line: elem.line_num,
4382+ column: None,
41164383 message: "@button has no text content or aria-label (accessibility)".to_string(),
41174384 severity: Severity::Warning,
41184385 source_line: None,
@@ -4129,6 +4396,7 @@ fn validate_tree(
41294396 if !has_aria && !has_track {
41304397 diagnostics.push(Diagnostic {
41314398 line: elem.line_num,
4399+ column: None,
41324400 message: "@video should have aria-label or captions for accessibility".to_string(),
41334401 severity: Severity::Warning,
41344402 source_line: None,
@@ -4143,6 +4411,7 @@ fn validate_tree(
41434411 if n > 0 {
41444412 diagnostics.push(Diagnostic {
41454413 line: elem.line_num,
4414+ column: None,
41464415 message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
41474416 severity: Severity::Warning,
41484417 source_line: None,
@@ -4480,6 +4749,7 @@ fn check_unused(ctx: &mut ParseContext) {
44804749 if !ctx.used_variables.contains(name) {
44814750 ctx.diagnostics.push(Diagnostic {
44824751 line,
4752+ column: None,
44834753 message: format!("unused variable '${}' (defined but never referenced)", name),
44844754 severity: Severity::Warning,
44854755 source_line: None,
@@ -4492,6 +4762,7 @@ fn check_unused(ctx: &mut ParseContext) {
44924762 if !ctx.used_defines.contains(name) {
44934763 ctx.diagnostics.push(Diagnostic {
44944764 line,
4765+ column: None,
44954766 message: format!("unused define '${}' (defined but never referenced)", name),
44964767 severity: Severity::Warning,
44974768 source_line: None,
@@ -4504,6 +4775,7 @@ fn check_unused(ctx: &mut ParseContext) {
45044775 if !ctx.used_functions.contains(name) {
45054776 ctx.diagnostics.push(Diagnostic {
45064777 line,
4778+ column: None,
45074779 message: format!("unused function '@{}' (defined but never called)", name),
45084780 severity: Severity::Warning,
45094781 source_line: None,
@@ -4516,6 +4788,7 @@ fn check_unused(ctx: &mut ParseContext) {
45164788 if !ctx.used_mixins.contains(name) {
45174789 ctx.diagnostics.push(Diagnostic {
45184790 line,
4791+ column: None,
45194792 message: format!("unused mixin '{}' (defined but never referenced)", name),
45204793 severity: Severity::Warning,
45214794 source_line: None,