Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 188 additions & 14 deletions javascript/packages/linter/src/rules/html-no-duplicate-ids.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,203 @@
import { ParserRule } from "../types"
import { AttributeVisitorMixin, StaticAttributeStaticValueParams } from "./rule-utils"
import { ControlFlowTrackingVisitor, ControlFlowType } from "./rule-utils"
import { LiteralNode } from "@herb-tools/core"
import { Printer, IdentityPrinter } from "@herb-tools/printer"

import type { ParseResult } from "@herb-tools/core"
import { hasERBOutput, getValidatableStaticContent, isEffectivelyStatic, isNode, getStaticAttributeName, isERBOutputNode } from "@herb-tools/core"

import type { ParseResult, HTMLAttributeNode, ERBContentNode } from "@herb-tools/core"
import type { LintOffense, LintContext } from "../types"

class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
interface ControlFlowState {
previousBranchIds: Set<string>
previousControlFlowIds: Set<string>
}

interface BranchState {
previousBranchIds: Set<string>
}

class OutputPrinter extends Printer {
visitLiteralNode(node: LiteralNode) {
this.write(IdentityPrinter.print(node))
}

visitERBContentNode(node: ERBContentNode) {
if (isERBOutputNode(node)) {
this.write(IdentityPrinter.print(node))
}
}
}

class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor<ControlFlowState, BranchState> {
private documentIds: Set<string> = new Set<string>()
private currentBranchIds: Set<string> = new Set<string>()
private controlFlowIds: Set<string> = new Set<string>()

visitHTMLAttributeNode(node: HTMLAttributeNode): void {
this.checkAttribute(node)
}

protected onEnterControlFlow(_controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): ControlFlowState {
const stateToRestore: ControlFlowState = {
previousBranchIds: this.currentBranchIds,
previousControlFlowIds: this.controlFlowIds
}

protected checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }: StaticAttributeStaticValueParams): void {
if (attributeName.toLowerCase() !== "id") return
if (!attributeValue) return
this.currentBranchIds = new Set<string>()

const id = attributeValue.trim()
if (!wasAlreadyInControlFlow) {
this.controlFlowIds = new Set<string>()
}

return stateToRestore
}

protected onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: ControlFlowState): void {
if (controlFlowType === ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
this.controlFlowIds.forEach(id => this.documentIds.add(id))
}

if (this.documentIds.has(id)) {
this.addOffense(
`Duplicate ID \`${id}\` found. IDs must be unique within a document.`,
attributeNode.location,
"error"
)
this.currentBranchIds = stateToRestore.previousBranchIds
this.controlFlowIds = stateToRestore.previousControlFlowIds
}

protected onEnterBranch(): BranchState {
const stateToRestore: BranchState = {
previousBranchIds: this.currentBranchIds
}

if (this.isInControlFlow) {
this.currentBranchIds = new Set<string>()
}

return stateToRestore
}

protected onExitBranch(_stateToRestore: BranchState): void {}

private checkAttribute(attributeNode: HTMLAttributeNode): void {
if (!this.isIdAttribute(attributeNode)) return

const idValue = this.extractIdValue(attributeNode)

if (!idValue) return
if (this.isWhitespaceOnlyId(idValue.identifier)) return

this.processIdDuplicate(idValue, attributeNode)
}

private isIdAttribute(attributeNode: HTMLAttributeNode): boolean {
if (!attributeNode.name?.children || !attributeNode.value) return false

return getStaticAttributeName(attributeNode.name) === "id"
}

private extractIdValue(attributeNode: HTMLAttributeNode): { identifier: string; shouldTrackDuplicates: boolean } | null {
const valueNodes = attributeNode.value?.children || []

if (hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
return null
}

const identifier = isEffectivelyStatic(valueNodes) ? getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes)
if (!identifier) return null

return { identifier, shouldTrackDuplicates: true }
}

private isWhitespaceOnlyId(identifier: string): boolean {
return identifier !== '' && identifier.trim() === ''
}

private processIdDuplicate(idValue: { identifier: string; shouldTrackDuplicates: boolean }, attributeNode: HTMLAttributeNode): void {
const { identifier, shouldTrackDuplicates } = idValue

if (!shouldTrackDuplicates) return

if (this.isInControlFlow) {
this.handleControlFlowId(identifier, attributeNode)
} else {
this.handleGlobalId(identifier, attributeNode)
}
}

private handleControlFlowId(identifier: string, attributeNode: HTMLAttributeNode): void {
if (this.currentControlFlowType === ControlFlowType.LOOP) {
this.handleLoopId(identifier, attributeNode)
} else {
this.handleConditionalId(identifier, attributeNode)
}

this.currentBranchIds.add(identifier)
}

private handleLoopId(identifier: string, attributeNode: HTMLAttributeNode): void {
const isStaticId = this.isStaticId(attributeNode)

if (isStaticId) {
this.addDuplicateIdOffense(identifier, attributeNode.location)
return
}

if (this.currentBranchIds.has(identifier)) {
this.addSameLoopIterationOffense(identifier, attributeNode.location)
}
}

private handleConditionalId(identifier: string, attributeNode: HTMLAttributeNode): void {
if (this.currentBranchIds.has(identifier)) {
this.addSameBranchOffense(identifier, attributeNode.location)
return
}

if (this.documentIds.has(identifier)) {
this.addDuplicateIdOffense(identifier, attributeNode.location)
return
}

this.controlFlowIds.add(identifier)
}

private handleGlobalId(identifier: string, attributeNode: HTMLAttributeNode): void {
if (this.documentIds.has(identifier)) {
this.addDuplicateIdOffense(identifier, attributeNode.location)
return
}

this.documentIds.add(id)
this.documentIds.add(identifier)
}

private isStaticId(attributeNode: HTMLAttributeNode): boolean {
const valueNodes = attributeNode.value!.children
const isCompletelyStatic = valueNodes.every(child => isNode(child, LiteralNode))
const isEffectivelyStaticValue = isEffectivelyStatic(valueNodes)

return isCompletelyStatic || isEffectivelyStaticValue
}

private addDuplicateIdOffense(identifier: string, location: any): void {
this.addOffense(
`Duplicate ID \`${identifier}\` found. IDs must be unique within a document.`,
location,
"error"
)
}

private addSameLoopIterationOffense(identifier: string, location: any): void {
this.addOffense(
`Duplicate ID \`${identifier}\` found within the same loop iteration. IDs must be unique within the same loop iteration.`,
location,
"error"
)
}

private addSameBranchOffense(identifier: string, location: any): void {
this.addOffense(
`Duplicate ID \`${identifier}\` found within the same control flow branch. IDs must be unique within the same control flow branch.`,
location,
"error"
)
}
}

Expand Down
97 changes: 97 additions & 0 deletions javascript/packages/linter/src/rules/rule-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,18 @@ import type {
Node
} from "@herb-tools/core"

import { IdentityPrinter } from "@herb-tools/printer"

import { DEFAULT_LINT_CONTEXT } from "../types.js"

import type * as Nodes from "@herb-tools/core"
import type { LintOffense, LintSeverity, LintContext } from "../types.js"

export enum ControlFlowType {
CONDITIONAL,
LOOP
}

/**
* Base visitor class that provides common functionality for rule visitors
*/
Expand Down Expand Up @@ -65,6 +73,95 @@ export abstract class BaseRuleVisitor extends Visitor {
}
}

/**
* Mixin that adds control flow tracking capabilities to rule visitors
* This allows rules to track state across different control flow structures
* like if/else branches, loops, etc.
*
* @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
* @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
*/
export abstract class ControlFlowTrackingVisitor<TControlFlowState = any, TBranchState = any> extends BaseRuleVisitor {
protected isInControlFlow: boolean = false
protected currentControlFlowType: ControlFlowType | null = null

/**
* Handle visiting a control flow node with proper scope management
*/
protected handleControlFlowNode(node: Node, controlFlowType: ControlFlowType, visitChildren: () => void): void {
const wasInControlFlow = this.isInControlFlow
const previousControlFlowType = this.currentControlFlowType

this.isInControlFlow = true
this.currentControlFlowType = controlFlowType

const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow)

visitChildren()

this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore)

this.isInControlFlow = wasInControlFlow
this.currentControlFlowType = previousControlFlowType
}

/**
* Handle visiting a branch node (like else, when) with proper scope management
*/
protected startNewBranch(visitChildren: () => void): void {
const stateToRestore = this.onEnterBranch()

visitChildren()

this.onExitBranch(stateToRestore)
}

visitERBIfNode(node: Nodes.ERBIfNode): void {
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node))
}

visitERBUnlessNode(node: Nodes.ERBUnlessNode): void {
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node))
}

visitERBCaseNode(node: Nodes.ERBCaseNode): void {
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node))
}

visitERBCaseMatchNode(node: Nodes.ERBCaseMatchNode): void {
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node))
}

visitERBWhileNode(node: Nodes.ERBWhileNode): void {
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node))
}

visitERBForNode(node: Nodes.ERBForNode): void {
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node))
}

visitERBUntilNode(node: Nodes.ERBUntilNode): void {
this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node))
}

visitERBBlockNode(node: Nodes.ERBBlockNode): void {
this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node))
}

visitERBElseNode(node: Nodes.ERBElseNode): void {
this.startNewBranch(() => super.visitERBElseNode(node))
}

visitERBWhenNode(node: Nodes.ERBWhenNode): void {
this.startNewBranch(() => super.visitERBWhenNode(node))
}

protected abstract onEnterControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean): TControlFlowState
protected abstract onExitControlFlow(controlFlowType: ControlFlowType, wasAlreadyInControlFlow: boolean, stateToRestore: TControlFlowState): void
protected abstract onEnterBranch(): TBranchState
protected abstract onExitBranch(stateToRestore: TBranchState): void
}

/**
* Gets attributes from an HTMLOpenTagNode
*/
Expand Down
Loading
Loading