Skip to content

Commit 3f3cfe6

Browse files
Linter: Implement html-require-closing-tags rule (#1199)
Co-authored-by: Marco Roth <marco.roth@intergga.ch>
1 parent d807f21 commit 3f3cfe6

File tree

12 files changed

+570
-23
lines changed

12 files changed

+570
-23
lines changed
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# Linter Rule: Require explicit closing tags
2+
3+
**Rule:** `html-require-closing-tags`
4+
5+
## Description
6+
7+
Disallow the omission of optional closing tags for HTML elements where the closing tag is technically optional according to the HTML specification. This rule flags elements that have an `HTMLOmittedCloseTagNode` as their close tag.
8+
9+
## Rationale
10+
11+
While HTML allows certain closing tags to be omitted (implicitly closed by sibling elements or parent closing), explicit closing tags improve code readability and maintainability. They make the document structure clear at a glance and reduce ambiguity about where elements end.
12+
13+
Explicit closing tags also:
14+
15+
- Make templates easier to understand for developers unfamiliar with HTML's implicit closing rules
16+
- Reduce potential for subtle bugs when refactoring or moving code
17+
- Improve consistency across the codebase
18+
- Make diffs cleaner when adding content to elements
19+
20+
## Elements with Optional Closing Tags
21+
22+
This rule would flag elements that have omitted closing tags:
23+
24+
- `<li>` - list items
25+
- `<dt>`, `<dd>` - definition list terms and descriptions
26+
- `<p>` - paragraphs
27+
- `<option>`, `<optgroup>` - select options and groups
28+
- `<thead>`, `<tbody>`, `<tfoot>` - table sections
29+
- `<tr>` - table rows
30+
- `<td>`, `<th>` - table cells
31+
- `<colgroup>` - table column groups
32+
- `<rt>`, `<rp>` - ruby annotations
33+
34+
## Examples
35+
36+
### ✅ Good
37+
38+
Explicit closing tags:
39+
40+
```erb
41+
<ul>
42+
<li>Item 1</li>
43+
<li>Item 2</li>
44+
<li>Item 3</li>
45+
</ul>
46+
```
47+
48+
```erb
49+
<dl>
50+
<dt>Term 1</dt>
51+
<dd>Definition 1</dd>
52+
<dt>Term 2</dt>
53+
<dd>Definition 2</dd>
54+
</dl>
55+
```
56+
57+
```erb
58+
<table>
59+
<thead>
60+
<tr>
61+
<th>Header 1</th>
62+
<th>Header 2</th>
63+
</tr>
64+
</thead>
65+
<tbody>
66+
<tr>
67+
<td>Cell 1</td>
68+
<td>Cell 2</td>
69+
</tr>
70+
</tbody>
71+
</table>
72+
```
73+
74+
```erb
75+
<select>
76+
<option>Option 1</option>
77+
<option>Option 2</option>
78+
<option>Option 3</option>
79+
</select>
80+
```
81+
82+
```erb
83+
<div>
84+
<p>Paragraph 1</p>
85+
<p>Paragraph 2</p>
86+
</div>
87+
```
88+
89+
### 🚫 Bad
90+
91+
Omitted closing tags (implicitly closed):
92+
93+
```erb
94+
<ul>
95+
<li>Item 1
96+
<li>Item 2
97+
<li>Item 3
98+
</ul>
99+
```
100+
101+
```erb
102+
<dl>
103+
<dt>Term 1
104+
<dd>Definition 1
105+
<dt>Term 2
106+
<dd>Definition 2
107+
</dl>
108+
```
109+
110+
```erb
111+
<table>
112+
<thead>
113+
<tr>
114+
<th>Header 1
115+
<th>Header 2
116+
<tbody>
117+
<tr>
118+
<td>Cell 1
119+
<td>Cell 2
120+
</table>
121+
```
122+
123+
```erb
124+
<select>
125+
<option>Option 1
126+
<option>Option 2
127+
<option>Option 3
128+
</select>
129+
```
130+
131+
```erb
132+
<div>
133+
<p>Paragraph 1
134+
<p>Paragraph 2
135+
</div>
136+
```
137+
138+
## References
139+
140+
- [HTML Spec: Optional Tags](https://html.spec.whatwg.org/multipage/syntax.html#optional-tags)
141+
- [HTML Spec: Tag Omission](https://html.spec.whatwg.org/multipage/dom.html#concept-element-tag-omission)
142+
- [CSS-Tricks: Fighting the Space Between Inline Block Elements](https://css-tricks.com/fighting-the-space-between-inline-block-elements/)

javascript/packages/linter/src/linter.ts

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
1+
import picomatch from "picomatch"
2+
13
import { Location } from "@herb-tools/core"
24
import { IdentityPrinter } from "@herb-tools/printer"
3-
import picomatch from "picomatch"
45

56
import { rules } from "./rules.js"
67
import { findNodeByLocation } from "./rules/rule-utils.js"
78
import { parseHerbDisableLine } from "./herb-disable-comment-utils.js"
89
import { hasLinterIgnoreDirective } from "./linter-ignore.js"
10+
import { ParseCache } from "./parse-cache.js"
911

1012
import { ParserNoErrorsRule } from "./rules/parser-no-errors.js"
13+
1114
import { DEFAULT_RULE_CONFIG } from "./types.js"
1215

1316
import type { RuleClass, Rule, ParserRule, LexerRule, SourceRule, LintResult, LintOffense, UnboundLintOffense, LintContext, AutofixResult } from "./types.js"
@@ -48,6 +51,7 @@ export class Linter {
4851
protected rules: RuleClass[]
4952
protected allAvailableRules: RuleClass[]
5053
protected herb: HerbBackend
54+
protected parseCache: ParseCache
5155
protected offenses: LintOffense[]
5256
protected config?: Config
5357

@@ -81,6 +85,7 @@ export class Linter {
8185
*/
8286
constructor(herb: HerbBackend, rules?: RuleClass[], config?: Config, allAvailableRules?: RuleClass[]) {
8387
this.herb = herb
88+
this.parseCache = new ParseCache(herb)
8489
this.config = config
8590
this.rules = rules !== undefined ? rules : this.getDefaultRules()
8691
this.allAvailableRules = allAvailableRules !== undefined ? allAvailableRules : this.rules
@@ -170,6 +175,13 @@ export class Linter {
170175
return (rule.constructor as any).type === "source"
171176
}
172177

178+
/**
179+
* Type guard to check if a rule is a ParserRule
180+
*/
181+
protected isParserRule(rule: Rule): rule is ParserRule {
182+
return (rule.constructor as any).type === "parser"
183+
}
184+
173185
/**
174186
* Execute a single rule and return its unbound offenses.
175187
* Handles rule type checking (Lexer/Parser/Source) and isEnabled checks.
@@ -303,7 +315,7 @@ export class Linter {
303315
let ignoredCount = 0
304316
let wouldBeIgnoredCount = 0
305317

306-
const parseResult = this.herb.parse(source, { track_whitespace: true })
318+
const parseResult = this.parseCache.get(source)
307319

308320
// Check for file-level ignore directive using visitor
309321
if (hasLinterIgnoreDirective(parseResult)) {
@@ -330,15 +342,6 @@ export class Linter {
330342
const offenses = rule.check(parseResult)
331343
this.offenses.push(...offenses)
332344
}
333-
334-
return {
335-
offenses: this.offenses,
336-
errors: this.offenses.filter(o => o.severity === "error").length,
337-
warnings: this.offenses.filter(o => o.severity === "warning").length,
338-
info: this.offenses.filter(o => o.severity === "info").length,
339-
hints: this.offenses.filter(o => o.severity === "hint").length,
340-
ignored: 0
341-
}
342345
}
343346

344347
for (let i = 0; i < sourceLines.length; i++) {
@@ -364,6 +367,17 @@ export class Linter {
364367

365368
for (const RuleClass of regularRules) {
366369
const rule = new RuleClass()
370+
const parserOptions = this.isParserRule(rule) ? rule.parserOptions : {}
371+
const parseResult = this.parseCache.get(source, parserOptions)
372+
373+
// Skip parser rules whose parse result has errors (parser-no-errors handled above)
374+
// Skip lexer/source rules when the default parse has errors
375+
if (this.isParserRule(rule)) {
376+
if (parseResult.recursiveErrors().length > 0) continue
377+
} else if (hasParserErrors) {
378+
continue
379+
}
380+
367381
const unboundOffenses = this.executeRule(rule, parseResult, lexResult, source, context)
368382
const boundOffenses = this.bindSeverity(unboundOffenses, rule.name)
369383

@@ -388,6 +402,7 @@ export class Linter {
388402

389403
if (unnecessaryRuleClass) {
390404
const unnecessaryRule = new unnecessaryRuleClass() as ParserRule
405+
const parseResult = this.parseCache.get(source, unnecessaryRule.parserOptions)
391406
const unboundOffenses = unnecessaryRule.check(parseResult, context)
392407
const boundOffenses = this.bindSeverity(unboundOffenses, unnecessaryRule.name)
393408

@@ -494,7 +509,7 @@ export class Linter {
494509
const unfixed: LintOffense[] = []
495510

496511
if (parserOffenses.length > 0) {
497-
const parseResult = this.herb.parse(currentSource, { track_whitespace: true })
512+
const parseResult = this.parseCache.get(currentSource)
498513

499514
for (const offense of parserOffenses) {
500515
const RuleClass = this.rules.find(rule => new rule().name === offense.rule)
@@ -602,4 +617,3 @@ export class Linter {
602617
}
603618
}
604619
}
605-
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { DEFAULT_PARSER_OPTIONS } from "@herb-tools/core"
2+
import { DEFAULT_LINTER_PARSER_OPTIONS } from "./types.js"
3+
4+
import type { ParseResult, HerbBackend, ParserOptions } from "@herb-tools/core"
5+
6+
export class ParseCache {
7+
private herb: HerbBackend
8+
private cache = new Map<string, ParseResult>()
9+
10+
constructor(herb: HerbBackend) {
11+
this.herb = herb
12+
}
13+
14+
get(source: string, parserOptions: Partial<ParserOptions> = {}): ParseResult {
15+
const effectiveOptions = this.resolveOptions(parserOptions)
16+
const key = source + JSON.stringify(effectiveOptions)
17+
18+
let result = this.cache.get(key)
19+
20+
if (!result) {
21+
result = this.herb.parse(source, effectiveOptions)
22+
this.cache.set(key, result)
23+
}
24+
25+
return result
26+
}
27+
28+
clear(): void {
29+
this.cache.clear()
30+
}
31+
32+
resolveOptions(parserOptions: Partial<ParserOptions>): ParserOptions {
33+
return {
34+
...DEFAULT_PARSER_OPTIONS,
35+
...DEFAULT_LINTER_PARSER_OPTIONS,
36+
...parserOptions
37+
}
38+
}
39+
}

javascript/packages/linter/src/rules.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import { HTMLNoSelfClosingRule } from "./rules/html-no-self-closing.js"
5757
import { HTMLNoSpaceInTagRule } from "./rules/html-no-space-in-tag.js"
5858
import { HTMLNoTitleAttributeRule } from "./rules/html-no-title-attribute.js"
5959
import { HTMLNoUnderscoresInAttributeNamesRule } from "./rules/html-no-underscores-in-attribute-names.js"
60+
import { HTMLRequireClosingTagsRule } from "./rules/html-require-closing-tags.js"
6061
import { HTMLTagNameLowercaseRule } from "./rules/html-tag-name-lowercase.js"
6162

6263
import { SVGTagNameCapitalizationRule } from "./rules/svg-tag-name-capitalization.js"
@@ -121,6 +122,7 @@ export const rules: RuleClass[] = [
121122
HTMLNoSpaceInTagRule,
122123
HTMLNoTitleAttributeRule,
123124
HTMLNoUnderscoresInAttributeNamesRule,
125+
HTMLRequireClosingTagsRule,
124126
HTMLTagNameLowercaseRule,
125127

126128
SVGTagNameCapitalizationRule,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ParserRule } from "../types.js"
2+
import { BaseRuleVisitor } from "./rule-utils.js"
3+
4+
import type { UnboundLintOffense, LintContext, FullRuleConfig } from "../types.js"
5+
import type { HTMLOmittedCloseTagNode, ParseResult, ParserOptions } from "@herb-tools/core"
6+
7+
class RequireClosingTagsVisitor extends BaseRuleVisitor {
8+
visitHTMLOmittedCloseTagNode(node: HTMLOmittedCloseTagNode): void {
9+
const tagName = node.tag_name?.value
10+
if (!tagName) return
11+
12+
this.addOffense(
13+
`Missing explicit closing tag for \`<${tagName}>\`. Use \`</${tagName}>\` instead of relying on implicit tag closing.`,
14+
node.location
15+
)
16+
}
17+
}
18+
19+
export class HTMLRequireClosingTagsRule extends ParserRule {
20+
static autocorrectable = false
21+
name = "html-require-closing-tags"
22+
23+
get defaultConfig(): FullRuleConfig {
24+
return {
25+
enabled: true,
26+
severity: "error",
27+
}
28+
}
29+
30+
get parserOptions(): Partial<ParserOptions> {
31+
return { strict: false }
32+
}
33+
34+
check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense[] {
35+
const visitor = new RequireClosingTagsVisitor(this.name, context)
36+
37+
visitor.visit(result.value)
38+
39+
return visitor.offenses
40+
}
41+
}

javascript/packages/linter/src/rules/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export * from "./html-no-self-closing.js"
5858
export * from "./html-no-space-in-tag.js"
5959
export * from "./html-no-title-attribute.js"
6060
export * from "./html-no-underscores-in-attribute-names.js"
61+
export * from "./html-require-closing-tags.js"
6162
export * from "./html-tag-name-lowercase.js"
6263

6364
export * from "./svg-tag-name-capitalization.js"

javascript/packages/linter/src/types.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { Diagnostic, LexResult, ParseResult } from "@herb-tools/core"
22

33
import type { rules } from "./rules.js"
4-
import type { Node } from "@herb-tools/core"
4+
import type { Node, ParserOptions } from "@herb-tools/core"
55
import type { RuleConfig } from "@herb-tools/config"
66
import type { Mutable } from "@herb-tools/rewriter"
77

88
export type { Mutable } from "@herb-tools/rewriter"
99

1010
export type LintSeverity = "error" | "warning" | "info" | "hint"
1111

12+
export const DEFAULT_LINTER_PARSER_OPTIONS: Partial<ParserOptions> = {
13+
track_whitespace: true,
14+
}
15+
1216
export type FullRuleConfig = Required<Pick<RuleConfig, 'enabled' | 'severity'>> & Omit<RuleConfig, 'enabled' | 'severity'>
1317

1418
/**
@@ -92,6 +96,11 @@ export abstract class ParserRule<TAutofixContext extends BaseAutofixContext = Ba
9296
get defaultConfig(): FullRuleConfig {
9397
return DEFAULT_RULE_CONFIG
9498
}
99+
100+
get parserOptions(): Partial<ParserOptions> {
101+
return DEFAULT_LINTER_PARSER_OPTIONS
102+
}
103+
95104
abstract check(result: ParseResult, context?: Partial<LintContext>): UnboundLintOffense<TAutofixContext>[]
96105

97106
/**

0 commit comments

Comments
 (0)