Skip to content

Commit c5c8c59

Browse files
authored
Language Server: Herb Linter Fix-on-save (marcoroth#758)
This pull request introduces the functionality for the Herb Language Server to be able to autofix Herb Linter offenses on save. This builds on the new `--fix` option in the Herb Linter (introduced in marcoroth#622), which can automatically correct offenses without formatting the rest of the document. If both `Fix-on-Save` and `Format-on-Save` are active, the Language Server will first fix the offenses and then format the document. https://github.com/user-attachments/assets/070bd0a4-4551-4527-9b0c-01a3e3d44df3 The Visual Studio Code Extension also exposes a new "Fix on save" toggle: <img width="2016" height="679" alt="CleanShot 2025-11-01 at 02 15 50@2x" src="https://github.com/user-attachments/assets/2fbfa731-d3ad-4b97-a953-c565c136dc7b" />
1 parent 0ea5085 commit c5c8c59

16 files changed

+785
-60
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Connection, TextEdit } from "vscode-languageserver/node"
2+
import { TextDocument } from "vscode-languageserver-textdocument"
3+
4+
import { Herb } from "@herb-tools/node-wasm"
5+
import { Linter } from "@herb-tools/linter"
6+
import { Config } from "@herb-tools/config"
7+
8+
import { getFullDocumentRange } from "./utils"
9+
10+
export class AutofixService {
11+
private connection: Connection
12+
private linter: Linter
13+
14+
constructor(connection: Connection, config?: Config) {
15+
this.connection = connection
16+
this.linter = this.buildLinter(config)
17+
}
18+
19+
setConfig(config: Config) {
20+
this.linter = this.buildLinter(config)
21+
}
22+
23+
private buildLinter(config?: Config) {
24+
return Linter.from(Herb, config)
25+
}
26+
27+
async autofix(document: TextDocument): Promise<TextEdit[]> {
28+
try {
29+
const text = document.getText()
30+
const lintResult = this.linter.lint(text, { fileName: document.uri })
31+
const offensesToFix = lintResult.offenses
32+
33+
if (offensesToFix.length === 0) return []
34+
35+
const autofixResult = this.linter.autofix(text, { fileName: document.uri }, offensesToFix)
36+
37+
if (autofixResult.source === text) return []
38+
39+
return [{ range: getFullDocumentRange(document), newText: autofixResult.source }]
40+
} catch (error) {
41+
this.connection.console.error(`[Autofix] Failed: ${error}`)
42+
43+
return []
44+
}
45+
}
46+
}

javascript/packages/language-server/src/code_action_service.ts

Lines changed: 108 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1-
import { CodeAction, CodeActionKind, Diagnostic, Range, TextEdit, WorkspaceEdit, CreateFile, TextDocumentEdit, OptionalVersionedTextDocumentIdentifier } from "vscode-languageserver/node"
1+
import { CodeAction, CodeActionKind, CodeActionParams, Diagnostic, Range, Position, TextEdit, WorkspaceEdit, CreateFile, TextDocumentEdit, OptionalVersionedTextDocumentIdentifier } from "vscode-languageserver/node"
2+
import { TextDocument } from "vscode-languageserver-textdocument"
3+
24
import { Config } from "@herb-tools/config"
35
import { Project } from "./project"
6+
import { Herb } from "@herb-tools/node-wasm"
7+
import { Linter } from "@herb-tools/linter"
8+
9+
import { getFullDocumentRange } from "./utils"
10+
11+
import type { LintOffense } from "@herb-tools/linter"
412

513
export class CodeActionService {
614
private project: Project
715
private config?: Config
16+
private linter: Linter
817

918
constructor(project: Project, config?: Config) {
1019
this.project = project
1120
this.config = config
21+
this.linter = Linter.from(Herb, config)
1222
}
1323

1424
setConfig(config: Config) {
@@ -65,14 +75,74 @@ export class CodeActionService {
6575
return actions.concat(disableAllActions)
6676
}
6777

78+
autofixCodeActions(params: CodeActionParams, document: TextDocument): CodeAction[] {
79+
if (this.config && !this.config.isLinterEnabled) {
80+
return []
81+
}
82+
83+
const codeActions: CodeAction[] = []
84+
const text = document.getText()
85+
86+
const lintResult = this.linter.lint(text, { fileName: document.uri })
87+
const offenses = lintResult.offenses
88+
89+
const relevantDiagnostics = params.context.diagnostics.filter(diagnostic => {
90+
return diagnostic.source === "Herb Linter " && this.isInRange(diagnostic.range, params.range)
91+
})
92+
93+
for (const diagnostic of relevantDiagnostics) {
94+
const offense = offenses.find(offense => this.rangesEqual(this.offenseToRange(offense), diagnostic.range) && offense.rule === diagnostic.code)
95+
96+
if (!offense) {
97+
continue
98+
}
99+
100+
const fixResult = this.linter.autofix(text, { fileName: document.uri }, [offense])
101+
102+
if (fixResult.fixed.length > 0 && fixResult.source !== text) {
103+
const codeAction: CodeAction = {
104+
title: `Herb Linter: Fix "${offense.message}"`,
105+
kind: CodeActionKind.QuickFix,
106+
diagnostics: [diagnostic],
107+
edit: this.createDocumentEdit(document, fixResult.source)
108+
}
109+
110+
codeActions.push(codeAction)
111+
}
112+
}
113+
114+
const allFixableOffenses = offenses.filter(offense => {
115+
const fixResult = this.linter.autofix(text, { fileName: document.uri }, [offense])
116+
117+
return fixResult.fixed.length > 0
118+
})
119+
120+
if (allFixableOffenses.length > 0) {
121+
const fixAllResult = this.linter.autofix(text, { fileName: document.uri }, allFixableOffenses)
122+
123+
if (fixAllResult.fixed.length > 0 && fixAllResult.source !== text) {
124+
const fixAllAction: CodeAction = {
125+
title: `Herb Linter: Fix all ${fixAllResult.fixed.length} autocorrectable offense${fixAllResult.fixed.length === 1 ? '' : 's'}`,
126+
kind: CodeActionKind.SourceFixAll,
127+
edit: this.createDocumentEdit(document, fixAllResult.source)
128+
}
129+
130+
codeActions.push(fixAllAction)
131+
}
132+
}
133+
134+
return codeActions
135+
}
136+
137+
68138
private createDisableLineAction(uri: string, diagnostic: Diagnostic, ruleName: string, documentText: string): CodeAction | null {
69139
const line = diagnostic.range.start.line
70140
const edit = this.createDisableCommentEdit(uri, line, ruleName, documentText)
71141

72142
if (!edit) return null
73143

74144
const action: CodeAction = {
75-
title: `Herb: Disable \`${ruleName}\` for this line`,
145+
title: `Herb Linter: Disable \`${ruleName}\` for this line`,
76146
kind: CodeActionKind.QuickFix,
77147
diagnostics: [diagnostic],
78148
edit,
@@ -88,7 +158,7 @@ export class CodeActionService {
88158
if (!edit) return null
89159

90160
const action: CodeAction = {
91-
title: "Herb: Disable all linter rules for this line",
161+
title: "Herb Linter: Disable all linter rules for this line",
92162
kind: CodeActionKind.QuickFix,
93163
diagnostics: [diagnostic],
94164
edit,
@@ -161,7 +231,7 @@ export class CodeActionService {
161231
const configUri = `file://${configPath}`
162232

163233
const action: CodeAction = {
164-
title: `Herb: Disable \`${ruleName}\` in \`.herb.yml\``,
234+
title: `Herb Linter: Disable \`${ruleName}\` in \`.herb.yml\``,
165235
kind: CodeActionKind.QuickFix,
166236
diagnostics: [diagnostic],
167237
edit,
@@ -265,4 +335,38 @@ export class CodeActionService {
265335
return null
266336
}
267337
}
338+
339+
private createDocumentEdit(document: TextDocument, newText: string): WorkspaceEdit {
340+
return {
341+
changes: {
342+
[document.uri]: [{
343+
range: getFullDocumentRange(document),
344+
newText
345+
}]
346+
}
347+
}
348+
}
349+
350+
private offenseToRange(offense: LintOffense): Range {
351+
return {
352+
start: Position.create(offense.location.start.line - 1, offense.location.start.column),
353+
end: Position.create(offense.location.end.line - 1, offense.location.end.column)
354+
}
355+
}
356+
357+
private rangesEqual(r1: Range, r2: Range): boolean {
358+
return (
359+
r1.start.line === r2.start.line &&
360+
r1.start.character === r2.start.character &&
361+
r1.end.line === r2.end.line &&
362+
r1.end.character === r2.end.character
363+
)
364+
}
365+
366+
private isInRange(diagnosticRange: Range, requestedRange: Range): boolean {
367+
if (diagnosticRange.start.line > requestedRange.end.line) return false
368+
if (diagnosticRange.end.line < requestedRange.start.line) return false
369+
370+
return true
371+
}
268372
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Connection, TextEdit, TextDocumentSaveReason } from "vscode-languageserver/node"
2+
import { TextDocument } from "vscode-languageserver-textdocument"
3+
4+
import { Settings } from "./settings"
5+
import { AutofixService } from "./autofix_service"
6+
import { FormattingService } from "./formatting_service"
7+
8+
export class DocumentSaveService {
9+
private connection: Connection
10+
private settings: Settings
11+
private autofixService: AutofixService
12+
private formattingService: FormattingService
13+
14+
constructor(connection: Connection, settings: Settings, autofixService: AutofixService, formattingService: FormattingService) {
15+
this.connection = connection
16+
this.settings = settings
17+
this.autofixService = autofixService
18+
this.formattingService = formattingService
19+
}
20+
21+
async applyFixesAndFormatting(document: TextDocument, reason: TextDocumentSaveReason): Promise<TextEdit[]> {
22+
const settings = await this.settings.getDocumentSettings(document.uri)
23+
const fixOnSave = settings?.linter?.fixOnSave !== false
24+
const formatterEnabled = settings?.formatter?.enabled ?? false
25+
26+
this.connection.console.log(`[DocumentSave] fixOnSave=${fixOnSave}, formatterEnabled=${formatterEnabled}`)
27+
28+
let autofixEdits: TextEdit[] = []
29+
30+
if (fixOnSave) {
31+
autofixEdits = await this.autofixService.autofix(document)
32+
}
33+
34+
if (!formatterEnabled) return autofixEdits
35+
36+
if (autofixEdits.length === 0) {
37+
return this.formattingService.formatOnSave(document, reason)
38+
}
39+
40+
const autofixedDocument: TextDocument = {
41+
...document,
42+
uri: document.uri,
43+
getText: () => autofixEdits[0].newText,
44+
}
45+
46+
return this.formattingService.formatOnSave(autofixedDocument, reason)
47+
}
48+
}

javascript/packages/language-server/src/document_service.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,6 @@ export class DocumentService {
77

88
constructor(connection: Connection) {
99
this.documents = new TextDocuments(TextDocument)
10-
11-
// Make the text document manager listen on the connection
12-
// for open, change and close text document events
1310
this.documents.listen(connection)
1411
}
1512

javascript/packages/language-server/src/formatting_service.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Connection, TextDocuments, DocumentFormattingParams, DocumentRangeFormattingParams, TextEdit, Range, Position } from "vscode-languageserver/node"
1+
import { Connection, TextDocuments, DocumentFormattingParams, DocumentRangeFormattingParams, TextEdit, Range, Position, TextDocumentSaveReason } from "vscode-languageserver/node"
22
import { TextDocument } from "vscode-languageserver-textdocument"
33
import { Formatter, defaultFormatOptions } from "@herb-tools/formatter"
44
import { Project } from "./project"
@@ -41,6 +41,25 @@ export class FormattingService {
4141
}
4242
}
4343

44+
async formatOnSave(document: TextDocument, reason: TextDocumentSaveReason): Promise<TextEdit[]> {
45+
this.connection.console.log(`[Formatting] formatOnSave called for ${document.uri}`)
46+
47+
if (reason !== TextDocumentSaveReason.Manual) {
48+
this.connection.console.log(`[Formatting] Skipping: reason=${reason} (not manual)`)
49+
return []
50+
}
51+
52+
const filePath = document.uri.replace(/^file:\/\//, '')
53+
54+
if (!this.shouldFormatFile(filePath)) {
55+
this.connection.console.log(`[Formatting] Skipping: file not in formatter config`)
56+
57+
return []
58+
}
59+
60+
return this.performFormatting({ textDocument: { uri: document.uri }, options: { tabSize: 2, insertSpaces: true } })
61+
}
62+
4463
private shouldFormatFile(filePath: string): boolean {
4564
if (filePath.endsWith('.herb.yml')) return false
4665
if (!this.config) return true
@@ -68,10 +87,10 @@ export class FormattingService {
6887
}
6988

7089
try {
90+
const text = document.getText()
7191
const options = await this.getFormatterOptions(params.textDocument.uri)
7292
const formatter = new Formatter(this.project.herbBackend, options)
7393

74-
const text = document.getText()
7594
let newText = formatter.format(text)
7695

7796
if (!newText.endsWith('\n')) {

0 commit comments

Comments
 (0)