on master help sign in
Merge htmlang lint with the compiler's own checks

The lint-only rules (deep nesting, empty layout containers, buttons without a type) move into htmlang-core as parser::lint(), returning ordinary diagnostics. htmlang lint reports the compile diagnostics plus those; the three checks it duplicated (image alt, input type, link text) were reported twice and now appear once.

commit01c4421d32f83ed15e6764ad2dbeed90ed14880b authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:20 (2 weeks ago) parent620e060
3 files changed+62−90
crates/htmlang-core/src/parser.rs+39 src/main.rs+7−90 tests/regressions.rs+16
crates/htmlang-core/src/parser.rs+39
@@ -3448,6 +3448,45 @@ fn is_container(kind: &ElementKind) -> bool {
34483448 || kind.spec().is_some_and(|spec| spec.container)
34493449}
34503450
3451+/// Stricter checks for `htmlang lint`, on top of the diagnostics every
3452+/// compile reports: deep nesting, empty layout containers, and buttons
3453+/// without an explicit `type`.
3454+pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
3455+ fn walk(nodes: &[Node], depth: usize, out: &mut Vec<Diagnostic>) {
3456+ for node in nodes {
3457+ let Node::Element(elem) = node else { continue };
3458+ let mut warn = |message: String| {
3459+ out.push(Diagnostic {
3460+ line: elem.line_num,
3461+ column: None,
3462+ message,
3463+ severity: Severity::Warning,
3464+ source_line: None,
3465+ })
3466+ };
3467+ if depth > 10 {
3468+ warn(format!(
3469+ "deeply nested element ({} levels): consider simplifying",
3470+ depth
3471+ ));
3472+ }
3473+ if matches!(elem.kind, ElementKind::Row | ElementKind::Column | ElementKind::El)
3474+ && elem.children.is_empty()
3475+ {
3476+ warn(format!("empty container (@{}) has no children", elem.kind.name()));
3477+ }
3478+ if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
3479+ warn("@button missing 'type' attribute (defaults to submit)".to_string());
3480+ }
3481+ walk(&elem.children, depth + 1, out);
3482+ }
3483+ }
3484+ let mut out = Vec::new();
3485+ walk(nodes, 0, &mut out);
3486+ out
3487+}
3488+
3489+
34513490fn validate_tree(
34523491 nodes: &[Node],
34533492 parent_kind: Option<&ElementKind>,
src/main.rs+7−90
@@ -344,96 +344,13 @@ fn lint_file(path: &str) -> Vec<String> {
344344 };
345345 let base = Path::new(path).parent();
346346 let result = htmlang::parser::parse_with_base(&input, base);
347− let mut warnings = Vec::new();
348−
349− // Report parser diagnostics
350− for d in &result.diagnostics {
351− let prefix = severity_label(d.severity);
352− warnings.push(format!("{}:{}:{}: {}", path, d.line, prefix, d.message));
353− }
354−
355− // Additional lint checks on the AST
356− lint_nodes(&result.document.nodes, path, 0, &mut warnings);
357− warnings
358−}
359−
360−fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings: &mut Vec<String>) {
361− for node in nodes {
362− if let htmlang::ast::Node::Element(elem) = node {
363− // Deeply nested elements (>10 levels)
364− if depth > 10 {
365− warnings.push(format!(
366− "{}:{}:lint: deeply nested element ({} levels) — consider simplifying",
367− path, elem.line_num, depth
368− ));
369− }
370−
371− // @image without alt
372− if elem.kind == htmlang::ast::ElementKind::Image
373− && !elem.attrs.iter().any(|a| a.key == "alt")
374− {
375− warnings.push(format!(
376− "{}:{}:lint: @image missing 'alt' attribute (accessibility)",
377− path, elem.line_num
378− ));
379− }
380−
381− // @link without content or aria-label
382− if elem.kind == htmlang::ast::ElementKind::Link {
383− let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label");
384− let has_children = !elem.children.is_empty();
385− let has_arg_text = elem.argument.as_ref().is_some_and(|_| false);
386− if !has_aria && !has_children && !has_arg_text {
387− warnings.push(format!(
388− "{}:{}:lint: @link has no visible text or aria-label (accessibility)",
389− path, elem.line_num
390− ));
391− }
392− }
393−
394− // @input without type
395− if elem.kind.is_tag("input")
396− && !elem.attrs.iter().any(|a| a.key == "type")
397− {
398− warnings.push(format!(
399− "{}:{}:lint: @input missing 'type' attribute",
400− path, elem.line_num
401− ));
402− }
403−
404− // Empty containers (no children, no text)
405− if matches!(
406− elem.kind,
407− htmlang::ast::ElementKind::Row
408− | htmlang::ast::ElementKind::Column
409− | htmlang::ast::ElementKind::El
410− ) && elem.children.is_empty()
411− {
412− warnings.push(format!(
413− "{}:{}:lint: empty container (@{}) has no children",
414− path,
415− elem.line_num,
416− match elem.kind {
417− htmlang::ast::ElementKind::Row => "row",
418− htmlang::ast::ElementKind::Column => "column",
419− _ => "el",
420− }
421− ));
422− }
423−
424− // @button without type
425− if elem.kind.is_tag("button")
426− && !elem.attrs.iter().any(|a| a.key == "type")
427− {
428− warnings.push(format!(
429− "{}:{}:lint: @button missing 'type' attribute (defaults to submit)",
430− path, elem.line_num
431− ));
432− }
433−
434− lint_nodes(&elem.children, path, depth + 1, warnings);
435− }
436− }
347+ let lint = htmlang::parser::lint(&result.document.nodes);
348+ result
349+ .diagnostics
350+ .iter()
351+ .chain(&lint)
352+ .map(|d| format!("{}:{}:{}: {}", path, d.line, severity_label(d.severity), d.message))
353+ .collect()
437354}
438355
439356/// Whether the build cache record at `path` matches `key` and every
tests/regressions.rs+16
@@ -385,3 +385,19 @@ fn data_glob_loads_each_file_under_its_stem() {
385385 assert!(out.contains("2: First, Second"), "{}", out);
386386 assert!(out.contains(">a<") && out.contains(">b<"), "{}", out);
387387}
388+
389+#[test]
390+fn lint_adds_only_checks_the_compiler_does_not_already_make() {
391+ let result = parser::parse("@row\n@button Go\n@image a.png");
392+ let lint = parser::lint(&result.document.nodes);
393+ let messages: Vec<&str> = result
394+ .diagnostics
395+ .iter()
396+ .chain(&lint)
397+ .map(|d| d.message.as_str())
398+ .collect();
399+ // Each problem is reported once, whichever pass finds it.
400+ assert_eq!(messages.iter().filter(|m| m.contains("'alt'")).count(), 1, "{:?}", messages);
401+ assert!(messages.iter().any(|m| m.contains("empty container")), "{:?}", messages);
402+ assert!(messages.iter().any(|m| m.contains("'type'")), "{:?}", messages);
403+}