Skip to content
Draft
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
2 changes: 1 addition & 1 deletion javascript/packages/core/src/ast-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ export function isEquivalentElement(first: HTMLElementNode, second: HTMLElementN
// --- AST Mutation Utilities ---

const CHILD_ARRAY_PROPS = ["children", "body", "statements", "conditions"]
const LINKED_NODE_PROPS = ["subsequent", "else_clause"]
const LINKED_NODE_PROPS = ["subsequent", "else_clause", "open_tag", "close_tag", "value", "name"]

/**
* Finds the array containing a target node in the AST, along with its index.
Expand Down
157 changes: 157 additions & 0 deletions javascript/packages/linter/src/cli/diff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { colorize } from "@herb-tools/highlighter"

export interface DiffLine {
type: "context" | "removed" | "added"
content: string
lineNumber: number
}

export interface DiffHunk {
lines: DiffLine[]
}

export function computeDiff(original: string, fixed: string, contextLines: number = 1): DiffHunk[] {
if (original === fixed) return []

const originalLines = original.split("\n")
const fixedLines = fixed.split("\n")

if (originalLines.length === fixedLines.length) {
return computeParallelDiff(originalLines, fixedLines, contextLines)
}

return computeBlockDiff(originalLines, fixedLines, contextLines)
}

function computeParallelDiff(originalLines: string[], fixedLines: string[], contextLines: number): DiffHunk[] {
const changeIndices: number[] = []

for (let index = 0; index < originalLines.length; index++) {
if (originalLines[index] !== fixedLines[index]) {
changeIndices.push(index)
}
}

if (changeIndices.length === 0) return []

const changeGroups: number[][] = []
let currentGroup: number[] = [changeIndices[0]]

for (let index = 1; index < changeIndices.length; index++) {
const gapBetweenChanges = changeIndices[index] - changeIndices[index - 1] - 1

if (gapBetweenChanges <= contextLines * 2) {
currentGroup.push(changeIndices[index])
} else {
changeGroups.push(currentGroup)
currentGroup = [changeIndices[index]]
}
}

changeGroups.push(currentGroup)

const hunks: DiffHunk[] = []

for (const group of changeGroups) {
const hunk: DiffHunk = { lines: [] }
const firstChange = group[0]
const lastChange = group[group.length - 1]
const contextStart = Math.max(0, firstChange - contextLines)
const contextEnd = Math.min(originalLines.length - 1, lastChange + contextLines)

for (let index = contextStart; index <= contextEnd; index++) {
if (group.includes(index)) {
hunk.lines.push({ type: "removed", content: originalLines[index], lineNumber: index + 1 })
hunk.lines.push({ type: "added", content: fixedLines[index], lineNumber: index + 1 })
} else {
hunk.lines.push({ type: "context", content: originalLines[index], lineNumber: index + 1 })
}
}

hunks.push(hunk)
}

return hunks
}

function computeBlockDiff(originalLines: string[], fixedLines: string[], contextLines: number): DiffHunk[] {
let prefixLength = 0

while (
prefixLength < originalLines.length &&
prefixLength < fixedLines.length &&
originalLines[prefixLength] === fixedLines[prefixLength]
) {
prefixLength++
}

let originalEnd = originalLines.length - 1
let fixedEnd = fixedLines.length - 1

while (
originalEnd > prefixLength &&
fixedEnd > prefixLength &&
originalLines[originalEnd] === fixedLines[fixedEnd]
) {
originalEnd--
fixedEnd--
}

if (prefixLength > originalEnd && prefixLength > fixedEnd) {
return []
}

const hunk: DiffHunk = { lines: [] }

const contextStart = Math.max(0, prefixLength - contextLines)

for (let index = contextStart; index < prefixLength; index++) {
hunk.lines.push({ type: "context", content: originalLines[index], lineNumber: index + 1 })
}

for (let index = prefixLength; index <= originalEnd; index++) {
hunk.lines.push({ type: "removed", content: originalLines[index], lineNumber: index + 1 })
}

for (let index = prefixLength; index <= fixedEnd; index++) {
hunk.lines.push({ type: "added", content: fixedLines[index], lineNumber: index + 1 })
}

const contextEnd = Math.min(originalLines.length - 1, originalEnd + contextLines)

for (let index = originalEnd + 1; index <= contextEnd; index++) {
hunk.lines.push({ type: "context", content: originalLines[index], lineNumber: index + 1 })
}

return hunk.lines.length > 0 ? [hunk] : []
}

export function formatDiff(hunks: DiffHunk[], indent: string = " "): string {
const lines: string[] = []

for (let hunkIndex = 0; hunkIndex < hunks.length; hunkIndex++) {
const hunk = hunks[hunkIndex]

if (hunkIndex > 0) {
lines.push(indent + colorize(" ...", "gray"))
}

for (const line of hunk.lines) {
switch (line.type) {
case "removed":
lines.push(indent + colorize(`- ${line.content}`, "red"))
break

case "added":
lines.push(indent + colorize(`+ ${line.content}`, "green"))
break

case "context":
lines.push(indent + colorize(` ${line.content}`, "gray"))
break
}
}
}

return lines.join("\n")
}
21 changes: 20 additions & 1 deletion javascript/packages/linter/src/cli/file-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { fileURLToPath } from "node:url"
import { availableParallelism } from "node:os"
import { colorize } from "@herb-tools/highlighter"

import { computeDiff, formatDiff } from "./diff.js"

import type { DiffHunk } from "./diff.js"
import type { Diagnostic } from "@herb-tools/core"
import type { FormatOption } from "./argument-parser.js"
import type { HerbConfigOptions } from "@herb-tools/config"
Expand All @@ -22,6 +25,7 @@ export interface ProcessedFile {
offense: Diagnostic
content: string
autocorrectable?: boolean
autofixDiff?: DiffHunk[]
}

export interface ProcessingContext {
Expand Down Expand Up @@ -208,11 +212,26 @@ export class FileProcessor {
}
} else {
for (const offense of lintResult.offenses) {
const autocorrectable = this.isRuleAutocorrectable(offense.rule)
let autofixDiff: DiffHunk[] | undefined

if (autocorrectable && formatOption !== "json") {
const previewResult = this.linter.previewAutofix(content, {
fileName: filename,
ignoreDisableComments: context?.ignoreDisableComments
}, [offense], { includeUnsafe: true })

if (previewResult.fixed.length > 0) {
autofixDiff = computeDiff(content, previewResult.source)
}
}

allOffenses.push({
filename,
offense: offense,
content,
autocorrectable: this.isRuleAutocorrectable(offense.rule)
autocorrectable,
autofixDiff,
})

const ruleData = ruleOffenses.get(offense.rule) || { count: 0, files: new Set() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { BaseFormatter } from "./base-formatter.js"
import { LineWrapper } from "@herb-tools/highlighter"
import { ruleDocumentationUrl } from "../../urls.js"
import { fileUrl } from "../file-url.js"
import { formatDiff } from "../diff.js"

import type { Diagnostic } from "@herb-tools/core"
import type { ProcessedFile } from "../file-processor.js"
Expand Down Expand Up @@ -34,7 +35,9 @@ export class DetailedFormatter extends BaseFormatter {
allOffenses.filter(item => item.autocorrectable).map(item => item.offense)
)

if (isSingleFile) {
const hasDiffs = allOffenses.some(item => item.autofixDiff && item.autofixDiff.length > 0)

if (isSingleFile && !hasDiffs) {
const { filename, content } = allOffenses[0]
const diagnostics = allOffenses.map(item => item.offense)

Expand All @@ -53,8 +56,8 @@ export class DetailedFormatter extends BaseFormatter {
} else {
const totalMessageCount = allOffenses.length

for (let i = 0; i < allOffenses.length; i++) {
const { filename, offense, content, autocorrectable } = allOffenses[i]
for (let index = 0; index < allOffenses.length; index++) {
const { filename, offense, content, autocorrectable, autofixDiff } = allOffenses[index]

const codeUrl = offense.code ? ruleDocumentationUrl(offense.code) : undefined
const suffix = autocorrectable ? correctableTag : undefined
Expand All @@ -68,8 +71,15 @@ export class DetailedFormatter extends BaseFormatter {
})
console.log(`\n${formatted}`)

if (autofixDiff && autofixDiff.length > 0) {
const diffHeader = colorize(" Suggested fix:", "cyan")
const diffOutput = formatDiff(autofixDiff)

console.log(`${diffHeader}\n${diffOutput}`)
}

const width = LineWrapper.getTerminalWidth()
const progressText = `[${i + 1}/${totalMessageCount}]`
const progressText = `[${index + 1}/${totalMessageCount}]`
const rightPadding = 16
const separatorLength = Math.max(0, width - progressText.length - 1 - rightPadding)
const separator = '⎯'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { colorize, hyperlink, TextFormatter } from "@herb-tools/highlighter"
import { BaseFormatter } from "./base-formatter.js"
import { ruleDocumentationUrl } from "../../urls.js"
import { fileUrl } from "../file-url.js"
import { formatDiff } from "../diff.js"

import type { Diagnostic } from "@herb-tools/core"
import type { ProcessedFile } from "../file-processor.js"
Expand Down Expand Up @@ -49,7 +50,7 @@ export class SimpleFormatter extends BaseFormatter {
const filenameLink = hyperlink(filenameText, fileUrl(filename))
console.log(`${filenameLink}:`)

for (const { offense, autocorrectable } of processedFiles) {
for (const { offense, autocorrectable, autofixDiff } of processedFiles) {
const isError = offense.severity === "error"
const severity = isError ? colorize("✗", "brightRed") : colorize("⚠", "brightYellow")
const ruleText = `(${offense.code})`
Expand All @@ -61,6 +62,12 @@ export class SimpleFormatter extends BaseFormatter {
const message = TextFormatter.highlightBackticks(offense.message)

console.log(` ${paddedLocation} ${severity} ${message} ${rule}${correctable}`)

if (autofixDiff && autofixDiff.length > 0) {
const diffOutput = formatDiff(autofixDiff, " ")

console.log(diffOutput)
}
}
}
}
22 changes: 18 additions & 4 deletions javascript/packages/linter/src/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,8 +571,8 @@ export class Linter {
const unfixed: LintOffense[] = []

if (parserOffenses.length > 0) {
const parseResult = this.parseCache.get(currentSource)
let needsReindent = false
let lastParseResult: ParseResult | null = null

for (const offense of parserOffenses) {
const ruleClass = this.findRuleClass(offense.rule)
Expand All @@ -598,6 +598,9 @@ export class Linter {
continue
}

const parserOptions = rule.parserOptions || {}
const parseResult = this.parseCache.get(currentSource, parserOptions)

if (offense.autofixContext) {
const originalNodeType = offense.autofixContext.node.type
const location: Location = offense.autofixContext.node.location ? Location.from(offense.autofixContext.node.location) : offense.location
Expand All @@ -621,6 +624,7 @@ export class Linter {

if (fixedResult) {
fixed.push(offense)
lastParseResult = parseResult

if (this.isParserRuleClass(ruleClass) && ruleClass.reindentAfterAutofix === true) {
needsReindent = true
Expand All @@ -630,11 +634,11 @@ export class Linter {
}
}

if (fixed.length > 0) {
if (fixed.length > 0 && lastParseResult) {
if (needsReindent) {
currentSource = new IndentPrinter().print(parseResult.value)
currentSource = new IndentPrinter().print(lastParseResult.value)
} else {
currentSource = new IdentityPrinter().print(parseResult.value)
currentSource = new IdentityPrinter().print(lastParseResult.value)
}
}
}
Expand Down Expand Up @@ -686,4 +690,14 @@ export class Linter {
unfixed
}
}

previewAutofix(source: string, context?: Partial<LintContext>, offensesToFix?: LintOffense[], options?: { includeUnsafe?: boolean }): AutofixResult {
this.parseCache.clear()

const result = this.autofix(source, context, offensesToFix, options)

this.parseCache.clear()

return result
}
}
Loading
Loading