From fddb7319a5a8b0e4e8295f11e53b97e6d5c66296 Mon Sep 17 00:00:00 2001 From: Elias Skogevall Date: Tue, 25 Jun 2024 09:04:41 +0000 Subject: [PATCH 1/8] feat!: implement language service --- conver.json | 2 +- packages/analyzer/src/analyzer.ts | 8 +- packages/analyzer/src/plugin.ts | 4 +- packages/analyzer/src/standard/transpile.ts | 4 +- packages/language-server/jest.config.ts | 6 - packages/language-server/project.json | 10 - .../src/features/diagnostics.ts | 54 --- packages/language-server/src/index.ts | 1 - .../language-server/src/language-server.ts | 41 -- .../language-server/src/language-service.ts | 76 ---- .../README.md | 6 +- packages/language-service/jest.config.ts | 6 + .../package.json | 14 +- packages/language-service/project.json | 21 + .../src/features/completion.ts | 118 +++-- .../src/features/definition.ts | 66 +-- .../src/features/diagnostics.ts | 58 +++ .../src/features/hover.ts | 48 +- packages/language-service/src/index.ts | 5 + packages/language-service/src/private.ts | 87 ++++ packages/language-service/src/protocol.ts | 115 +++++ packages/language-service/src/public.ts | 133 ++++++ .../language-service/src/utils/document.ts | 6 + .../language-service/src/utils/position.ts | 38 ++ .../src/utils}/program.ts | 162 +++---- .../src/utils/text-rendering.ts | 0 packages/language-service/src/worker.ts | 8 + .../tsconfig.json | 0 .../tsconfig.lib.json | 0 .../tsconfig.spec.json | 0 packages/parser/src/parse.ts | 4 +- packages/parser/src/parser.ts | 8 +- packages/syntax-tree/src/index.ts | 2 +- .../syntax-tree/src/syntax-tree/document.ts | 15 - packages/syntax-tree/src/syntax-tree/root.ts | 15 + .../typescript/src/transpiler/transpiler.ts | 6 +- packages/vscode/esbuild.config.js | 2 +- packages/vscode/package.json | 11 +- packages/vscode/src/extension.ts | 415 ++++++++++++++---- packages/vscode/src/language-server.ts | 3 - packages/vscode/src/utils/debounce.ts | 33 ++ packages/vscode/src/utils/deferred.ts | 12 + packages/vscode/src/worker.ts | 1 + pnpm-lock.yaml | 82 +--- tsconfig.json | 2 +- 45 files changed, 1156 insertions(+), 552 deletions(-) delete mode 100644 packages/language-server/jest.config.ts delete mode 100644 packages/language-server/project.json delete mode 100644 packages/language-server/src/features/diagnostics.ts delete mode 100644 packages/language-server/src/index.ts delete mode 100644 packages/language-server/src/language-server.ts delete mode 100644 packages/language-server/src/language-service.ts rename packages/{language-server => language-service}/README.md (64%) create mode 100644 packages/language-service/jest.config.ts rename packages/{language-server => language-service}/package.json (71%) create mode 100644 packages/language-service/project.json rename packages/{language-server => language-service}/src/features/completion.ts (60%) rename packages/{language-server => language-service}/src/features/definition.ts (55%) create mode 100644 packages/language-service/src/features/diagnostics.ts rename packages/{language-server => language-service}/src/features/hover.ts (65%) create mode 100644 packages/language-service/src/index.ts create mode 100644 packages/language-service/src/private.ts create mode 100644 packages/language-service/src/protocol.ts create mode 100644 packages/language-service/src/public.ts create mode 100644 packages/language-service/src/utils/document.ts create mode 100644 packages/language-service/src/utils/position.ts rename packages/{language-server/src => language-service/src/utils}/program.ts (59%) rename packages/{language-server => language-service}/src/utils/text-rendering.ts (100%) create mode 100644 packages/language-service/src/worker.ts rename packages/{language-server => language-service}/tsconfig.json (100%) rename packages/{language-server => language-service}/tsconfig.lib.json (100%) rename packages/{language-server => language-service}/tsconfig.spec.json (100%) delete mode 100644 packages/syntax-tree/src/syntax-tree/document.ts create mode 100644 packages/syntax-tree/src/syntax-tree/root.ts delete mode 100644 packages/vscode/src/language-server.ts create mode 100644 packages/vscode/src/utils/debounce.ts create mode 100644 packages/vscode/src/utils/deferred.ts create mode 100644 packages/vscode/src/worker.ts diff --git a/conver.json b/conver.json index fd45471..d9017d2 100644 --- a/conver.json +++ b/conver.json @@ -24,7 +24,7 @@ "@knuckles/config": "minor", "@knuckles/eslint": "minor", "@knuckles/fabricator": "minor", - "@knuckles/language-server": "minor", + "@knuckles/language-service": "minor", "@knuckles/location": "minor", "@knuckles/parser": "minor", "@knuckles/ssr": "minor", diff --git a/packages/analyzer/src/analyzer.ts b/packages/analyzer/src/analyzer.ts index 8fb0cb2..cbb888f 100644 --- a/packages/analyzer/src/analyzer.ts +++ b/packages/analyzer/src/analyzer.ts @@ -13,7 +13,7 @@ import { type NormalizedConfig, } from "@knuckles/config"; import { type ParserError, parse } from "@knuckles/parser"; -import type { Document } from "@knuckles/syntax-tree"; +import type { SyntaxTree } from "@knuckles/syntax-tree"; import assert from "node:assert"; export interface AnalyzerOptions { @@ -26,7 +26,7 @@ export interface AnalyzeOptions { } export interface AnalyzeCache { - document?: Document; + document?: SyntaxTree; snapshots?: Partial; } @@ -34,7 +34,7 @@ export interface AnalyzeResult { issues: AnalyzerIssue[]; snapshots: Partial; metadata: Record; - document: Document | null; + document: SyntaxTree | null; } export class Analyzer { @@ -102,7 +102,7 @@ export class Analyzer { const snapshots = (options?.cache?.snapshots ?? {}) as AnalyzerSnapshots; const metadata = {}; - let document: Document; + let document: SyntaxTree; if (options?.cache?.document) { document = options.cache.document; } else { diff --git a/packages/analyzer/src/plugin.ts b/packages/analyzer/src/plugin.ts index 86a2aad..e619450 100644 --- a/packages/analyzer/src/plugin.ts +++ b/packages/analyzer/src/plugin.ts @@ -1,7 +1,7 @@ import type { AnalyzerIssue } from "./issue.js"; import type { NormalizedConfig } from "@knuckles/config"; import type { Snapshot } from "@knuckles/fabricator"; -import type { Document } from "@knuckles/syntax-tree"; +import type { SyntaxTree } from "@knuckles/syntax-tree"; export interface AnalyzerSnapshots { [name: string]: Snapshot | undefined; @@ -12,7 +12,7 @@ export interface AnalyzerSnapshots { export interface AnalyzeContext { readonly fileName: string; readonly text: string; - readonly document: Document; + readonly document: SyntaxTree; readonly snapshots: AnalyzerSnapshots; readonly metadata: Record; diff --git a/packages/analyzer/src/standard/transpile.ts b/packages/analyzer/src/standard/transpile.ts index 97ba874..1b14024 100644 --- a/packages/analyzer/src/standard/transpile.ts +++ b/packages/analyzer/src/standard/transpile.ts @@ -1,12 +1,12 @@ import { Chunk } from "@knuckles/fabricator"; import { - type Document, + type SyntaxTree, Element, type Binding, KoVirtualElement, } from "@knuckles/syntax-tree"; -export function transpile(document: Document) { +export function transpile(document: SyntaxTree) { const chunk = new Chunk(); const render = (binding: Binding) => { diff --git a/packages/language-server/jest.config.ts b/packages/language-server/jest.config.ts deleted file mode 100644 index 8db6bdc..0000000 --- a/packages/language-server/jest.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* eslint-disable */ -export default { - displayName: "@knuckles/language-server", - preset: "../../jest.preset.cjs", - coverageDirectory: "../../coverage/packages/language-server", -}; diff --git a/packages/language-server/project.json b/packages/language-server/project.json deleted file mode 100644 index d1eaf3c..0000000 --- a/packages/language-server/project.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "@knuckles/language-server", - "projectType": "library", - "sourceRoot": "{projectRoot}/src", - "targets": { - "build": {}, - "test": {}, - "lint": {} - } -} diff --git a/packages/language-server/src/features/diagnostics.ts b/packages/language-server/src/features/diagnostics.ts deleted file mode 100644 index 738a0fc..0000000 --- a/packages/language-server/src/features/diagnostics.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { LanguageService } from "../language-service.js"; -import { AnalyzerSeverity, type AnalyzerIssue } from "@knuckles/analyzer"; -import { Position } from "@knuckles/location"; -import { writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import * as vscode from "vscode-languageserver/node.js"; - -export interface DiagnosticParams { - textDocument: vscode.TextDocumentIdentifier; -} - -export default async function sendDiagnostics( - service: LanguageService, - params: DiagnosticParams, -): Promise { - const state = await service.getState(params.textDocument); - - const path = fileURLToPath(state.document.uri); - writeFileSync(path + ".ts", state.snapshot?.generated ?? "// broken"); - - const diagnostics = state.issues.map((issue) => - translateIssueToDiagnostic(issue, state.document.getText()), - ); - - await service.connection.sendDiagnostics({ - uri: state.document.uri, - diagnostics, - }); -} - -function translateIssueToDiagnostic( - issue: AnalyzerIssue, - text: string, -): vscode.Diagnostic { - const start = issue.start ?? Position.fromOffset(0, text); - const end = issue.end ?? Position.fromOffset(start.offset + 1, text); - const range = vscode.Range.create( - start.line, - start.column, - end.line, - end.column, - ); - const severity = { - [AnalyzerSeverity.Error]: vscode.DiagnosticSeverity.Error, - [AnalyzerSeverity.Warning]: vscode.DiagnosticSeverity.Warning, - }[issue.severity]; - return { - range, - severity, - message: issue.message, - source: "knuckles", - code: issue.name, - }; -} diff --git a/packages/language-server/src/index.ts b/packages/language-server/src/index.ts deleted file mode 100644 index d454f43..0000000 --- a/packages/language-server/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./language-server.js"; diff --git a/packages/language-server/src/language-server.ts b/packages/language-server/src/language-server.ts deleted file mode 100644 index 8088334..0000000 --- a/packages/language-server/src/language-server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import getCompletion from "./features/completion.js"; -import getDefinition from "./features/definition.js"; -import getHover from "./features/hover.js"; -import { LanguageService } from "./language-service.js"; -import * as vscode from "vscode-languageserver/node.js"; - -export interface LanguageServerOptions { - connection?: vscode.Connection; -} - -export function startLanguageServer(options?: LanguageServerOptions) { - const service = new LanguageService(); - - // Create connection - const connection = - options?.connection ?? vscode.createConnection(vscode.ProposedFeatures.all); - connection.onInitialize(() => { - return { - capabilities: { - textDocumentSync: vscode.TextDocumentSyncKind.Incremental, - definitionProvider: true, - hoverProvider: true, - completionProvider: { - resolveProvider: false, - triggerCharacters: [".", '"', "'", "`", "/", "@", "<", "#", " "], - allCommitCharacters: [".", ",", ";", ")"], - }, - }, - }; - }); - - // Register features - connection.onHover((params) => getHover(service, params)); - connection.onCompletion((params) => getCompletion(service, params)); - connection.onDefinition((params) => getDefinition(service, params)); - - // Listen - service.listen(connection); - connection.listen(); - connection.console.log("Listening..."); -} diff --git a/packages/language-server/src/language-service.ts b/packages/language-server/src/language-service.ts deleted file mode 100644 index 264f972..0000000 --- a/packages/language-server/src/language-service.ts +++ /dev/null @@ -1,76 +0,0 @@ -import sendDiagnostics from "./features/diagnostics.js"; -import { DocumentStateProvider, ProgramProvider } from "./program.js"; -import assert from "node:assert"; -import { TextDocument } from "vscode-languageserver-textdocument"; -import * as vscode from "vscode-languageserver/node.js"; - -export class LanguageService { - readonly documents = new vscode.TextDocuments(TextDocument); - #stateMap = new WeakMap(); - - #_connection!: vscode.Connection; - get connection() { - return this.#_connection; - } - - #programProvider = new ProgramProvider(); - - #disposeState(document: TextDocument) { - this.#stateMap.delete(document); - } - - async getState(identifier: vscode.TextDocumentIdentifier) { - const document = this.documents.get(identifier.uri); - assert(document); - const provider = this.#stateMap.get(document); - assert(provider); - const state = await provider.get(); - return state; - } - - listen(connection: vscode.Connection) { - this.#_connection = connection; - - this.documents.onDidOpen(async (event) => { - // Create document state - const { document } = event; - const provider = new DocumentStateProvider( - document, - this.#programProvider, - ); - this.#stateMap.set(document, provider); - }); - - const touch = async (document: TextDocument) => { - const provider = this.#stateMap.get(document)!; - await provider.touch(); - - await sendDiagnostics(this, { - textDocument: vscode.TextDocumentIdentifier.create(document.uri), - }); - }; - - this.documents.onDidChangeContent(async (event) => { - const { document } = event; - touch(document); - }); - - connection.onNotification( - "workspace/didChangeActiveTextEditor", - (params) => { - const uri = params.uri; - const document = this.documents.get(uri); - if (document) { - touch(document); - } - }, - ); - - this.documents.onDidClose((event) => { - // Dispose document state - this.#disposeState(event.document); - }); - this.documents.listen(connection); - this.#programProvider.listen(this); - } -} diff --git a/packages/language-server/README.md b/packages/language-service/README.md similarity index 64% rename from packages/language-server/README.md rename to packages/language-service/README.md index 53e0207..831d80b 100644 --- a/packages/language-server/README.md +++ b/packages/language-service/README.md @@ -1,12 +1,12 @@ # Language Server - + Implements a language server according to the [Language Server Protocol] (LSP) to provide language features for [Knockout] views. @@ -14,7 +14,7 @@ Implements a language server according to the [Language Server Protocol] (LSP) t -[**Documentation**](https://knuckles.elsk.dev) | [Package (npm)](https://npmjs.com/package/@knuckles/language-server) | [Repository](https://github.com/tscpp/knuckles) | [Source Code](https://github.com/tscpp/knuckles/tree/main/packages/language-server) +[**Documentation**](https://knuckles.elsk.dev) | [Package (npm)](https://npmjs.com/package/@knuckles/language-service) | [Repository](https://github.com/tscpp/knuckles) | [Source Code](https://github.com/tscpp/knuckles/tree/main/packages/language-service) diff --git a/packages/language-service/jest.config.ts b/packages/language-service/jest.config.ts new file mode 100644 index 0000000..4a56af8 --- /dev/null +++ b/packages/language-service/jest.config.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +export default { + displayName: "@knuckles/language-service", + preset: "../../jest.preset.cjs", + coverageDirectory: "../../coverage/packages/language-service", +}; diff --git a/packages/language-server/package.json b/packages/language-service/package.json similarity index 71% rename from packages/language-server/package.json rename to packages/language-service/package.json index 524470a..d8833a5 100644 --- a/packages/language-server/package.json +++ b/packages/language-service/package.json @@ -1,7 +1,7 @@ { - "name": "@knuckles/language-server", + "name": "@knuckles/language-service", "version": "0.13.1", - "description": "Implements the language service protocol (LSP) for Knuckles.", + "description": "The language service for Knuckles.", "repository": { "type": "git", "url": "https://github.com/tscpp/knuckles", @@ -9,8 +9,12 @@ }, "license": "MIT", "type": "module", - "exports": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./worker": "./src/worker.ts" + }, "dependencies": { + "@eliassko/logger": "^1.1.0", "@knuckles/analyzer": "workspace:~", "@knuckles/config": "workspace:~", "@knuckles/fabricator": "workspace:~", @@ -19,9 +23,7 @@ "@knuckles/syntax-tree": "workspace:~", "@knuckles/typescript": "workspace:~", "minimatch": "^9.0.4", - "ts-morph": "^22.0.0", - "vscode-languageserver": "^9.0.1", - "vscode-languageserver-textdocument": "^1.0.11" + "ts-morph": "^22.0.0" }, "devDependencies": { "typescript": "^5.4.5" diff --git a/packages/language-service/project.json b/packages/language-service/project.json new file mode 100644 index 0000000..c6f1649 --- /dev/null +++ b/packages/language-service/project.json @@ -0,0 +1,21 @@ +{ + "name": "@knuckles/language-service", + "projectType": "library", + "sourceRoot": "{projectRoot}/src", + "targets": { + "build": { + "options": { + "entry": { + "index": "{projectRoot}/src/index.ts", + "worker": "{projectRoot}/src/worker.ts" + }, + "package": { + ".": "./index.js", + "./worker": "./worker.js" + } + } + }, + "test": {}, + "lint": {} + } +} diff --git a/packages/language-server/src/features/completion.ts b/packages/language-service/src/features/completion.ts similarity index 60% rename from packages/language-server/src/features/completion.ts rename to packages/language-service/src/features/completion.ts index e6df2c3..954d60d 100644 --- a/packages/language-server/src/features/completion.ts +++ b/packages/language-service/src/features/completion.ts @@ -1,17 +1,75 @@ -import type { LanguageService } from "../language-service.js"; +import type { LanguageServiceWorker } from "../private.js"; +import { toPosition, type ProtocolPosition } from "../utils/position.js"; import { Position } from "@knuckles/location"; import { Element } from "@knuckles/syntax-tree"; import { ts } from "ts-morph"; -import * as vscode from "vscode-languageserver/node.js"; + +export interface CompletionParams { + fileName: string; + position: ProtocolPosition; + context?: CompletionContext; +} + +export interface CompletionContext { + triggerCharacter?: string; + triggerKind?: CompletionTriggerKind; +} + +export enum CompletionTriggerKind { + Invoked = 1, + TriggerCharacter, + TriggerForIncompleteCompletions, +} + +export enum CompletionItemKind { + Text, + Method, + Function, + Constructor, + Field, + Variable, + Class, + Interface, + Module, + Property, + Unit, + Value, + Enum, + Keyword, + Snippet, + Color, + File, + Reference, + Folder, + EnumMember, + Constant, + Struct, + Event, + Operator, + TypeParameter, +} + +export interface CompletionItem { + label: string; + kind: CompletionItemKind; + preselect: boolean | undefined; + insertText: string | undefined; + filterText: string | undefined; + sortText: string; +} + +export type Completion = CompletionItem[]; export default async function getCompletion( - service: LanguageService, - params: vscode.CompletionParams, -): Promise { - const state = await service.getState(params.textDocument); + this: LanguageServiceWorker, + params: CompletionParams, +): Promise { + const state = await this.getDocumentState(params.fileName); if (state.broken) return []; - const originalPosition = convertPosition(params.position); + const tsService = state.tsProject.getLanguageService(); + + const originalPosition = toPosition(params.position, state.document.text); const generatedPosition = state.snapshot.mirror({ original: originalPosition, }); @@ -21,8 +79,8 @@ export default async function getCompletion( const quotePreference = getQuotePreferenceAt(originalPosition); - const completions = state.service!.compilerObject.getCompletionsAtPosition( - state.sourceFile!.getFilePath(), + const completions = tsService.compilerObject.getCompletionsAtPosition( + state.tsSourceFile!.getFilePath(), generatedPosition.offset, { includeCompletionsForImportStatements: false, @@ -42,15 +100,7 @@ export default async function getCompletion( return completions.entries.map(convertCompletion); - function convertPosition(position: vscode.Position): Position { - return Position.fromLineAndColumn( - position.line, - position.character, - state.snapshot!.original, - ); - } - - function convertCompletion(entry: ts.CompletionEntry): vscode.CompletionItem { + function convertCompletion(entry: ts.CompletionEntry): CompletionItem { return { label: entry.name, kind: convertKind(entry.kind), @@ -86,11 +136,11 @@ export default async function getCompletion( } // https://github.com/microsoft/vscode/blob/77e5788/extensions/typescript-language-features/src/languageFeatures/completions.ts#L440 -function convertKind(kind: string): vscode.CompletionItemKind { +function convertKind(kind: string): CompletionItemKind { switch (kind) { case ts.ScriptElementKind.primitiveType: case ts.ScriptElementKind.keyword: - return vscode.CompletionItemKind.Keyword; + return CompletionItemKind.Keyword; case ts.ScriptElementKind.constElement: case ts.ScriptElementKind.letElement: @@ -98,53 +148,53 @@ function convertKind(kind: string): vscode.CompletionItemKind { case ts.ScriptElementKind.localVariableElement: case ts.ScriptElementKind.alias: case ts.ScriptElementKind.parameterElement: - return vscode.CompletionItemKind.Variable; + return CompletionItemKind.Variable; case ts.ScriptElementKind.memberVariableElement: case ts.ScriptElementKind.memberGetAccessorElement: case ts.ScriptElementKind.memberSetAccessorElement: - return vscode.CompletionItemKind.Field; + return CompletionItemKind.Field; case ts.ScriptElementKind.functionElement: case ts.ScriptElementKind.localFunctionElement: - return vscode.CompletionItemKind.Function; + return CompletionItemKind.Function; case ts.ScriptElementKind.memberFunctionElement: case ts.ScriptElementKind.constructSignatureElement: case ts.ScriptElementKind.callSignatureElement: case ts.ScriptElementKind.indexSignatureElement: - return vscode.CompletionItemKind.Method; + return CompletionItemKind.Method; case ts.ScriptElementKind.enumElement: - return vscode.CompletionItemKind.Enum; + return CompletionItemKind.Enum; case ts.ScriptElementKind.enumMemberElement: - return vscode.CompletionItemKind.EnumMember; + return CompletionItemKind.EnumMember; case ts.ScriptElementKind.moduleElement: case ts.ScriptElementKind.externalModuleName: - return vscode.CompletionItemKind.Module; + return CompletionItemKind.Module; case ts.ScriptElementKind.classElement: case ts.ScriptElementKind.typeElement: - return vscode.CompletionItemKind.Class; + return CompletionItemKind.Class; case ts.ScriptElementKind.interfaceElement: - return vscode.CompletionItemKind.Interface; + return CompletionItemKind.Interface; case ts.ScriptElementKind.warning: - return vscode.CompletionItemKind.Text; + return CompletionItemKind.Text; case ts.ScriptElementKind.scriptElement: - return vscode.CompletionItemKind.File; + return CompletionItemKind.File; case ts.ScriptElementKind.directory: - return vscode.CompletionItemKind.Folder; + return CompletionItemKind.Folder; case ts.ScriptElementKind.string: - return vscode.CompletionItemKind.Constant; + return CompletionItemKind.Constant; default: - return vscode.CompletionItemKind.Property; + return CompletionItemKind.Property; } } diff --git a/packages/language-server/src/features/definition.ts b/packages/language-service/src/features/definition.ts similarity index 55% rename from packages/language-server/src/features/definition.ts rename to packages/language-service/src/features/definition.ts index 7bddba8..631994b 100644 --- a/packages/language-server/src/features/definition.ts +++ b/packages/language-service/src/features/definition.ts @@ -1,59 +1,64 @@ -import type { LanguageService } from "../language-service.js"; +import type { LanguageServiceWorker } from "../private.js"; +import { + toPosition, + toProtocolRange, + type ProtocolLocation, + type ProtocolPosition, +} from "../utils/position.js"; import { Position, Range } from "@knuckles/location"; -import { pathToFileURL } from "node:url"; import type { DefinitionInfo } from "ts-morph"; -import * as vscode from "vscode-languageserver/node.js"; + +export interface DefinitionParams { + fileName: string; + position: ProtocolPosition; +} + +export interface Definition extends Array {} export default async function getDefinition( - service: LanguageService, - params: vscode.DefinitionParams, -): Promise { - const state = await service.getState(params.textDocument); + this: LanguageServiceWorker, + params: DefinitionParams, +): Promise { + const state = await this.getDocumentState(params.fileName); if (state.broken) return []; - const originalPosition = Position.fromLineAndColumn( - params.position.line, - params.position.character, - state.snapshot.original, - ); + const tsService = state.tsProject.getLanguageService(); + + const position = toPosition(params.position, state.document.text); + const generatedPosition = state.snapshot.mirror({ - original: originalPosition, + original: position, }); if (generatedPosition) { - const definitions = state.service.getDefinitionsAtPosition( - state.sourceFile, + const definitions = tsService.getDefinitionsAtPosition( + state.tsSourceFile, generatedPosition.offset, ); - return definitions.flatMap((definition): vscode.Location[] => { + return definitions.flatMap((definition): ProtocolLocation[] => { const node = definition.getNode(); const definitionToLocation = ( definition: DefinitionInfo, - ): vscode.Location => { + ): ProtocolLocation => { const sourceFile = definition.getSourceFile(); const path = sourceFile.getFilePath(); - const uri = pathToFileURL(path).toString(); const span = definition.getTextSpan(); - const range1 = Range.fromOffsets( + const range = Range.fromOffsets( span.getStart(), span.getEnd(), sourceFile.getFullText(), ); - const range2 = vscode.Range.create( - vscode.Position.create(range1.start.line, range1.start.column), - vscode.Position.create(range1.end.line, range1.end.column), - ); return { - uri: uri, - range: range2, + path, + range: toProtocolRange(range), }; }; if ( - node.getSourceFile().getFilePath() === state.sourceFile.getFilePath() + node.getSourceFile().getFilePath() === state.tsSourceFile.getFilePath() ) { const generatedStartOffset = node.getStart(); const generatedPosition = Position.fromOffset( @@ -77,15 +82,12 @@ export default async function getDefinition( ); return [ { - uri: state.document.uri, - range: vscode.Range.create( - vscode.Position.create(range.start.line, range.start.column), - vscode.Position.create(range.end.line, range.end.column), - ), + path: state.document.path, + range: toProtocolRange(range), }, ]; } else { - const definitions = state.service.getDefinitions(node); + const definitions = tsService.getDefinitions(node); return definitions.map((definition) => definitionToLocation(definition), ); diff --git a/packages/language-service/src/features/diagnostics.ts b/packages/language-service/src/features/diagnostics.ts new file mode 100644 index 0000000..d04c363 --- /dev/null +++ b/packages/language-service/src/features/diagnostics.ts @@ -0,0 +1,58 @@ +import type { LanguageServiceWorker } from "../private.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; +} + +export interface Diagnostic { + range: ProtocolRange; + severity: DiagnosticSeverity; + message: string; + source: string; + code: string; +} + +export enum DiagnosticSeverity { + Error, + Warning, + Information, + Hint, +} + +export type Diagnostics = Diagnostic[]; + +export default async function getDiagnostics( + this: LanguageServiceWorker, + params: DiagnosticsParams, +): Promise { + const state = await this.getDocumentState(params.fileName); + + const diagnostics = state.issues.map((issue) => + translateIssueToDiagnostic(issue, state.document.text), + ); + + return diagnostics; +} + +function translateIssueToDiagnostic( + 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 severity = { + [AnalyzerSeverity.Error]: DiagnosticSeverity.Error, + [AnalyzerSeverity.Warning]: DiagnosticSeverity.Warning, + }[issue.severity]; + return { + range, + severity, + message: issue.message, + source: "knuckles", + code: issue.name, + }; +} diff --git a/packages/language-server/src/features/hover.ts b/packages/language-service/src/features/hover.ts similarity index 65% rename from packages/language-server/src/features/hover.ts rename to packages/language-service/src/features/hover.ts index bff7c45..34969bf 100644 --- a/packages/language-server/src/features/hover.ts +++ b/packages/language-service/src/features/hover.ts @@ -1,23 +1,30 @@ -import type { LanguageService } from "../language-service.js"; +import type { LanguageServiceWorker } from "../private.js"; +import { toPosition, type ProtocolPosition } from "../utils/position.js"; import { quickInfoToMarkdown } from "../utils/text-rendering.js"; import { Position } from "@knuckles/location"; import { Element } from "@knuckles/syntax-tree"; import assert from "node:assert/strict"; -import type * as vscode from "vscode-languageserver/node.js"; + +export interface HoverParams { + fileName: string; + position: ProtocolPosition; +} + +export interface Hover { + documentation: string; +} export default async function getHover( - service: LanguageService, - params: vscode.HoverParams, -): Promise { - const state = await service.getState(params.textDocument); + this: LanguageServiceWorker, + params: HoverParams, +): Promise { + const state = await this.getDocumentState(params.fileName); if (state.broken) return null; + const tsService = state.tsProject.getLanguageService(); + // Translate to position in generated snapshot. - const originalPosition = Position.fromLineAndColumn( - params.position.line, - params.position.character, - state.snapshot.original, - ); + const originalPosition = toPosition(params.position, state.document.text); const generatedPosition = state.snapshot.mirror({ original: originalPosition, }); @@ -32,12 +39,12 @@ export default async function getHover( : undefined; const quickInfo = definition - ? state.service.compilerObject.getQuickInfoAtPosition( + ? tsService.compilerObject.getQuickInfoAtPosition( definition.getSourceFile().getFilePath(), definition.getTextSpan().getStart(), ) - : state.service.compilerObject.getQuickInfoAtPosition( - state.sourceFile.getFilePath(), + : tsService.compilerObject.getQuickInfoAtPosition( + state.tsSourceFile.getFilePath(), generatedPosition.offset, ); @@ -50,24 +57,21 @@ export default async function getHover( } return { - contents: { - kind: "markdown", - value: quickInfoToMarkdown(quickInfo), - }, + documentation: quickInfoToMarkdown(quickInfo), }; function getAbsoluteDefinitionAt(position: Position) { assert(!state.broken); - const [definition] = state.service.getDefinitionsAtPosition( - state.sourceFile, + const [definition] = tsService.getDefinitionsAtPosition( + state.tsSourceFile, position.offset, ); if (definition) { const node = definition.getNode(); if ( - node.getSourceFile().getFilePath() === state.sourceFile.getFilePath() + node.getSourceFile().getFilePath() === state.tsSourceFile.getFilePath() ) { const generatedStartOffset = node.getStart(); const generatedPosition = Position.fromOffset( @@ -79,7 +83,7 @@ export default async function getHover( }); if (!originalPosition) { - const [definition] = state.service.getDefinitions(node); + const [definition] = tsService.getDefinitions(node); if (definition) { return definition; diff --git a/packages/language-service/src/index.ts b/packages/language-service/src/index.ts new file mode 100644 index 0000000..e5ebbc7 --- /dev/null +++ b/packages/language-service/src/index.ts @@ -0,0 +1,5 @@ +export * from "./public.js"; +export type * from "./features/completion.js"; +export type * from "./features/definition.js"; +export type * from "./features/diagnostics.js"; +export type * from "./features/hover.js"; diff --git a/packages/language-service/src/private.ts b/packages/language-service/src/private.ts new file mode 100644 index 0000000..1ece71f --- /dev/null +++ b/packages/language-service/src/private.ts @@ -0,0 +1,87 @@ +import getCompletion from "./features/completion.js"; +import getDefinition from "./features/definition.js"; +import getDiagnostics from "./features/diagnostics.js"; +import getHover from "./features/hover.js"; +import { type Protocol, type ProtocolClient } from "./protocol.js"; +import type { LanguageService } from "./public.js"; +import { Document } from "./utils/document.js"; +import { DocumentStateProvider, ProgramProvider } from "./utils/program.js"; +import { Logger } from "@eliassko/logger"; +import assert from "node:assert/strict"; + +export class LanguageServiceWorker { + /* + Implement private methods invoked from the public api here! + See the public api at ./public.ts. + */ + readonly api = { + "document/open": async (params: { fileName: string; text: string }) => { + await this.createOrUpdateDocument(params.fileName, params.text); + }, + + "document/edit": async (params: { fileName: string; text: string }) => { + await this.createOrUpdateDocument(params.fileName, params.text); + }, + + "document/close": (params: { fileName: string }) => { + this.removeDocument(params.fileName); + }, + + "document/completion": getCompletion.bind(this), + "document/definition": getDefinition.bind(this), + "document/diagnostics": getDiagnostics.bind(this), + "document/hover": getHover.bind(this), + }; + + #programProvider = new ProgramProvider(); + #documents = new Map(); + #providers = new Map(); + #client: ProtocolClient; + + readonly logger = new Logger(); + + constructor(protected protocol: Protocol) { + protocol.methods = this.api; + this.#client = protocol.createClient(); + + this.logger.onLog((log) => { + this.#client.request("log", [log]); + }); + this.logger.debug("Hello from language service!"); + } + + async createOrUpdateDocument(fileName: string, text: string) { + let document = this.#documents.get(fileName); + if (document) { + const provider = this.#providers.get(document)!; + + document.text = text; + await provider.touch(); + } else { + document = new Document(fileName, text); + this.#documents.set(fileName, document); + + const provider = new DocumentStateProvider( + this, + document, + this.#programProvider, + ); + this.#providers.set(document, provider); + await provider.touch(); + } + return document; + } + + removeDocument(fileName: string) { + this.#documents.delete(fileName); + } + + async getDocumentState(fileName: string) { + const document = this.#documents.get(fileName); + assert(document); + const provider = this.#providers.get(document); + assert(provider); + const state = await provider.get(); + return state; + } +} diff --git a/packages/language-service/src/protocol.ts b/packages/language-service/src/protocol.ts new file mode 100644 index 0000000..67a2d19 --- /dev/null +++ b/packages/language-service/src/protocol.ts @@ -0,0 +1,115 @@ +export interface Request { + id: number; + method: METHOD; + params: PARAMS; +} + +export interface Response { + id: number; + result?: RESULT; + error?: ResponseError; +} + +export interface ResponseError { + message: string; +} + +export type Message = Request | Response; + +export function isRequest(value: Message): value is Request { + return Object.hasOwn(value, "method"); +} + +export function isResponse(value: Message): value is Response { + return !isRequest(value); +} + +export type ProtocolMap = Record any>; + +export interface Protocol { + methods: ProtocolMap; + onMessage(message: Message): void; + createClient(): ProtocolClient; +} + +export interface ProtocolClient { + request>( + method: K, + params: Parameters[0], + ): Promise>>; +} + +export function createProtocol( + sendMessage: (message: Message) => void, +): Protocol { + const responseMap = new Map void>(); + + return { + methods: {}, + async onMessage(message: Message) { + if (isRequest(message)) { + if (!Object.hasOwn(this.methods, message.method)) { + const response: Response = { + id: message.id, + error: { + message: `Method '${message.method}' is not defined.`, + }, + }; + sendMessage(response); + return; + } + + const method = this.methods[message.method]!; + + let result: unknown; + let error: ResponseError | undefined; + try { + result = await method(message.params as any); + } catch (cause) { + if (cause instanceof Error) { + error = { message: cause.message }; + } else { + error = { message: String(cause) }; + } + } + + const response: Response = { + id: message.id, + result, + error, + }; + sendMessage(response); + } else { + const callback = responseMap.get(message.id); + callback?.(message); + } + }, + createClient() { + let nextRequestId = 0; + + return { + request(method, params) { + const id = nextRequestId++; + + const promise = new Promise((resolve, reject) => { + responseMap.set(id, (response) => { + if (response.error) { + reject(new Error(response.error.message)); + } else { + resolve(response.result); + } + }); + }); + + sendMessage({ + id, + method, + params, + }); + + return promise; + }, + }; + }, + }; +} diff --git a/packages/language-service/src/public.ts b/packages/language-service/src/public.ts new file mode 100644 index 0000000..b20ba69 --- /dev/null +++ b/packages/language-service/src/public.ts @@ -0,0 +1,133 @@ +import type { Completion, CompletionParams } from "./features/completion.js"; +import type { Definition, DefinitionParams } from "./features/definition.js"; +import type { Diagnostics, DiagnosticsParams } from "./features/diagnostics.js"; +import type { Hover, HoverParams } from "./features/hover.js"; +import { LanguageServiceWorker } from "./private.js"; +import { + createProtocol, + isRequest, + type Message, + type Protocol, + type ProtocolClient, +} from "./protocol.js"; +import { Logger, type Log } from "@eliassko/logger"; +import { join } from "node:path"; +import { Worker } from "node:worker_threads"; + +export interface LanguageServiceOptions { + worker?: boolean; + workerURL?: string | URL | undefined; + logger?: Logger; +} + +export class LanguageService { + private api = { + log: (params: Log[]) => { + for (const log of params) { + this.logger.log(log); + } + }, + }; + + #worker: Worker | undefined; + #client: ProtocolClient; + + readonly logger: Logger; + + constructor(options?: LanguageServiceOptions) { + this.logger = options?.logger ?? new Logger(); + + let protocol: Protocol; + + if (options?.worker !== false) { + this.#worker = new Worker( + options?.workerURL ?? join(__dirname, "worker.js"), + ); + + this.#worker.on("exit", (code) => { + throw new Error(`Language service exited with code ${code}.`); + }); + + this.#worker.on("messageerror", (error) => { + throw new Error("Language service crashed.", { + cause: error, + }); + }); + + protocol = createProtocol((message) => { + if (isRequest(message)) { + this.logger.debug(`Sending request "${message.method}".`); + } + this.#worker!.postMessage(message); + }); + protocol.methods = this.api; + this.#worker.on("message", (message: Message) => + protocol.onMessage(message), + ); + } else { + protocol = createProtocol((message) => { + if (isRequest(message)) { + this.logger.debug(`Sending request "${message.method}".`); + } + worker.onMessage(message); + }); + protocol.methods = this.api; + const worker = createProtocol((message) => protocol.onMessage(message)); + new LanguageServiceWorker(worker); + } + + this.#client = protocol.createClient(); + } + + async stop() { + await this.#worker?.terminate(); + } + + /* + Implement public methods using the private api here! + See the private api at ./private.ts. + */ + + #onDiagnostics = new Set<(diagnostics: Diagnostics) => void>(); + onDiagnostics(listener: (diagnostics: Diagnostics) => void) { + this.#onDiagnostics.add(listener); + return () => { + this.#onDiagnostics.delete(listener); + }; + } + + //#region document + openDocument(fileName: string, text: string) { + const params = { fileName, text }; + return this.#client.request("document/open", params); + } + + editDocument(fileName: string, text: string) { + const params = { fileName, text }; + return this.#client.request("document/edit", params); + } + + closeDocument(fileName: string) { + const params = { fileName }; + return this.#client.request("document/close", params); + } + //#endregion + + //#region language features + getCompletion(params: CompletionParams): Promise { + return this.#client.request("document/completion", params); + } + + getDefinition(params: DefinitionParams): Promise { + return this.#client.request("document/definition", params); + } + + getDiagnostics(params: DiagnosticsParams): Promise { + return this.#client.request("document/diagnostics", params); + } + + getHover(params: HoverParams): Promise { + return this.#client.request("document/hover", params); + } + //#endregion +} diff --git a/packages/language-service/src/utils/document.ts b/packages/language-service/src/utils/document.ts new file mode 100644 index 0000000..e68a4f0 --- /dev/null +++ b/packages/language-service/src/utils/document.ts @@ -0,0 +1,6 @@ +export class Document { + constructor( + public path: string, + public text: string, + ) {} +} diff --git a/packages/language-service/src/utils/position.ts b/packages/language-service/src/utils/position.ts new file mode 100644 index 0000000..7902e53 --- /dev/null +++ b/packages/language-service/src/utils/position.ts @@ -0,0 +1,38 @@ +import { Position, Range } from "@knuckles/location"; + +export interface ProtocolPosition { + line: number; + column: number; +} + +export function toPosition(position: ProtocolPosition, text: string) { + return Position.fromLineAndColumn(position.line, position.column, text); +} + +export function toProtocolPosition(position: Position): ProtocolPosition { + return { + line: position.line, + column: position.column, + }; +} + +export interface ProtocolRange { + start: ProtocolPosition; + end: ProtocolPosition; +} + +export function toRange(range: ProtocolRange, text: string) { + return new Range(toPosition(range.start, text), toPosition(range.end, text)); +} + +export function toProtocolRange(range: Range): ProtocolRange { + return { + start: toProtocolPosition(range.start), + end: toProtocolPosition(range.end), + }; +} + +export interface ProtocolLocation { + path: string; + range: ProtocolRange; +} diff --git a/packages/language-server/src/program.ts b/packages/language-service/src/utils/program.ts similarity index 59% rename from packages/language-server/src/program.ts rename to packages/language-service/src/utils/program.ts index fa6465b..a77165d 100644 --- a/packages/language-server/src/program.ts +++ b/packages/language-service/src/utils/program.ts @@ -1,4 +1,5 @@ -import type { LanguageService } from "./language-service.js"; +import type { LanguageServiceWorker } from "../private.js"; +import type { Document } from "./document.js"; import { Analyzer, type AnalyzerFlags, @@ -11,37 +12,30 @@ import { defaultConfig, } from "@knuckles/config"; import type { Snapshot } from "@knuckles/fabricator"; -import type { Document } from "@knuckles/syntax-tree"; +import type { SyntaxTree } from "@knuckles/syntax-tree"; import analyzerTypeScriptPlugin from "@knuckles/typescript/analyzer"; import assert from "node:assert"; import { normalize } from "node:path"; -import { fileURLToPath } from "node:url"; import * as morph from "ts-morph"; import { ts } from "ts-morph"; -import type { TextDocument } from "vscode-languageserver-textdocument"; -import * as vscode from "vscode-languageserver/node.js"; - -const POLLING_DELAY = 250; export type DocumentState = ( | { broken: true; snapshot?: undefined; - sourceFile?: undefined; - service?: undefined; - checker?: undefined; + tsSourceFile?: undefined; + tsProject?: undefined; syntaxTree?: undefined; } | { broken: false; snapshot: Snapshot; - sourceFile: morph.SourceFile; - service: morph.LanguageService; - checker: morph.TypeChecker; - syntaxTree: Document; + tsSourceFile: morph.SourceFile; + tsProject: morph.Project; + syntaxTree: SyntaxTree; } ) & { - document: TextDocument; + document: Document; issues: AnalyzerIssue[]; }; @@ -50,43 +44,47 @@ export class DocumentStateProvider { #state: Promise; constructor( - readonly document: TextDocument, + protected service: LanguageServiceWorker, + readonly document: Document, programProvider: ProgramProvider, ) { this.#programProvider = programProvider; - this.#state = this.#refresh(); + this.#state = Promise.resolve({ + broken: true, + document: document, + issues: [], + }); } async #refresh(): Promise { const startTime = performance.now(); + this.service.logger.info("Analyzing..."); - const path = fileURLToPath(this.document.uri); - - const program = await this.#programProvider.getProject(path); + const program = await this.#programProvider.getProject(this.document.path); const analyzer = await program.getAnalyzer(); - const result = await analyzer.analyze(path, this.document.getText()); + const result = await analyzer.analyze( + this.document.path, + this.document.text, + ); + + const endTime = performance.now(); + const deltaTime = endTime - startTime; + this.service.logger.info(`Analyze took ${deltaTime.toFixed(0)}ms`); const snapshot = result.snapshots.typescript; if (result.document && snapshot) { - const sourceFile = result.metadata["tsSourceFile"]; - assert(sourceFile instanceof morph.SourceFile); + const tsSourceFile = result.metadata["tsSourceFile"]; + assert(tsSourceFile instanceof morph.SourceFile); - const project = sourceFile.getProject(); - const service = project.getLanguageService(); - const checker = project.getTypeChecker(); - - const endTime = performance.now(); - const deltaTime = endTime - startTime; - console.log(`Analyze took ${deltaTime.toFixed(2)}ms`); + const tsProject = tsSourceFile.getProject(); return { broken: false, document: this.document, snapshot, - sourceFile, - service, - checker, + tsSourceFile: tsSourceFile, + tsProject, issues: result.issues, syntaxTree: result.document, }; @@ -99,24 +97,8 @@ export class DocumentStateProvider { } } - #debounce = false; - touch() { - if (!this.#debounce) { - this.#debounce = true; - this.#state = new Promise((resolve, reject) => { - setTimeout(() => { - this.#refresh() - .then(resolve) - .catch(reject) - .finally(() => { - this.#debounce = false; - }); - }, POLLING_DELAY); - }); - } - - return this.#state; + return (this.#state = this.#refresh()); } get() { @@ -145,21 +127,21 @@ export class ConfigProvider { } } - #invalidate(path: string) { - this.#cache.delete(path); - } - - listen(service: LanguageService) { - // Invalidate config cache when config file content is changed. - return service.connection.onDidChangeWatchedFiles((event) => { - const documents = new Set(event.changes.map((change) => change.uri)); - - for (const uri of documents) { - const path = fileURLToPath(uri); - this.#invalidate(path); - } - }); - } + // #invalidate(path: string) { + // this.#cache.delete(path); + // } + + // listen(service: LanguageService) { + // // Invalidate config cache when config file content is changed. + // return service.connection.onDidChangeWatchedFiles((event) => { + // const documents = new Set(event.changes.map((change) => change.uri)); + + // for (const uri of documents) { + // const path = fileURLToPath(uri); + // this.#invalidate(path); + // } + // }); + // } } export class Program { @@ -245,29 +227,29 @@ export class ProgramProvider { return instance; } - listen(service: LanguageService) { - const disposables = [ - this.#configProvider.listen(service), - - // Dispose dangling programs - service.documents.onDidClose((event) => { - const path = fileURLToPath(event.document.uri); - - for (const instance of this.#instances) { - instance.openDocuments.delete(path); - - if (instance.openDocuments.size === 0) { - instance.dispose(); - this.#instances.delete(instance); - } - } - }), - ]; - - return vscode.Disposable.create(() => { - for (const disposable of disposables) { - disposable.dispose(); - } - }); - } + // listen(service: LanguageService) { + // const disposables = [ + // this.#configProvider.listen(service), + + // // Dispose dangling programs + // service.documents.onDidClose((event) => { + // const path = fileURLToPath(event.document.uri); + + // for (const instance of this.#instances) { + // instance.openDocuments.delete(path); + + // if (instance.openDocuments.size === 0) { + // instance.dispose(); + // this.#instances.delete(instance); + // } + // } + // }), + // ]; + + // return vscode.Disposable.create(() => { + // for (const disposable of disposables) { + // disposable.dispose(); + // } + // }); + // } } diff --git a/packages/language-server/src/utils/text-rendering.ts b/packages/language-service/src/utils/text-rendering.ts similarity index 100% rename from packages/language-server/src/utils/text-rendering.ts rename to packages/language-service/src/utils/text-rendering.ts diff --git a/packages/language-service/src/worker.ts b/packages/language-service/src/worker.ts new file mode 100644 index 0000000..bcc4415 --- /dev/null +++ b/packages/language-service/src/worker.ts @@ -0,0 +1,8 @@ +import { LanguageServiceWorker } from "./private.js"; +import { createProtocol, type Message } from "./protocol.js"; +import { parentPort } from "node:worker_threads"; + +const protocol = createProtocol((message) => parentPort!.postMessage(message)); +parentPort!.on("message", (message: Message) => protocol.onMessage(message)); + +new LanguageServiceWorker(protocol); diff --git a/packages/language-server/tsconfig.json b/packages/language-service/tsconfig.json similarity index 100% rename from packages/language-server/tsconfig.json rename to packages/language-service/tsconfig.json diff --git a/packages/language-server/tsconfig.lib.json b/packages/language-service/tsconfig.lib.json similarity index 100% rename from packages/language-server/tsconfig.lib.json rename to packages/language-service/tsconfig.lib.json diff --git a/packages/language-server/tsconfig.spec.json b/packages/language-service/tsconfig.spec.json similarity index 100% rename from packages/language-server/tsconfig.spec.json rename to packages/language-service/tsconfig.spec.json diff --git a/packages/parser/src/parse.ts b/packages/parser/src/parse.ts index 9fd8fea..6f705b6 100644 --- a/packages/parser/src/parse.ts +++ b/packages/parser/src/parse.ts @@ -1,12 +1,12 @@ import type { ParserError } from "./error.js"; import type { ParserOptions } from "./parser.js"; import Parser from "./parser.js"; -import type { Document } from "@knuckles/syntax-tree"; +import type { SyntaxTree } from "@knuckles/syntax-tree"; export interface ParseOptions extends ParserOptions {} export type ParseResult = { - document: Document | null; + document: SyntaxTree | null; errors: ParserError[]; }; diff --git a/packages/parser/src/parser.ts b/packages/parser/src/parser.ts index 130bbe5..ea395be 100644 --- a/packages/parser/src/parser.ts +++ b/packages/parser/src/parser.ts @@ -13,7 +13,7 @@ import { } from "./utils/parse5.js"; import { Range, Position } from "@knuckles/location"; import { - Document, + SyntaxTree, type Node, Text, Comment, @@ -66,7 +66,7 @@ export default class Parser { //#endregion //#region Document - parse(): Document | null { + parse(): SyntaxTree | null { const fragment = p5.parseFragment(this.#string, { sourceCodeLocationInfo: true, scriptingEnabled: false, @@ -91,7 +91,7 @@ export default class Parser { } } - #parseDocument(fragment: p5t.DocumentFragment): Document { + #parseDocument(fragment: p5t.DocumentFragment): SyntaxTree { const iter = fragment.childNodes[Symbol.iterator](); const children: Node[] = []; let result: IteratorResult | undefined; @@ -100,7 +100,7 @@ export default class Parser { children.push(this.#parseNode(result.value, iter)); } - return new Document({ + return new SyntaxTree({ children, range: new Range( Position.zero, diff --git a/packages/syntax-tree/src/index.ts b/packages/syntax-tree/src/index.ts index 76b2c31..b7b11d6 100644 --- a/packages/syntax-tree/src/index.ts +++ b/packages/syntax-tree/src/index.ts @@ -1,6 +1,6 @@ export * from "./syntax-tree/binding.js"; export * from "./syntax-tree/comment.js"; -export * from "./syntax-tree/document.js"; +export * from "./syntax-tree/root.js"; export * from "./syntax-tree/element.js"; export * from "./syntax-tree/node.js"; export * from "./syntax-tree/primitives.js"; diff --git a/packages/syntax-tree/src/syntax-tree/document.ts b/packages/syntax-tree/src/syntax-tree/document.ts deleted file mode 100644 index 538e6f3..0000000 --- a/packages/syntax-tree/src/syntax-tree/document.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ParentNode, type ParentNodeInit, type RawParentNode } from "./node.js"; - -export interface DocumentInit extends ParentNodeInit {} - -export interface RawDocument extends RawParentNode {} - -export class Document extends ParentNode { - constructor(init: DocumentInit) { - super(init); - } - - override toJSON(): RawDocument { - return super.toJSON(); - } -} diff --git a/packages/syntax-tree/src/syntax-tree/root.ts b/packages/syntax-tree/src/syntax-tree/root.ts new file mode 100644 index 0000000..d32a358 --- /dev/null +++ b/packages/syntax-tree/src/syntax-tree/root.ts @@ -0,0 +1,15 @@ +import { ParentNode, type ParentNodeInit, type RawParentNode } from "./node.js"; + +export interface SyntaxTreeInit extends ParentNodeInit {} + +export interface RawSyntaxTree extends RawParentNode {} + +export class SyntaxTree extends ParentNode { + constructor(init: SyntaxTreeInit) { + super(init); + } + + override toJSON(): RawSyntaxTree { + return super.toJSON(); + } +} diff --git a/packages/typescript/src/transpiler/transpiler.ts b/packages/typescript/src/transpiler/transpiler.ts index 80cd21c..a4b21f5 100644 --- a/packages/typescript/src/transpiler/transpiler.ts +++ b/packages/typescript/src/transpiler/transpiler.ts @@ -2,7 +2,7 @@ import { ns, quote, rmnl } from "./utils.js"; import { Chunk, type ChunkLike } from "@knuckles/fabricator"; import type { Position } from "@knuckles/location"; -import type { Document } from "@knuckles/syntax-tree"; +import type { SyntaxTree } from "@knuckles/syntax-tree"; import * as ko from "@knuckles/syntax-tree"; import * as ts from "ts-morph"; @@ -53,7 +53,7 @@ export class Transpiler { this.#strictness = options?.strictness ?? "loose"; } - transpile(fileName: string, syntaxTree: Document): TranspilerOutput { + transpile(fileName: string, syntaxTree: SyntaxTree): TranspilerOutput { const renderer = new Renderer(fileName, this.project, this.#strictness); renderer.refresh(); const chunk = renderer.render(syntaxTree); @@ -200,7 +200,7 @@ class Renderer { importDeclaration.remove(); } - render(document: Document) { + render(document: SyntaxTree) { return new Chunk() .append(`import ${ns} from '@knuckles/typescript/types';`) .newline(2) diff --git a/packages/vscode/esbuild.config.js b/packages/vscode/esbuild.config.js index 827662c..95f8c75 100644 --- a/packages/vscode/esbuild.config.js +++ b/packages/vscode/esbuild.config.js @@ -2,7 +2,7 @@ * @type {import('esbuild').BuildOptions} */ export default { - entryPoints: ["src/extension.ts", "src/language-server.ts"], + entryPoints: ["src/extension.ts", "src/worker.ts"], format: "cjs", bundle: true, minify: false, diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 60f4c64..5d4d6fe 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -17,19 +17,20 @@ "contributes": { "commands": [ { - "command": "_knuckles.openJsDocLink" + "command": "knuckles.restartLanguageService", + "title": "Knuckles: Restart language service" } ] }, "activationEvents": [ - "*" + "onLanguage:html" ], "dependencies": { - "@knuckles/language-server": "workspace:~", - "typescript": "^5.4.5", - "vscode-languageclient": "^9.0.1" + "@knuckles/language-service": "workspace:~", + "typescript": "^5.4.5" }, "devDependencies": { + "@eliassko/logger": "^1.1.0", "@types/mocha": "^10.0.6", "@types/node": "20.x", "@types/vscode": "^1.88.0", diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 60a164c..e63fbbd 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -1,95 +1,362 @@ -import * as vscode from "vscode"; +import { createDebounceAsync } from "./utils/debounce.js"; +import { LogLevel, Logger } from "@eliassko/logger"; import { - type ForkOptions, - LanguageClient, - type LanguageClientOptions, - type ServerOptions, - TransportKind, - RequestType, -} from "vscode-languageclient/node.js"; - -let client: LanguageClient; - -export const GetDocumentTextRequest = new RequestType< - { uri: string }, - string, - void ->("custom/readFile"); + LanguageService, + LanguageServiceOptions, +} from "@knuckles/language-service"; +import { join } from "node:path"; +import * as vscode from "vscode"; -export function activate(context: vscode.ExtensionContext) { - console.log("activate"); +let service: LanguageService | undefined; - const serverModule = context.asAbsolutePath("./dist/language-server.cjs"); - console.log(serverModule); - const debugOptions: ForkOptions = { - execArgv: ["--nolazy", "--inspect=6009"], - }; +const DEBOUNCE_RATE = 1000; - const serverOptions: ServerOptions = { - run: { - module: serverModule, - transport: TransportKind.ipc, +export function activate(context: vscode.ExtensionContext) { + const options = { + selector: { + scheme: "file", + language: "html", + }, + completion: { + triggerCharacters: [".", '"', "'", "`", "/", "@", "<", "#", " "], + commitCharacters: [".", ",", ";", ")"], + }, + diagnostics: { + debounce: 1000, }, debug: { - module: serverModule, - transport: TransportKind.ipc, - options: debugOptions, + // Warning! Disabling this will run the language service on the same + // thread as vscode. + worker: false, }, }; - const clientOptions: LanguageClientOptions = { - documentSelector: [ + //#region logging + const outputChannel = vscode.window.createOutputChannel( + "Knuckles Language Service", + { log: true }, + ); + const logger = new Logger(); + logger.onLog((log) => { + switch (log.level) { + case LogLevel.Error: + outputChannel.error(log.text); + break; + case LogLevel.Warning: + outputChannel.warn(log.text); + break; + case LogLevel.Info: + outputChannel.info(log.text); + break; + case LogLevel.Verbose: + outputChannel.debug(log.text); + break; + case LogLevel.Debug: + outputChannel.trace(log.text); + break; + } + }); + //#endregion + + //#region language service + const serviceOptions: LanguageServiceOptions = { + worker: options.debug.worker, + workerURL: join(__dirname, "worker.js"), + logger, + }; + + logger.info("Starting language service."); + service = new LanguageService(serviceOptions); + //#endregion + + //#region text document + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument((document) => { + if (!service) return; + if (!vscode.languages.match(options.selector, document)) return; + getLatestUpdate(document); + updateDiagnostics(document); + }), + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (!service) return; + if (event.contentChanges.length === 0) return; + if (!vscode.languages.match(options.selector, event.document)) return; + getLatestUpdate(event.document); + updateDiagnostics(event.document); + }), + ); + + context.subscriptions.push( + vscode.workspace.onDidCloseTextDocument((document) => { + if (!service) return; + if (!vscode.languages.match(options.selector, document)) return; + service.closeDocument(document.fileName); + }), + ); + + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(async (editor) => { + if (!service) return; + if (editor) { + if (!vscode.languages.match(options.selector, editor.document)) return; + await service.openDocument( + editor.document.fileName, + editor.document.getText(), + ); + } + }), + ); + //#endregion + + //#region updates + const documentVersionMap = new Map(); + const debounceUpdateMap = new Map Promise>(); + + async function update(document: vscode.TextDocument) { + if (!service) return; + await service.editDocument(document.fileName, document.getText()); + logger.debug("Document is updated!"); + } + + function getDebouncedUpdate(document: vscode.TextDocument) { + let debounce = debounceUpdateMap.get(document.uri.toString()); + if (debounce) return debounce; + + debounce = createDebounceAsync(() => update(document), DEBOUNCE_RATE); + debounceUpdateMap.set(document.uri.toString(), debounce); + return debounce; + } + + async function getLatestUpdate(document: vscode.TextDocument) { + if (!service) return false; + + const currentVersion = document.version; + + // Check if the version is actually outdated. + const cached = documentVersionMap.get(document.uri.toString()); + if (cached === currentVersion) return true; + + const debounced = getDebouncedUpdate(document); + await debounced(); + + documentVersionMap.set(document.uri.toString(), document.version); + + return document.version === currentVersion; + } + //#endregion + + //#region completion + context.subscriptions.push( + vscode.languages.registerCompletionItemProvider( + options.selector, { - scheme: "file", - language: "html", + async provideCompletionItems(document, position, token, context) { + if (!service) return null; + if (!(await getLatestUpdate(document))) return null; + + if (token.isCancellationRequested) { + return null; + } + + const items = await service.getCompletion({ + fileName: document.fileName, + position: { + line: position.line, + column: position.character, + }, + context: { + triggerCharacter: context.triggerCharacter, + triggerKind: context.triggerKind as number, + }, + }); + + if (token.isCancellationRequested) { + return null; + } + + return items.map( + (item): vscode.CompletionItem => ({ + label: item.label, + kind: item.kind as number, + preselect: item.preselect, + insertText: item.insertText, + filterText: item.filterText, + sortText: item.sortText, + commitCharacters: options.completion.commitCharacters, + }), + ); + }, }, - ], - synchronize: { - fileEvents: vscode.workspace.createFileSystemWatcher( - "**/knuckles.config.*", - ), - }, - markdown: { - isTrusted: true, - }, - }; + ...options.completion.triggerCharacters, + ), + ); + //#endregion + + //#region go to definition + context.subscriptions.push( + vscode.languages.registerDefinitionProvider(options.selector, { + async provideDefinition(document, position, token) { + if (!service) return null; + if (!(await getLatestUpdate(document))) return null; + + if (token.isCancellationRequested) { + return null; + } + + const definition = await service.getDefinition({ + fileName: document.fileName, + position: { + line: position.line, + column: position.character, + }, + }); - client = new LanguageClient( - "knucklesLanguageServer", - "Knuckles Language Server", - serverOptions, - clientOptions, + if (token.isCancellationRequested) { + return null; + } + + return definition.map( + (location): vscode.Location => ({ + uri: vscode.Uri.file(location.path), + range: new vscode.Range( + new vscode.Position( + location.range.start.line, + location.range.start.column, + ), + new vscode.Position( + location.range.end.line, + location.range.end.column, + ), + ), + }), + ); + }, + }), ); + //#endregion - client.start(); + //#region diagnostics + async function getDiagnostics(document: vscode.TextDocument) { + if (!service) return null; + if (!(await getLatestUpdate(document))) return null; - vscode.window.onDidChangeActiveTextEditor((editor) => { - if (editor) { - client.sendNotification("workspace/didChangeActiveTextEditor", { - uri: editor.document.uri.toString(), - }); - } - }); + const diagnostics = await service.getDiagnostics({ + fileName: document.fileName, + }); - vscode.commands.registerCommand( - "_knuckles.openJsDocLink", - async (fileName, start, length) => { - const document = await vscode.workspace.openTextDocument(fileName); - await vscode.window.showTextDocument(document, { - selection: new vscode.Selection( - document.positionAt(start), - document.positionAt(start + length), + return diagnostics.map( + (diagnostic): vscode.Diagnostic => ({ + message: diagnostic.message, + range: new vscode.Range( + new vscode.Position( + diagnostic.range.start.line, + diagnostic.range.start.column, + ), + new vscode.Position( + diagnostic.range.end.line, + diagnostic.range.end.column, + ), ), - }); - }, - ); -} + severity: diagnostic.severity as number, + code: diagnostic.code, + source: diagnostic.source, + }), + ); + } + const diagnosticCollection = + vscode.languages.createDiagnosticCollection("knuckles"); + async function updateDiagnostics(document: vscode.TextDocument) { + const originalVersion = document.version; + const diagnostics = (await getDiagnostics(document)) ?? []; + + logger.debug(`Found ${diagnostics.length} diagnostics.`); -export function deactivate(): Thenable | undefined { - console.log("deactivate"); + // Avoid updating diagnostics in document while it is being edited. + if (originalVersion !== document.version) return; - if (!client) { - return undefined; + logger.debug("Updated diagnostics."); + + diagnosticCollection.set(document.uri, diagnostics); } - return client.stop(); + //#endregion + + //#region hover + context.subscriptions.push( + vscode.languages.registerHoverProvider(options.selector, { + async provideHover(document, position, token) { + if (!service) return null; + if (!(await getLatestUpdate(document))) return null; + + if (token.isCancellationRequested) { + return null; + } + + const hover = await service.getHover({ + fileName: document.fileName, + position: { + line: position.line, + column: position.character, + }, + }); + + if (token.isCancellationRequested) { + return null; + } + + if (!hover) { + return null; + } + + const content = new vscode.MarkdownString(hover.documentation); + content.isTrusted = true; + + return { + contents: [content], + }; + }, + }), + ); + //#endregion + + //#region internal commands + context.subscriptions.push( + vscode.commands.registerCommand( + "_knuckles.openJsDocLink", + async (fileName, start, length) => { + const document = await vscode.workspace.openTextDocument(fileName); + await vscode.window.showTextDocument(document, { + selection: new vscode.Selection( + document.positionAt(start), + document.positionAt(start + length), + ), + }); + }, + ), + ); + //#endregion + + //#region public commands + context.subscriptions.push( + vscode.commands.registerCommand( + "knuckles.restartLanguageService", + async () => { + if (service) { + logger.info("Gracefully stopping previous language service."); + try { + await service.stop(); + } catch {} + logger.info("Restarting language service."); + service = new LanguageService(serviceOptions); + } + }, + ), + ); + //#endregion +} + +export async function deactivate() { + await service?.stop(); } diff --git a/packages/vscode/src/language-server.ts b/packages/vscode/src/language-server.ts deleted file mode 100644 index c00292b..0000000 --- a/packages/vscode/src/language-server.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { startLanguageServer } from "@knuckles/language-server"; - -startLanguageServer(); diff --git a/packages/vscode/src/utils/debounce.ts b/packages/vscode/src/utils/debounce.ts new file mode 100644 index 0000000..a46e8a9 --- /dev/null +++ b/packages/vscode/src/utils/debounce.ts @@ -0,0 +1,33 @@ +import Deferred from "./deferred.js"; + +export function createDebounceAsync( + callback: () => Promise, + wait: number, +): () => Promise { + let isRunning = false; + let timeout: NodeJS.Timeout | undefined; + let deferred = new Deferred(); + + const procrastinate = () => { + clearTimeout(timeout); + timeout = setTimeout(async () => { + isRunning = true; + try { + const value = await callback(); + deferred.resolve(value); + } catch (reason) { + deferred.reject(reason); + } finally { + isRunning = false; + deferred = new Deferred(); + } + }, wait); + }; + + return () => { + if (!isRunning) { + procrastinate(); + } + return deferred.promise; + }; +} diff --git a/packages/vscode/src/utils/deferred.ts b/packages/vscode/src/utils/deferred.ts new file mode 100644 index 0000000..039be3e --- /dev/null +++ b/packages/vscode/src/utils/deferred.ts @@ -0,0 +1,12 @@ +export default class Deferred { + promise: Promise; + resolve!: (value: T | PromiseLike) => void; + reject!: (reason?: any) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + } +} diff --git a/packages/vscode/src/worker.ts b/packages/vscode/src/worker.ts new file mode 100644 index 0000000..a94a91d --- /dev/null +++ b/packages/vscode/src/worker.ts @@ -0,0 +1 @@ +import "@knuckles/language-service/worker"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 564e8fb..bf6f9df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -359,8 +359,11 @@ importers: specifier: workspace:~ version: link:../location - packages/language-server: + packages/language-service: dependencies: + '@eliassko/logger': + specifier: ^1.1.0 + version: 1.1.0 '@knuckles/analyzer': specifier: workspace:~ version: link:../analyzer @@ -388,12 +391,6 @@ importers: ts-morph: specifier: ^22.0.0 version: 22.0.0 - vscode-languageserver: - specifier: ^9.0.1 - version: 9.0.1 - vscode-languageserver-textdocument: - specifier: ^1.0.11 - version: 1.0.11 devDependencies: typescript: specifier: ^5.4.5 @@ -546,16 +543,16 @@ importers: packages/vscode: dependencies: - '@knuckles/language-server': + '@knuckles/language-service': specifier: workspace:~ - version: link:../language-server + version: link:../language-service typescript: specifier: ^5.4.5 version: 5.4.5 - vscode-languageclient: - specifier: ^9.0.1 - version: 9.0.1 devDependencies: + '@eliassko/logger': + specifier: ^1.1.0 + version: 1.1.0 '@types/mocha': specifier: ^10.0.6 version: 10.0.6 @@ -1433,6 +1430,9 @@ packages: search-insights: optional: true + '@eliassko/logger@1.1.0': + resolution: {integrity: sha512-NKM0ICa914jBhSXnEtRvCKNg5KPNh9irJAA1qkmPACJ2UlGShe7xOzwv0UpicOtlTnXQ1NmiDCQl+bbW5mGJ9w==} + '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} engines: {node: '>=12'} @@ -6060,8 +6060,9 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-inspect@1.13.1: - resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} + object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} object-is@1.1.6: resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} @@ -7622,27 +7623,6 @@ packages: vm-browserify@1.1.2: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} - vscode-jsonrpc@8.2.0: - resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} - engines: {node: '>=14.0.0'} - - vscode-languageclient@9.0.1: - resolution: {integrity: sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==} - engines: {vscode: ^1.82.0} - - vscode-languageserver-protocol@3.17.5: - resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} - - vscode-languageserver-textdocument@1.0.11: - resolution: {integrity: sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==} - - vscode-languageserver-types@3.17.5: - resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} - - vscode-languageserver@9.0.1: - resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} - hasBin: true - vue-demi@0.14.7: resolution: {integrity: sha512-EOG8KXDQNwkJILkx/gPcoL/7vH+hORoBaKgGe+6W7VFMvCYJfmF2dGbvgDroVnI8LU7/kTu8mbjRZGBU1z9NTA==} engines: {node: '>=12'} @@ -8900,6 +8880,11 @@ snapshots: transitivePeerDependencies: - '@algolia/client-search' + '@eliassko/logger@1.1.0': + dependencies: + chalk: 5.3.0 + object-inspect: 1.13.2 + '@esbuild/aix-ppc64@0.19.12': optional: true @@ -12261,7 +12246,7 @@ snapshots: is-string: 1.0.7 is-typed-array: 1.1.13 is-weakref: 1.0.2 - object-inspect: 1.13.1 + object-inspect: 1.13.2 object-keys: 1.1.1 object.assign: 4.1.5 regexp.prototype.flags: 1.5.2 @@ -14641,7 +14626,7 @@ snapshots: object-assign@4.1.1: {} - object-inspect@1.13.1: {} + object-inspect@1.13.2: {} object-is@1.1.6: dependencies: @@ -15482,7 +15467,7 @@ snapshots: call-bind: 1.0.7 es-errors: 1.3.0 get-intrinsic: 1.2.4 - object-inspect: 1.13.1 + object-inspect: 1.13.2 siginfo@2.0.0: {} @@ -16344,27 +16329,6 @@ snapshots: vm-browserify@1.1.2: {} - vscode-jsonrpc@8.2.0: {} - - vscode-languageclient@9.0.1: - dependencies: - minimatch: 5.1.6 - semver: 7.6.0 - vscode-languageserver-protocol: 3.17.5 - - vscode-languageserver-protocol@3.17.5: - dependencies: - vscode-jsonrpc: 8.2.0 - vscode-languageserver-types: 3.17.5 - - vscode-languageserver-textdocument@1.0.11: {} - - vscode-languageserver-types@3.17.5: {} - - vscode-languageserver@9.0.1: - dependencies: - vscode-languageserver-protocol: 3.17.5 - vue-demi@0.14.7(vue@3.4.23(typescript@5.4.5)): dependencies: vue: 3.4.23(typescript@5.4.5) diff --git a/tsconfig.json b/tsconfig.json index e105cd8..7fb34fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,7 @@ { "path": "packages/config/tsconfig.json" }, { "path": "packages/eslint/tsconfig.json" }, { "path": "packages/fabricator/tsconfig.json" }, - { "path": "packages/language-server/tsconfig.json" }, + { "path": "packages/language-service/tsconfig.json" }, { "path": "packages/location/tsconfig.json" }, { "path": "packages/parser/tsconfig.json" }, { "path": "packages/ssr/tsconfig.json" }, From 350bb6606482b7583eecfbea256a1a4ddc6c5f69 Mon Sep 17 00:00:00 2001 From: Elias Skogevall Date: Tue, 25 Jun 2024 09:28:34 +0000 Subject: [PATCH 2/8] fix: remove old debug logging --- packages/language-service/src/utils/text-rendering.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/language-service/src/utils/text-rendering.ts b/packages/language-service/src/utils/text-rendering.ts index 0c4681d..01be3f9 100644 --- a/packages/language-service/src/utils/text-rendering.ts +++ b/packages/language-service/src/utils/text-rendering.ts @@ -157,7 +157,6 @@ export function documentationToMarkdown( * Convert `@link` inline tags to markdown links */ function convertLinkTags(parts: readonly ts.SymbolDisplayPart[]): string { - console.dir(parts, { depth: 100 }); const out: string[] = []; let currentLink: @@ -257,6 +256,5 @@ export function quickInfoToMarkdown(quickInfo: ts.QuickInfo): string { ] .filter((v) => !!v) .join("\n\n"); - console.log(l); return l; } From e5417ca6cc4e6a79afcbf3d049cafbe76a38206a Mon Sep 17 00:00:00 2001 From: Elias Skogevall Date: Tue, 25 Jun 2024 09:28:49 +0000 Subject: [PATCH 3/8] chore: add config to jest runner extension --- .vscode/settings.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 4fadaf2..6782948 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -47,5 +47,7 @@ "editor.wordWrapColumn": 100, "editor.rulers": [100] }, - "todo-tree.highlights.enabled": false + "todo-tree.highlights.enabled": false, + "jestrunner.jestCommand": "clear; node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", + "jestrunner.changeDirectoryToWorkspaceRoot": false } From b98bb4c90d87a64378bd0a566c5653790a9e84e9 Mon Sep 17 00:00:00 2001 From: Elias Skogevall Date: Tue, 25 Jun 2024 09:28:58 +0000 Subject: [PATCH 4/8] test: add tests to language service --- .../src/features/__fixtures__/hover.html | 3 ++ .../features/__fixtures__/hover.viewmodel.ts | 3 ++ .../src/features/hover.spec.ts | 41 +++++++++++++++++++ packages/language-service/tsconfig.json | 4 +- packages/language-service/tsconfig.lib.json | 3 +- 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 packages/language-service/src/features/__fixtures__/hover.html create mode 100644 packages/language-service/src/features/__fixtures__/hover.viewmodel.ts create mode 100644 packages/language-service/src/features/hover.spec.ts diff --git a/packages/language-service/src/features/__fixtures__/hover.html b/packages/language-service/src/features/__fixtures__/hover.html new file mode 100644 index 0000000..5c9fd2b --- /dev/null +++ b/packages/language-service/src/features/__fixtures__/hover.html @@ -0,0 +1,3 @@ + +

+ diff --git a/packages/language-service/src/features/__fixtures__/hover.viewmodel.ts b/packages/language-service/src/features/__fixtures__/hover.viewmodel.ts new file mode 100644 index 0000000..f15b597 --- /dev/null +++ b/packages/language-service/src/features/__fixtures__/hover.viewmodel.ts @@ -0,0 +1,3 @@ +export default class { + hello = "hello"; +} diff --git a/packages/language-service/src/features/hover.spec.ts b/packages/language-service/src/features/hover.spec.ts new file mode 100644 index 0000000..df55527 --- /dev/null +++ b/packages/language-service/src/features/hover.spec.ts @@ -0,0 +1,41 @@ +import { LanguageService } from "../index.js"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +describe("hover", () => { + let service: LanguageService; + let fileName: string; + + beforeAll(async () => { + service = new LanguageService({ + worker: false, + }); + fileName = fileURLToPath( + new URL("__fixtures__/hover.html", import.meta.url), + ); + const text = await readFile(fileName, "utf8"); + await service.openDocument(fileName, text); + }); + + it("provides hover details for bindings (name)", async () => { + const hover = await service.getHover({ + fileName: fileName, + position: { + line: 1, + column: 17, + }, + }); + expect(hover).not.toBe(null); + }); + + it("provides hover details for viewmodel property", async () => { + const hover = await service.getHover({ + fileName: fileName, + position: { + line: 1, + column: 23, + }, + }); + expect(hover).not.toBe(null); + }); +}); diff --git a/packages/language-service/tsconfig.json b/packages/language-service/tsconfig.json index 79e5b05..84d11f3 100644 --- a/packages/language-service/tsconfig.json +++ b/packages/language-service/tsconfig.json @@ -1,7 +1,7 @@ { "references": [ - { "path": "tsconfig.lib.json" } - // { "path": "tsconfig.spec.json" } + { "path": "tsconfig.lib.json" }, + { "path": "tsconfig.spec.json" } ], "include": [] } diff --git a/packages/language-service/tsconfig.lib.json b/packages/language-service/tsconfig.lib.json index a42d6f2..efd3a03 100644 --- a/packages/language-service/tsconfig.lib.json +++ b/packages/language-service/tsconfig.lib.json @@ -1,4 +1,5 @@ { "extends": ["@tools/tsconfig/node.json"], - "include": ["src"] + "include": ["src"], + "exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "**/__fixtures__"] } From 7d7ce0019fd047e7a05977c334b746507e9622ac Mon Sep 17 00:00:00 2001 From: Elias Skogevall Date: Tue, 25 Jun 2024 09:30:42 +0000 Subject: [PATCH 5/8] chore: ignore fixtures in eslint --- eslint.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/eslint.config.js b/eslint.config.js index aa362d1..ab637e9 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,6 +20,7 @@ export default config([ "**/vite.config.*", "**/jest.config.ts", "**/coverage/", + "**/__fixtures__/", // documentation "docs/**/*", From b0cc6afed815c26026a69b5acf1112d9f2ce47b6 Mon Sep 17 00:00:00 2001 From: Elias Skogevall Date: Tue, 25 Jun 2024 09:31:21 +0000 Subject: [PATCH 6/8] chore: fix eslint errors --- packages/language-service/src/features/completion.ts | 2 +- packages/vscode/src/extension.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/language-service/src/features/completion.ts b/packages/language-service/src/features/completion.ts index 954d60d..31186b7 100644 --- a/packages/language-service/src/features/completion.ts +++ b/packages/language-service/src/features/completion.ts @@ -1,6 +1,6 @@ import type { LanguageServiceWorker } from "../private.js"; import { toPosition, type ProtocolPosition } from "../utils/position.js"; -import { Position } from "@knuckles/location"; +import { type Position } from "@knuckles/location"; import { Element } from "@knuckles/syntax-tree"; import { ts } from "ts-morph"; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index e63fbbd..29099ef 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -2,7 +2,7 @@ import { createDebounceAsync } from "./utils/debounce.js"; import { LogLevel, Logger } from "@eliassko/logger"; import { LanguageService, - LanguageServiceOptions, + type LanguageServiceOptions, } from "@knuckles/language-service"; import { join } from "node:path"; import * as vscode from "vscode"; From 9eac6c0a48dd239e5a7afb82bcf674d6beae400e Mon Sep 17 00:00:00 2001 From: Elias Skogevall Date: Thu, 27 Jun 2024 09:01:29 +0000 Subject: [PATCH 7/8] test: fix lg service tests --- packages/language-service/src/features/hover.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/language-service/src/features/hover.spec.ts b/packages/language-service/src/features/hover.spec.ts index df55527..a39adb6 100644 --- a/packages/language-service/src/features/hover.spec.ts +++ b/packages/language-service/src/features/hover.spec.ts @@ -1,4 +1,5 @@ import { LanguageService } from "../index.js"; +import { describe, beforeAll, it, expect } from "@jest/globals"; import { readFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; From d86aa2fb21d813a21683ffeeefd6ada07b114694 Mon Sep 17 00:00:00 2001 From: Martin Skogevall Date: Thu, 27 Jun 2024 11:28:34 +0200 Subject: [PATCH 8/8] Code actions (#156) --- packages/analyzer/src/issue.ts | 13 +- .../rules/virtual-element-end-notation.ts | 14 +- .../src/features/code-actions.ts | 112 ++++++++++++++ .../src/features/completion.ts | 1 - .../src/features/diagnostics.ts | 11 +- packages/language-service/src/index.ts | 1 + packages/language-service/src/private.ts | 2 + packages/language-service/src/public.ts | 5 + packages/language-service/src/utils/issue.ts | 10 ++ packages/vscode/src/extension.ts | 146 ++++++++++++++++++ packages/vscode/src/utils/diagnostics.ts | 15 ++ 11 files changed, 319 insertions(+), 11 deletions(-) create mode 100644 packages/language-service/src/features/code-actions.ts create mode 100644 packages/language-service/src/utils/issue.ts create mode 100644 packages/vscode/src/utils/diagnostics.ts 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 + ); +}