diff --git a/packages/analyzer/src/issue.ts b/packages/analyzer/src/issue.ts index d9464ed..b888fae 100644 --- a/packages/analyzer/src/issue.ts +++ b/packages/analyzer/src/issue.ts @@ -1,4 +1,4 @@ -import type { Position } from "@knuckles/location"; +import type { Position, Range } from "@knuckles/location"; export enum AnalyzerSeverity { Error = "error", @@ -11,4 +11,15 @@ export interface AnalyzerIssue { message: string; start: Position | undefined; end: Position | undefined; + quickFix?: AnalyzerQuickFix | undefined; +} + +export interface AnalyzerQuickFix { + label?: string | undefined; + edits: AnalyzerQuickFixEdit[]; +} + +export interface AnalyzerQuickFixEdit { + range: Range; + text: string; } diff --git a/packages/analyzer/src/standard/rules/virtual-element-end-notation.ts b/packages/analyzer/src/standard/rules/virtual-element-end-notation.ts index b00603a..9e422fc 100644 --- a/packages/analyzer/src/standard/rules/virtual-element-end-notation.ts +++ b/packages/analyzer/src/standard/rules/virtual-element-end-notation.ts @@ -10,9 +10,8 @@ export default { check({ report, document }) { document.visit( (node): void => { - const regex = new RegExp( - `\\/ko\\s+${escapeStringRegexp(node.binding.name.value)}`, - ); + const bindingName = node.binding.name.value; + const regex = new RegExp(`\\/ko\\s+${escapeStringRegexp(bindingName)}`); if (!regex.test(node.endComment.content)) { report({ @@ -21,6 +20,15 @@ export default { severity: this.severity, start: node.endComment.start, end: node.endComment.end, + quickFix: { + label: "Add notation", + edits: [ + { + range: node.endComment, + text: ``, + }, + ], + }, }); } }, diff --git a/packages/language-service/src/features/code-actions.ts b/packages/language-service/src/features/code-actions.ts new file mode 100644 index 0000000..db27644 --- /dev/null +++ b/packages/language-service/src/features/code-actions.ts @@ -0,0 +1,112 @@ +import type { LanguageServiceWorker } from "../private.js"; +import { + type ProtocolPosition, + type ProtocolRange, +} from "../utils/position.js"; +import { Range } from "@knuckles/location"; + +export interface DiagnosticIdentifier { + code: string; + range: ProtocolRange; +} + +export interface CodeActionParams { + fileName: string; + position: ProtocolPosition; + diagnostics?: DiagnosticIdentifier[]; +} + +export interface CodeAction { + label: string; + edits: CodeActionEdit[]; + diagnostic?: DiagnosticIdentifier; +} + +export type CodeActionEdit = + | { + type: "create-file"; + fileName: string; + } + | { + type: "delete-file"; + fileName: string; + } + | { + type: "rename-file"; + oldFileName: string; + newFileName: string; + } + | { + type: "delete"; + fileName: string; + range: ProtocolRange; + } + | { + type: "replace"; + fileName: string; + range: ProtocolRange; + text: string; + } + | { + type: "insert"; + fileName: string; + position: ProtocolPosition; + text: string; + }; + +export type CodeActions = CodeAction[]; + +export default async function getCodeActions( + this: LanguageServiceWorker, + params: CodeActionParams, +): Promise { + const state = await this.getDocumentState(params.fileName); + if (state.broken) return []; + + const codeActions: CodeActions = []; + + for (const issue of state.issues) { + if (issue.quickFix) { + const diagnostic = (params.diagnostics ?? []).find((diagnostic) => + Range.fromLinesAndColumns( + diagnostic.range.start.line, + diagnostic.range.start.column, + diagnostic.range.end.line, + diagnostic.range.end.column, + state.document.text, + ), + ); + const label = issue.quickFix.label ?? "Fix this issue"; + const edits = issue.quickFix.edits.map((edit): CodeActionEdit => { + if (edit.text.length === 0) { + return { + type: "delete", + fileName: params.fileName, + range: edit.range, + }; + } else if (edit.range.size === 0) { + return { + type: "insert", + fileName: params.fileName, + position: edit.range.start.toJSON(), + text: edit.text, + }; + } else { + return { + type: "replace", + fileName: params.fileName, + range: edit.range.toJSON(), + text: edit.text, + }; + } + }); + codeActions.push({ + label, + edits, + diagnostic, + }); + } + } + + return codeActions; +} diff --git a/packages/language-service/src/features/completion.ts b/packages/language-service/src/features/completion.ts index 31186b7..f7afcaa 100644 --- a/packages/language-service/src/features/completion.ts +++ b/packages/language-service/src/features/completion.ts @@ -86,7 +86,6 @@ export default async function getCompletion( includeCompletionsForImportStatements: false, includeCompletionsForModuleExports: false, allowRenameOfImportPath: false, - // TODO: get quote from current binding attribute quotePreference, triggerCharacter: params.context?.triggerCharacter as | ts.CompletionsTriggerCharacter diff --git a/packages/language-service/src/features/diagnostics.ts b/packages/language-service/src/features/diagnostics.ts index d04c363..91251b9 100644 --- a/packages/language-service/src/features/diagnostics.ts +++ b/packages/language-service/src/features/diagnostics.ts @@ -1,7 +1,8 @@ import type { LanguageServiceWorker } from "../private.js"; +import type { Document } from "../utils/document.js"; +import { getFullIssueRange } from "../utils/issue.js"; import type { ProtocolRange } from "../utils/position.js"; import { AnalyzerSeverity, type AnalyzerIssue } from "@knuckles/analyzer"; -import { Position, Range } from "@knuckles/location"; export interface DiagnosticsParams { fileName: string; @@ -31,19 +32,17 @@ export default async function getDiagnostics( const state = await this.getDocumentState(params.fileName); const diagnostics = state.issues.map((issue) => - translateIssueToDiagnostic(issue, state.document.text), + translateIssueToDiagnostic(state.document, issue), ); return diagnostics; } function translateIssueToDiagnostic( + document: Document, issue: AnalyzerIssue, - text: string, ): Diagnostic { - const start = issue.start ?? Position.fromOffset(0, text); - const end = issue.end ?? Position.fromOffset(start.offset + 1, text); - const range = new Range(start, end); + const range = getFullIssueRange(document, issue); const severity = { [AnalyzerSeverity.Error]: DiagnosticSeverity.Error, [AnalyzerSeverity.Warning]: DiagnosticSeverity.Warning, diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts index e5ebbc7..df58b61 100644 --- a/packages/language-service/src/index.ts +++ b/packages/language-service/src/index.ts @@ -1,4 +1,5 @@ export * from "./public.js"; +export type * from "./features/code-actions.js"; export type * from "./features/completion.js"; export type * from "./features/definition.js"; export type * from "./features/diagnostics.js"; diff --git a/packages/language-service/src/private.ts b/packages/language-service/src/private.ts index 1ece71f..57dfd8a 100644 --- a/packages/language-service/src/private.ts +++ b/packages/language-service/src/private.ts @@ -1,3 +1,4 @@ +import getCodeActions from "./features/code-actions.js"; import getCompletion from "./features/completion.js"; import getDefinition from "./features/definition.js"; import getDiagnostics from "./features/diagnostics.js"; @@ -31,6 +32,7 @@ export class LanguageServiceWorker { "document/definition": getDefinition.bind(this), "document/diagnostics": getDiagnostics.bind(this), "document/hover": getHover.bind(this), + "document/quick-fixes": getCodeActions.bind(this), }; #programProvider = new ProgramProvider(); diff --git a/packages/language-service/src/public.ts b/packages/language-service/src/public.ts index b20ba69..f24b7bf 100644 --- a/packages/language-service/src/public.ts +++ b/packages/language-service/src/public.ts @@ -1,3 +1,4 @@ +import type { CodeActions, CodeActionParams } from "./features/code-actions.js"; import type { Completion, CompletionParams } from "./features/completion.js"; import type { Definition, DefinitionParams } from "./features/definition.js"; import type { Diagnostics, DiagnosticsParams } from "./features/diagnostics.js"; @@ -129,5 +130,9 @@ export class LanguageService { getHover(params: HoverParams): Promise { return this.#client.request("document/hover", params); } + + getCodeActions(params: CodeActionParams): Promise { + return this.#client.request("document/quick-fixes", params); + } //#endregion } diff --git a/packages/language-service/src/utils/issue.ts b/packages/language-service/src/utils/issue.ts new file mode 100644 index 0000000..2bbb46d --- /dev/null +++ b/packages/language-service/src/utils/issue.ts @@ -0,0 +1,10 @@ +import type { Document } from "./document.js"; +import type { AnalyzerIssue } from "@knuckles/analyzer"; +import { Position, Range } from "@knuckles/location"; + +export function getFullIssueRange(document: Document, issue: AnalyzerIssue) { + const start = issue.start ?? Position.fromOffset(0, document.text); + const end = issue.end ?? Position.fromOffset(start.offset + 1, document.text); + const range = new Range(start, end); + return range; +} diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 29099ef..75c91c0 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,7 +1,9 @@ import { createDebounceAsync } from "./utils/debounce.js"; +import { isMatchingReference } from "./utils/diagnostics.js"; import { LogLevel, Logger } from "@eliassko/logger"; import { LanguageService, + type DiagnosticIdentifier, type LanguageServiceOptions, } from "@knuckles/language-service"; import { join } from "node:path"; @@ -148,6 +150,135 @@ export function activate(context: vscode.ExtensionContext) { } //#endregion + //#region code actions + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider(options.selector, { + async provideCodeActions(document, range, context, token) { + if (!service) return null; + if (!(await getLatestUpdate(document))) return null; + if (token.isCancellationRequested) return null; + + const codeActions = await service.getCodeActions({ + fileName: document.fileName, + position: { + line: range.start.line, + column: range.start.character, + }, + diagnostics: context.diagnostics + .filter( + (d): d is typeof d & { code: string } => + Boolean(d.code) && typeof d.code === "string", + ) + .map((diagnostic) => ({ + code: diagnostic.code, + range: { + start: { + line: diagnostic.range.start.line, + column: diagnostic.range.start.character, + }, + end: { + line: diagnostic.range.end.line, + column: diagnostic.range.end.character, + }, + }, + })), + }); + + if (token.isCancellationRequested) return null; + + return codeActions.map((fix): vscode.CodeAction => { + const workspaceEdit = new vscode.WorkspaceEdit(); + + for (const edit of fix.edits) { + switch (edit.type) { + case "delete": + workspaceEdit.delete( + vscode.Uri.file(edit.fileName), + new vscode.Range( + new vscode.Position( + edit.range.start.line, + edit.range.start.column, + ), + new vscode.Position( + edit.range.end.line, + edit.range.end.column, + ), + ), + ); + break; + + case "replace": + workspaceEdit.replace( + vscode.Uri.file(edit.fileName), + new vscode.Range( + new vscode.Position( + edit.range.start.line, + edit.range.start.column, + ), + new vscode.Position( + edit.range.end.line, + edit.range.end.column, + ), + ), + edit.text, + ); + break; + + case "insert": + workspaceEdit.insert( + vscode.Uri.file(edit.fileName), + new vscode.Position(edit.position.line, edit.position.column), + edit.text, + ); + break; + + case "create-file": + workspaceEdit.createFile(vscode.Uri.file(edit.fileName)); + break; + + case "delete-file": + workspaceEdit.deleteFile(vscode.Uri.file(edit.fileName)); + break; + + case "rename-file": + workspaceEdit.renameFile( + vscode.Uri.file(edit.oldFileName), + vscode.Uri.file(edit.newFileName), + ); + break; + } + } + + const diagnosticId = fix.diagnostic; + const diagnostic = diagnosticId + ? context.diagnostics.find((d) => + isMatchingReference(d, diagnosticId), + ) + : undefined; + + return { + title: fix.label, + edit: workspaceEdit, + + // TODO: allow language service to return multiple diagnostics + diagnostics: diagnostic ? [diagnostic] : [], + + // TODO: move to language service + kind: vscode.CodeActionKind.QuickFix, + isPreferred: true, + + command: { + title: "Fix this issue (command)", + command: "_knuckles.quickFix", + arguments: [document.uri.toString(), fix.diagnostic], + }, + }; + }); + }, + }), + ); + //#endregion code action + //#region completion context.subscriptions.push( vscode.languages.registerCompletionItemProvider( @@ -336,6 +467,21 @@ export function activate(context: vscode.ExtensionContext) { }, ), ); + + // Remove diagnostics immediately when using quick-fixes. + context.subscriptions.push( + vscode.commands.registerCommand( + "_knuckles.quickFix", + async (documentUri: string, diagnosticId: DiagnosticIdentifier) => { + const uri = vscode.Uri.parse(documentUri); + const filteredDiagnostics = + diagnosticCollection + .get(uri) + ?.filter((d) => !isMatchingReference(d, diagnosticId)) ?? []; + diagnosticCollection.set(uri, filteredDiagnostics); + }, + ), + ); //#endregion //#region public commands diff --git a/packages/vscode/src/utils/diagnostics.ts b/packages/vscode/src/utils/diagnostics.ts new file mode 100644 index 0000000..d183f3d --- /dev/null +++ b/packages/vscode/src/utils/diagnostics.ts @@ -0,0 +1,15 @@ +import type { DiagnosticIdentifier } from "@knuckles/language-service"; +import type * as vscode from "vscode"; + +export function isMatchingReference( + diagnostic: vscode.Diagnostic, + diagnosticId: DiagnosticIdentifier, +) { + return ( + diagnostic.code === diagnosticId.code && + diagnostic.range.start.line === diagnosticId.range.start.line && + diagnostic.range.start.character === diagnosticId.range.start.column && + diagnostic.range.end.line === diagnosticId.range.end.line && + diagnostic.range.end.character === diagnosticId.range.end.column + ); +}