From 87af0aead8db809ffea402cfc3619de8190c6c16 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Fri, 23 Apr 2021 17:53:01 -0500 Subject: [PATCH] First Pass at LSP (#129) * feat(lsp): add HTML features to LSP * chore: add language server license * feat(lsp): add folding ranges, scaffold TS features * feat(lsp): TypeScript Language Service setup * refactor(lsp): cleanup typescript completion providr * chore: format * chore: cleanup eslint * fix: license * chore: remove comment * chore: add marketplace info * chore: publish --- .eslintignore | 1 + .vscode/launch.json | 20 +- .vscode/tasks.json | 18 +- .../snowpack/astro/components/BaseHead.astro | 2 +- .../astro/components/MainLayout.astro | 2 +- examples/snowpack/astro/components/index.ts | 1 + package.json | 2 +- vscode/assets/icon.png | Bin 0 -> 17936 bytes vscode/package-lock.json | 66 +++- vscode/package.json | 40 ++- .../client/src/features/defaultSettings.ts | 30 -- vscode/packages/client/src/html/autoClose.ts | 108 ++++++ vscode/packages/client/src/index.ts | 73 ++-- vscode/packages/server/LICENSE | 37 ++ vscode/packages/server/package-lock.json | 265 +++++++++++++- vscode/packages/server/package.json | 1 + .../server/src/core/config/ConfigManager.ts | 13 + .../packages/server/src/core/config/index.ts | 1 + .../server/src/core/documents/Document.ts | 159 +++++++++ .../src/core/documents/DocumentManager.ts | 104 ++++++ .../server/src/core/documents/index.ts | 2 + .../server/src/core/documents/parseAstro.ts | 74 ++++ .../server/src/core/documents/parseHtml.ts | 169 +++++++++ .../server/src/core/documents/utils.ts | 139 ++++++++ vscode/packages/server/src/index.ts | 122 +++++-- .../packages/server/src/plugins/PluginHost.ts | 166 +++++++++ .../server/src/plugins/astro/AstroPlugin.ts | 107 ++++++ .../server/src/plugins/html/HTMLPlugin.ts | 135 +++++++ vscode/packages/server/src/plugins/index.ts | 5 + .../packages/server/src/plugins/interfaces.ts | 217 ++++++++++++ .../typescript/LanguageServiceManager.ts | 82 +++++ .../src/plugins/typescript/SnapshotManager.ts | 333 ++++++++++++++++++ .../plugins/typescript/TypeScriptPlugin.ts | 89 +++++ .../src/plugins/typescript/astro-sys.ts | 42 +++ .../features/CompletionsProvider.ts | 123 +++++++ .../src/plugins/typescript/languageService.ts | 179 ++++++++++ .../server/src/plugins/typescript/utils.ts | 182 ++++++++++ vscode/packages/server/src/utils.ts | 98 ++++++ vscode/scripts/esbuild.config.mjs | 2 +- vscode/syntaxes/astro.tmLanguage.json | 28 +- 40 files changed, 3087 insertions(+), 150 deletions(-) create mode 100644 examples/snowpack/astro/components/index.ts create mode 100644 vscode/assets/icon.png delete mode 100644 vscode/packages/client/src/features/defaultSettings.ts create mode 100644 vscode/packages/client/src/html/autoClose.ts create mode 100644 vscode/packages/server/LICENSE create mode 100644 vscode/packages/server/src/core/config/ConfigManager.ts create mode 100644 vscode/packages/server/src/core/config/index.ts create mode 100644 vscode/packages/server/src/core/documents/Document.ts create mode 100644 vscode/packages/server/src/core/documents/DocumentManager.ts create mode 100644 vscode/packages/server/src/core/documents/index.ts create mode 100644 vscode/packages/server/src/core/documents/parseAstro.ts create mode 100644 vscode/packages/server/src/core/documents/parseHtml.ts create mode 100644 vscode/packages/server/src/core/documents/utils.ts create mode 100644 vscode/packages/server/src/plugins/PluginHost.ts create mode 100644 vscode/packages/server/src/plugins/astro/AstroPlugin.ts create mode 100644 vscode/packages/server/src/plugins/html/HTMLPlugin.ts create mode 100644 vscode/packages/server/src/plugins/index.ts create mode 100644 vscode/packages/server/src/plugins/interfaces.ts create mode 100644 vscode/packages/server/src/plugins/typescript/LanguageServiceManager.ts create mode 100644 vscode/packages/server/src/plugins/typescript/SnapshotManager.ts create mode 100644 vscode/packages/server/src/plugins/typescript/TypeScriptPlugin.ts create mode 100644 vscode/packages/server/src/plugins/typescript/astro-sys.ts create mode 100644 vscode/packages/server/src/plugins/typescript/features/CompletionsProvider.ts create mode 100644 vscode/packages/server/src/plugins/typescript/languageService.ts create mode 100644 vscode/packages/server/src/plugins/typescript/utils.ts create mode 100644 vscode/packages/server/src/utils.ts diff --git a/.eslintignore b/.eslintignore index d6b8f1b2f5..7c46fa8fe1 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ src/parser/parse/**/*.ts +vscode/**/*.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index d7e68782a8..35e13d1aae 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,18 +5,32 @@ { "type": "extensionHost", "request": "launch", - "name": "Launch Extension", + "name": "Launch Client", "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceRoot}/vscode" ], "outFiles": [ - "${workspaceRoot}/vscode/packages/client/out/**/*.js" + "${workspaceRoot}/vscode/dist/**/*.js" ], "preLaunchTask": { "type": "npm", - "script": "watch:extension" + "script": "build:extension" } }, + { + "type": "node", + "request": "attach", + "name": "Attach to Server", + "port": 6040, + "restart": true, + "outFiles": ["${workspaceRoot}/vscode/dist/**/*.js"] + }, + ], + "compounds": [ + { + "name": "Launch Extension", + "configurations": ["Launch Client", "Attach to Server"] + } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index cd137b601e..073d95220f 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ "tasks": [ { "type": "npm", - "script": "compile:extension", + "script": "build:extension", "group": "build", "presentation": { "panel": "dedicated", @@ -13,22 +13,6 @@ "problemMatcher": [ "$tsc" ] - }, - { - "type": "npm", - "script": "watch:extension", - "isBackground": true, - "group": { - "kind": "build", - "isDefault": true - }, - "presentation": { - "panel": "dedicated", - "reveal": "never" - }, - "problemMatcher": [ - "$tsc-watch" - ] } ] } diff --git a/examples/snowpack/astro/components/BaseHead.astro b/examples/snowpack/astro/components/BaseHead.astro index b24861ca61..a96fc7327b 100644 --- a/examples/snowpack/astro/components/BaseHead.astro +++ b/examples/snowpack/astro/components/BaseHead.astro @@ -35,4 +35,4 @@ export let permalink: string; - \ No newline at end of file + diff --git a/examples/snowpack/astro/components/MainLayout.astro b/examples/snowpack/astro/components/MainLayout.astro index 852a6636f8..c9ce65f086 100644 --- a/examples/snowpack/astro/components/MainLayout.astro +++ b/examples/snowpack/astro/components/MainLayout.astro @@ -17,4 +17,4 @@ import Menu from './Menu.astro'; - \ No newline at end of file + diff --git a/examples/snowpack/astro/components/index.ts b/examples/snowpack/astro/components/index.ts new file mode 100644 index 0000000000..b9d3e23cbf --- /dev/null +++ b/examples/snowpack/astro/components/index.ts @@ -0,0 +1 @@ +console.log('Hello world!'); diff --git a/package.json b/package.json index 28a782a29c..dbb929b3e3 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "lint": "eslint 'src/**/*.{js,ts}'", "format": "prettier -w '{src,test}/**/*.{js,ts}'", "test": "uvu test -i fixtures -i test-utils.js", - "watch:extension": "cd vscode && npm run watch", + "build:extension": "cd vscode && npm run build", "publish-hidden": "npm run build && npm publish --tag shhhhh" }, "dependencies": { diff --git a/vscode/assets/icon.png b/vscode/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a37afb318378e872d50226e3fb728aa345375bb9 GIT binary patch literal 17936 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'includeLanguages'); - } - function setEmmetIncludeLanguages(value: Record) { - return vscode.workspace.getConfiguration('emmet').set('includeLanguages', value); - } -} diff --git a/vscode/packages/client/src/html/autoClose.ts b/vscode/packages/client/src/html/autoClose.ts new file mode 100644 index 0000000000..0dbce66c85 --- /dev/null +++ b/vscode/packages/client/src/html/autoClose.ts @@ -0,0 +1,108 @@ +// Original source: https://github.com/Microsoft/vscode/blob/master/extensions/html-language-features/client/src/tagClosing.ts + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +'use strict'; + +import { window, workspace, Disposable, TextDocument, Position, SnippetString } from 'vscode'; + +import { TextDocumentContentChangeEvent } from 'vscode-languageserver-protocol'; + +/** */ +export function activateTagClosing( + tagProvider: (document: TextDocument, position: Position) => Thenable, + supportedLanguages: { [id: string]: boolean }, + configName: string +): Disposable { + const disposables: Disposable[] = []; + workspace.onDidChangeTextDocument( + (event) => onDidChangeTextDocument(event.document, event.contentChanges), + null, + disposables + ); + + let isEnabled = false; + updateEnabledState(); + window.onDidChangeActiveTextEditor(updateEnabledState, null, disposables); + + let timeout: NodeJS.Timer | undefined = void 0; + + /** Check if this feature is enabled */ + function updateEnabledState() { + isEnabled = false; + const editor = window.activeTextEditor; + if (!editor) { + return; + } + const document = editor.document; + if (!supportedLanguages[document.languageId]) { + return; + } + if (!workspace.getConfiguration(void 0, document.uri).get(configName)) { + return; + } + isEnabled = true; + } + + /** Handle text document changes */ + function onDidChangeTextDocument( + document: TextDocument, + changes: readonly TextDocumentContentChangeEvent[] + ) { + if (!isEnabled) { + return; + } + const activeDocument = window.activeTextEditor && window.activeTextEditor.document; + if (document !== activeDocument || changes.length === 0) { + return; + } + if (typeof timeout !== 'undefined') { + clearTimeout(timeout); + } + const lastChange = changes[changes.length - 1]; + const lastCharacter = lastChange.text[lastChange.text.length - 1]; + if ( + ('range' in lastChange && (lastChange.rangeLength ?? 0) > 0) || + (lastCharacter !== '>' && lastCharacter !== '/') + ) { + return; + } + const rangeStart = + 'range' in lastChange + ? lastChange.range.start + : new Position(0, document.getText().length); + const version = document.version; + timeout = setTimeout(() => { + const position = new Position( + rangeStart.line, + rangeStart.character + lastChange.text.length + ); + tagProvider(document, position).then((text) => { + if (text && isEnabled) { + const activeEditor = window.activeTextEditor; + if (activeEditor) { + const activeDocument = activeEditor.document; + if (document === activeDocument && activeDocument.version === version) { + const selections = activeEditor.selections; + if ( + selections.length && + selections.some((s) => s.active.isEqual(position)) + ) { + activeEditor.insertSnippet( + new SnippetString(text), + selections.map((s) => s.active) + ); + } else { + activeEditor.insertSnippet(new SnippetString(text), position); + } + } + } + } + }); + timeout = void 0; + }, 100); + } + return Disposable.from(...disposables); +} diff --git a/vscode/packages/client/src/index.ts b/vscode/packages/client/src/index.ts index c7233892b0..9ed016eb09 100644 --- a/vscode/packages/client/src/index.ts +++ b/vscode/packages/client/src/index.ts @@ -1,21 +1,22 @@ import * as path from 'path'; import * as vscode from 'vscode'; import * as lsp from 'vscode-languageclient/node'; - -import * as defaultSettings from './features/defaultSettings.js'; +import { activateTagClosing } from './html/autoClose'; let docClient: lsp.LanguageClient; +const TagCloseRequest: lsp.RequestType = new lsp.RequestType('html/tag'); + +/** */ export async function activate(context: vscode.ExtensionContext) { docClient = createLanguageService(context, 'doc', 'astro', 'Astro', 6040); - defaultSettings.activate(); - await docClient.onReady(); - startEmbeddedLanguageServices(); } +/** */ function createLanguageService(context: vscode.ExtensionContext, mode: 'doc', id: string, name: string, port: number) { + const { workspace } = vscode; const serverModule = context.asAbsolutePath(path.join('dist', 'server.js')); const debugOptions = { execArgv: ['--nolazy', '--inspect=' + port] }; const serverOptions: lsp.ServerOptions = { @@ -33,47 +34,33 @@ function createLanguageService(context: vscode.ExtensionContext, mode: 'doc', id }; const clientOptions: lsp.LanguageClientOptions = { documentSelector: [{ scheme: 'file', language: 'astro' }], - initializationOptions: serverInitOptions, + synchronize: { + configurationSection: ['javascript', 'typescript', 'prettier'], + fileEvents: workspace.createFileSystemWatcher('{**/*.js,**/*.ts}', false, false, false), + }, + initializationOptions: { + ...serverInitOptions, + configuration: { + prettier: workspace.getConfiguration('prettier'), + emmet: workspace.getConfiguration('emmet'), + typescript: workspace.getConfiguration('typescript'), + javascript: workspace.getConfiguration('javascript'), + }, + dontFilterIncompleteCompletions: true, // VSCode filters client side and is smarter at it than us + }, }; const client = new lsp.LanguageClient(id, name, serverOptions, clientOptions); + context.subscriptions.push(client.start()); + client.onReady().then(() => { + const tagRequestor = (document: vscode.TextDocument, position: vscode.Position) => { + const param = client.code2ProtocolConverter.asTextDocumentPositionParams(document, position); + return client.sendRequest(TagCloseRequest, param); + }; + const disposable = activateTagClosing(tagRequestor, { astro: true }, 'html.autoClosingTags'); + context.subscriptions.push(disposable); + }); + return client; } - -async function startEmbeddedLanguageServices() { - const ts = vscode.extensions.getExtension('vscode.typescript-language-features'); - const css = vscode.extensions.getExtension('vscode.css-language-features'); - const html = vscode.extensions.getExtension('vscode.html-language-features'); - - if (ts && !ts.isActive) { - await ts.activate(); - } - if (css && !css.isActive) { - await css.activate(); - } - if (html && !html.isActive) { - await html.activate(); - } - - /* from html-language-features */ - const EMPTY_ELEMENTS: string[] = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr']; - vscode.languages.setLanguageConfiguration('astro', { - indentationRules: { - increaseIndentPattern: /<(?!\?|(?:area|base|br|col|frame|hr|html|img|input|link|meta|param)\b|[^>]*\/>)([-_\.A-Za-z0-9]+)(?=\s|>)\b[^>]*>(?!.*<\/\1>)|)|\{[^}"']*$/, - decreaseIndentPattern: /^\s*(<\/(?!html)[-_\.A-Za-z0-9]+\b[^>]*>|-->|\})/, - }, - wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g, - onEnterRules: [ - { - beforeText: new RegExp(`<(?!(?:${EMPTY_ELEMENTS.join('|')}))([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$`, 'i'), - afterText: /^<\/([_:\w][_:\w-.\d]*)\s*>/i, - action: { indentAction: vscode.IndentAction.IndentOutdent }, - }, - { - beforeText: new RegExp(`<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`, 'i'), - action: { indentAction: vscode.IndentAction.Indent }, - }, - ], - }); -} diff --git a/vscode/packages/server/LICENSE b/vscode/packages/server/LICENSE new file mode 100644 index 0000000000..1dbac4c72e --- /dev/null +++ b/vscode/packages/server/LICENSE @@ -0,0 +1,37 @@ +MIT License + +Copyright (c) 2021 Nate Moore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +""" + +This license applies to code originating from the https://github.com/sveltejs/language-tools repository, +which has provided an extremely solid foundation for us to build upon: + +Copyright (c) 2020-Present [these people](https://github.com/sveltejs/language-tools/graphs/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" diff --git a/vscode/packages/server/package-lock.json b/vscode/packages/server/package-lock.json index 71cdde13ce..95b88e9cc1 100644 --- a/vscode/packages/server/package-lock.json +++ b/vscode/packages/server/package-lock.json @@ -4,31 +4,123 @@ "lockfileVersion": 1, "requires": true, "dependencies": { + "@emmetio/abbreviation": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.2.2.tgz", + "integrity": "sha512-TtE/dBnkTCct8+LntkqVrwqQao6EnPAs1YN3cUgxOxTaBlesBCY37ROUAVZrRlG64GNnVShdl/b70RfAI3w5lw==", + "requires": { + "@emmetio/scanner": "^1.0.0" + } + }, + "@emmetio/css-abbreviation": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.4.tgz", + "integrity": "sha512-qk9L60Y+uRtM5CPbB0y+QNl/1XKE09mSO+AhhSauIfr2YOx/ta3NJw2d8RtCFxgzHeRqFRr8jgyzThbu+MZ4Uw==", + "requires": { + "@emmetio/scanner": "^1.0.0" + } + }, + "@emmetio/scanner": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.0.tgz", + "integrity": "sha512-8HqW8EVqjnCmWXVpqAOZf+EGESdkR27odcMMMGefgKXtar00SoYNSryGv//TELI4T3QFsECo78p+0lmalk/CFA==" + }, "astro": { "version": "file:../..", "dev": true, "requires": { - "typescript": "^4.2.3", - "vscode-languageserver": "^7.0.0" + "vscode-emmet-helper": "2.1.2", + "vscode-html-languageservice": "^3.0.3" }, "dependencies": { + "@emmetio/abbreviation": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.2.2.tgz", + "integrity": "sha512-TtE/dBnkTCct8+LntkqVrwqQao6EnPAs1YN3cUgxOxTaBlesBCY37ROUAVZrRlG64GNnVShdl/b70RfAI3w5lw==", + "dev": true, + "requires": { + "@emmetio/scanner": "^1.0.0" + } + }, + "@emmetio/css-abbreviation": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.4.tgz", + "integrity": "sha512-qk9L60Y+uRtM5CPbB0y+QNl/1XKE09mSO+AhhSauIfr2YOx/ta3NJw2d8RtCFxgzHeRqFRr8jgyzThbu+MZ4Uw==", + "dev": true, + "requires": { + "@emmetio/scanner": "^1.0.0" + } + }, + "@emmetio/scanner": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.0.tgz", + "integrity": "sha512-8HqW8EVqjnCmWXVpqAOZf+EGESdkR27odcMMMGefgKXtar00SoYNSryGv//TELI4T3QFsECo78p+0lmalk/CFA==", + "dev": true + }, + "emmet": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.3.4.tgz", + "integrity": "sha512-3IqSwmO+N2ZGeuhDyhV/TIOJFUbkChi53bcasSNRE7Yd+4eorbbYz4e53TpMECt38NtYkZNupQCZRlwdAYA42A==", + "dev": true, + "requires": { + "@emmetio/abbreviation": "^2.2.2", + "@emmetio/css-abbreviation": "^2.1.4" + } + }, + "jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", + "dev": true + }, "typescript": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.3.tgz", - "integrity": "sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==", - "dev": true + "integrity": "sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==" + }, + "vscode-emmet-helper": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vscode-emmet-helper/-/vscode-emmet-helper-2.1.2.tgz", + "integrity": "sha512-Fy6UNawSgxE3Kuqi54vSXohf03iOIrp1A74ReAgzvGP9Yt7fUAvkqF6No2WAc34/w0oWAHAeqoBNqmKKWh6U5w==", + "dev": true, + "requires": { + "emmet": "^2.1.5", + "jsonc-parser": "^2.3.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.15.1", + "vscode-nls": "^5.0.0", + "vscode-uri": "^2.1.2" + } + }, + "vscode-html-languageservice": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-3.2.0.tgz", + "integrity": "sha512-aLWIoWkvb5HYTVE0kI9/u3P0ZAJGrYOSAAE6L0wqB9radKRtbJNrF9+BjSUFyCgBdNBE/GFExo35LoknQDJrfw==", + "dev": true, + "requires": { + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "3.16.0-next.2", + "vscode-nls": "^5.0.0", + "vscode-uri": "^2.1.2" + }, + "dependencies": { + "vscode-languageserver-types": { + "version": "3.16.0-next.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0-next.2.tgz", + "integrity": "sha512-QjXB7CKIfFzKbiCJC4OWC8xUncLsxo19FzGVp/ADFvvi87PlmBSCAtZI5xwGjF5qE0xkLf0jjKUn3DzmpDP52Q==", + "dev": true + } + } }, "vscode-jsonrpc": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-6.0.0.tgz", - "integrity": "sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==", - "dev": true + "integrity": "sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==" }, "vscode-languageserver": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-7.0.0.tgz", "integrity": "sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==", - "dev": true, "requires": { "vscode-languageserver-protocol": "3.16.0" } @@ -37,17 +129,167 @@ "version": "3.16.0", "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.16.0.tgz", "integrity": "sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A==", - "dev": true, "requires": { "vscode-jsonrpc": "6.0.0", "vscode-languageserver-types": "3.16.0" } }, + "vscode-languageserver-textdocument": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz", + "integrity": "sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA==", + "dev": true + }, "vscode-languageserver-types": { "version": "3.16.0", "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz", - "integrity": "sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==", + "integrity": "sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==" + }, + "vscode-nls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.0.0.tgz", + "integrity": "sha512-u0Lw+IYlgbEJFF6/qAqG2d1jQmJl0eyAGJHoAJqr2HT4M2BNuQYSEiSE75f52pXHSJm8AlTjnLLbBFPrdz2hpA==", "dev": true + }, + "vscode-uri": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", + "integrity": "sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==", + "dev": true + } + } + }, + "command-exists": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.6.tgz", + "integrity": "sha512-Qst/zUUNmS/z3WziPxyqjrcz09pm+2Knbs5mAZL4VAE0sSrNY1/w8+/YxeHcoBTsO6iojA6BW7eFf27Eg2MRuw==" + }, + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "crypto-random-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-1.0.0.tgz", + "integrity": "sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=" + }, + "emmet": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.3.4.tgz", + "integrity": "sha512-3IqSwmO+N2ZGeuhDyhV/TIOJFUbkChi53bcasSNRE7Yd+4eorbbYz4e53TpMECt38NtYkZNupQCZRlwdAYA42A==", + "requires": { + "@emmetio/abbreviation": "^2.2.2", + "@emmetio/css-abbreviation": "^2.1.4" + } + }, + "fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "graceful-fs": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz", + "integrity": "sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==" + }, + "jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==" + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "p-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-debounce/-/p-debounce-1.0.0.tgz", + "integrity": "sha1-y38svu/YegnrqGHhErZ1J+Yh4v0=" + }, + "temp-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-1.0.0.tgz", + "integrity": "sha1-CnwOom06Oa+n4OvqnB/AvE2qAR0=" + }, + "tempy": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.2.1.tgz", + "integrity": "sha512-LB83o9bfZGrntdqPuRdanIVCPReam9SOZKW0fOy5I9X3A854GGWi0tjCqoXEk84XIEYBc/x9Hq3EFop/H5wJaw==", + "requires": { + "temp-dir": "^1.0.0", + "unique-string": "^1.0.0" + } + }, + "typescript-language-server": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/typescript-language-server/-/typescript-language-server-0.5.1.tgz", + "integrity": "sha512-60Kguhwk/R1BB4pEIb6B9C7Ix7JzLzYnsODlmorYMPjMeEV0rCBqTR6FGAj4wVw/eHrHcpwLENmmURKUd8aybA==", + "requires": { + "command-exists": "1.2.6", + "commander": "^2.11.0", + "fs-extra": "^7.0.0", + "p-debounce": "^1.0.0", + "tempy": "^0.2.1", + "vscode-languageserver": "^5.3.0-next", + "vscode-uri": "^1.0.5" + }, + "dependencies": { + "vscode-languageserver": { + "version": "5.3.0-next.10", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-5.3.0-next.10.tgz", + "integrity": "sha512-QL7Fe1FT6PdLtVzwJeZ78pTic4eZbzLRy7yAQgPb9xalqqgZESR0+yDZPwJrM3E7PzOmwHBceYcJR54eQZ7Kng==", + "requires": { + "vscode-languageserver-protocol": "^3.15.0-next.8", + "vscode-textbuffer": "^1.0.0" + } + }, + "vscode-uri": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-1.0.8.tgz", + "integrity": "sha512-obtSWTlbJ+a+TFRYGaUumtVwb+InIUVI0Lu0VBUAPmj2cU5JutEXg3xUE0c2J5Tcy7h2DEKVJBFi+Y9ZSFzzPQ==" + } + } + }, + "unique-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz", + "integrity": "sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=", + "requires": { + "crypto-random-string": "^1.0.0" + } + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, + "vscode-emmet-helper": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/vscode-emmet-helper/-/vscode-emmet-helper-2.1.2.tgz", + "integrity": "sha512-Fy6UNawSgxE3Kuqi54vSXohf03iOIrp1A74ReAgzvGP9Yt7fUAvkqF6No2WAc34/w0oWAHAeqoBNqmKKWh6U5w==", + "requires": { + "emmet": "^2.1.5", + "jsonc-parser": "^2.3.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.15.1", + "vscode-nls": "^5.0.0", + "vscode-uri": "^2.1.2" + }, + "dependencies": { + "vscode-languageserver-types": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0.tgz", + "integrity": "sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==" } } }, @@ -106,6 +348,11 @@ "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.0.0.tgz", "integrity": "sha512-u0Lw+IYlgbEJFF6/qAqG2d1jQmJl0eyAGJHoAJqr2HT4M2BNuQYSEiSE75f52pXHSJm8AlTjnLLbBFPrdz2hpA==" }, + "vscode-textbuffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vscode-textbuffer/-/vscode-textbuffer-1.0.0.tgz", + "integrity": "sha512-zPaHo4urgpwsm+PrJWfNakolRpryNja18SUip/qIIsfhuEqEIPEXMxHOlFPjvDC4JgTaimkncNW7UMXRJTY6ow==" + }, "vscode-uri": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-2.1.2.tgz", diff --git a/vscode/packages/server/package.json b/vscode/packages/server/package.json index d5837cc08f..39fc8a1dc1 100644 --- a/vscode/packages/server/package.json +++ b/vscode/packages/server/package.json @@ -14,6 +14,7 @@ "astro": "file:../../" }, "dependencies": { + "vscode-emmet-helper": "2.1.2", "vscode-html-languageservice": "^3.0.3", "vscode-languageserver": "^6.1.1", "vscode-languageserver-textdocument": "^1.0.1" diff --git a/vscode/packages/server/src/core/config/ConfigManager.ts b/vscode/packages/server/src/core/config/ConfigManager.ts new file mode 100644 index 0000000000..4c1c23b135 --- /dev/null +++ b/vscode/packages/server/src/core/config/ConfigManager.ts @@ -0,0 +1,13 @@ +import { VSCodeEmmetConfig } from 'vscode-emmet-helper'; + +export class ConfigManager { + private emmetConfig: VSCodeEmmetConfig = {}; + + updateEmmetConfig(config: VSCodeEmmetConfig): void { + this.emmetConfig = config || {}; + } + + getEmmetConfig(): VSCodeEmmetConfig { + return this.emmetConfig; + } +} diff --git a/vscode/packages/server/src/core/config/index.ts b/vscode/packages/server/src/core/config/index.ts new file mode 100644 index 0000000000..cd869b795b --- /dev/null +++ b/vscode/packages/server/src/core/config/index.ts @@ -0,0 +1 @@ +export * from './ConfigManager'; diff --git a/vscode/packages/server/src/core/documents/Document.ts b/vscode/packages/server/src/core/documents/Document.ts new file mode 100644 index 0000000000..4f90813ee9 --- /dev/null +++ b/vscode/packages/server/src/core/documents/Document.ts @@ -0,0 +1,159 @@ +import { Position, Range } from 'vscode-languageserver'; +import { TextDocument } from 'vscode-languageserver-textdocument'; +import { HTMLDocument } from 'vscode-html-languageservice'; + +import { clamp, urlToPath } from '../../utils'; +import { parseHtml } from './parseHtml'; +import { parseAstro, AstroDocument } from './parseAstro'; + +export class Document implements TextDocument { + + private content: string; + + languageId = 'astro'; + version = 0; + html!: HTMLDocument; + astro!: AstroDocument; + + constructor(public uri: string, text: string) { + this.content = text; + this.updateDocInfo(); + } + + private updateDocInfo() { + this.html = parseHtml(this.content); + this.astro = parseAstro(this.content); + } + + setText(text: string) { + this.content = text; + this.version++; + this.updateDocInfo(); + } + + /** + * Update the text between two positions. + * @param text The new text slice + * @param start Start offset of the new text + * @param end End offset of the new text + */ + update(text: string, start: number, end: number): void { + const content = this.getText(); + this.setText(content.slice(0, start) + text + content.slice(end)); + } + + getText(): string { + return this.content + } + + /** + * Get the line and character based on the offset + * @param offset The index of the position + */ + positionAt(offset: number): Position { + offset = clamp(offset, 0, this.getTextLength()); + + const lineOffsets = this.getLineOffsets(); + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return Position.create(0, offset); + } + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + return Position.create(line, offset - lineOffsets[line]); + } + + /** + * Get the index of the line and character position + * @param position Line and character position + */ + offsetAt(position: Position): number { + const lineOffsets = this.getLineOffsets(); + + if (position.line >= lineOffsets.length) { + return this.getTextLength(); + } else if (position.line < 0) { + return 0; + } + + const lineOffset = lineOffsets[position.line]; + const nextLineOffset = + position.line + 1 < lineOffsets.length + ? lineOffsets[position.line + 1] + : this.getTextLength(); + + return clamp(nextLineOffset, lineOffset, lineOffset + position.character); + } + + getLineUntilOffset(offset: number): string { + const { line, character } = this.positionAt(offset); + return this.lines[line].slice(0, character); + } + + private getLineOffsets() { + const lineOffsets = []; + const text = this.getText(); + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + return lineOffsets; + } + + /** + * Get the length of the document's content + */ + getTextLength(): number { + return this.getText().length; + } + + /** + * Returns the file path if the url scheme is file + */ + getFilePath(): string | null { + return urlToPath(this.uri); + } + + /** + * Get URL file path. + */ + getURL() { + return this.uri; + } + + + get lines(): string[] { + return this.getText().split(/\r?\n/); + } + + get lineCount(): number { + return this.lines.length; + } + +} diff --git a/vscode/packages/server/src/core/documents/DocumentManager.ts b/vscode/packages/server/src/core/documents/DocumentManager.ts new file mode 100644 index 0000000000..6195514d80 --- /dev/null +++ b/vscode/packages/server/src/core/documents/DocumentManager.ts @@ -0,0 +1,104 @@ +import { EventEmitter } from 'events'; +import { + TextDocumentContentChangeEvent, + TextDocumentItem +} from 'vscode-languageserver'; +import { Document } from './Document'; +import { normalizeUri } from '../../utils'; + +export type DocumentEvent = 'documentOpen' | 'documentChange' | 'documentClose'; + +export class DocumentManager { + private emitter = new EventEmitter(); + private openedInClient = new Set(); + private documents: Map = new Map(); + private locked = new Set(); + private deleteCandidates = new Set(); + + constructor( + private createDocument: (textDocument: { uri: string, text: string }) => Document + ) {} + + get(uri: string) { + return this.documents.get(normalizeUri(uri)); + } + + openDocument(textDocument: TextDocumentItem) { + let document: Document; + if (this.documents.has(textDocument.uri)) { + document = this.get(textDocument.uri) as Document; + document.setText(textDocument.text); + } else { + document = this.createDocument(textDocument); + this.documents.set(normalizeUri(textDocument.uri), document); + this.notify('documentOpen', document); + } + + this.notify('documentChange', document); + + return document; + } + + closeDocument(uri: string) { + uri = normalizeUri(uri); + + const document = this.documents.get(uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + this.notify('documentClose', document); + + // Some plugin may prevent a document from actually being closed. + if (!this.locked.has(uri)) { + this.documents.delete(uri); + } else { + this.deleteCandidates.add(uri); + } + + this.openedInClient.delete(uri); + } + + updateDocument( + uri: string, + changes: TextDocumentContentChangeEvent[] + ) { + const document = this.documents.get(normalizeUri(uri)); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + for (const change of changes) { + let start = 0; + let end = 0; + if ('range' in change) { + start = document.offsetAt(change.range.start); + end = document.offsetAt(change.range.end); + } else { + end = document.getTextLength(); + } + + document.update(change.text, start, end); + } + + this.notify('documentChange', document); + } + + markAsOpenedInClient(uri: string) { + this.openedInClient.add(normalizeUri(uri)); + } + + getAllOpenedByClient() { + return Array.from(this.documents.entries()).filter((doc) => + this.openedInClient.has(doc[0]) + ); + } + + on(name: DocumentEvent, listener: (document: Document) => void) { + this.emitter.on(name, listener); + } + + private notify(name: DocumentEvent, document: Document) { + this.emitter.emit(name, document); + } +} diff --git a/vscode/packages/server/src/core/documents/index.ts b/vscode/packages/server/src/core/documents/index.ts new file mode 100644 index 0000000000..708a040c92 --- /dev/null +++ b/vscode/packages/server/src/core/documents/index.ts @@ -0,0 +1,2 @@ +export * from './Document'; +export * from './DocumentManager'; diff --git a/vscode/packages/server/src/core/documents/parseAstro.ts b/vscode/packages/server/src/core/documents/parseAstro.ts new file mode 100644 index 0000000000..e4f71721a5 --- /dev/null +++ b/vscode/packages/server/src/core/documents/parseAstro.ts @@ -0,0 +1,74 @@ +import { getFirstNonWhitespaceIndex } from './utils'; + +interface Frontmatter { + state: null | 'open' | 'closed'; + startOffset: null | number; + endOffset: null | number; +} + +interface Content { + firstNonWhitespaceOffset: null | number; +} + +export interface AstroDocument { + frontmatter: Frontmatter + content: Content; +} + +/** Parses a document to collect metadata about Astro features */ +export function parseAstro(content: string): AstroDocument { + const frontmatter = getFrontmatter(content) + return { + frontmatter, + content: getContent(content, frontmatter) + } +} + +/** Get frontmatter metadata */ +function getFrontmatter(content: string): Frontmatter { + /** Quickly check how many `---` blocks are in the document */ + function getFrontmatterState(): Frontmatter['state'] { + const parts = content.trim().split('---').length; + switch (parts) { + case 1: return null; + case 2: return 'open'; + default: return 'closed'; + } + } + const state = getFrontmatterState(); + + /** Construct a range containing the document's frontmatter */ + function getFrontmatterOffsets(): [number|null, number|null] { + const startOffset = content.indexOf('---'); + if (startOffset === -1) return [null, null]; + const endOffset = content.slice(startOffset + 3).indexOf('---') + 3; + if (endOffset === -1) return [startOffset, null]; + return [startOffset, endOffset]; + } + const [startOffset, endOffset] = getFrontmatterOffsets(); + + return { + state, + startOffset, + endOffset + }; +} + +/** Get content metadata */ +function getContent(content: string, frontmatter: Frontmatter): Content { + switch (frontmatter.state) { + case null: { + const offset = getFirstNonWhitespaceIndex(content); + return { firstNonWhitespaceOffset: offset === -1 ? null : offset } + } + case 'open': { + return { firstNonWhitespaceOffset: null } + } + case 'closed': { + const { endOffset } = frontmatter; + const end = (endOffset ?? 0) + 3; + const offset = getFirstNonWhitespaceIndex(content.slice(end)) + return { firstNonWhitespaceOffset: end + offset } + } + } +} diff --git a/vscode/packages/server/src/core/documents/parseHtml.ts b/vscode/packages/server/src/core/documents/parseHtml.ts new file mode 100644 index 0000000000..86af06008b --- /dev/null +++ b/vscode/packages/server/src/core/documents/parseHtml.ts @@ -0,0 +1,169 @@ +import { + getLanguageService, + HTMLDocument, + TokenType, + ScannerState, + Scanner, + Node, + Position +} from 'vscode-html-languageservice'; +import { Document } from './Document'; +import { isInsideExpression } from './utils'; + +const parser = getLanguageService(); + +/** + * Parses text as HTML + */ +export function parseHtml(text: string): HTMLDocument { + const preprocessed = preprocess(text); + + // We can safely only set getText because only this is used for parsing + const parsedDoc = parser.parseHTMLDocument({ getText: () => preprocessed }); + + return parsedDoc; +} + +const createScanner = parser.createScanner as ( + input: string, + initialOffset?: number, + initialState?: ScannerState +) => Scanner; + +/** + * scan the text and remove any `>` or `<` that cause the tag to end short, + */ +function preprocess(text: string) { + let scanner = createScanner(text); + let token = scanner.scan(); + let currentStartTagStart: number | null = null; + + while (token !== TokenType.EOS) { + const offset = scanner.getTokenOffset(); + + if (token === TokenType.StartTagOpen) { + currentStartTagStart = offset; + } + + if (token === TokenType.StartTagClose) { + if (shouldBlankStartOrEndTagLike(offset)) { + blankStartOrEndTagLike(offset); + } else { + currentStartTagStart = null; + } + } + + if (token === TokenType.StartTagSelfClose) { + currentStartTagStart = null; + } + + // + // https://github.com/microsoft/vscode-html-languageservice/blob/71806ef57be07e1068ee40900ef8b0899c80e68a/src/parser/htmlScanner.ts#L327 + if ( + token === TokenType.Unknown && + scanner.getScannerState() === ScannerState.WithinTag && + scanner.getTokenText() === '<' && + shouldBlankStartOrEndTagLike(offset) + ) { + blankStartOrEndTagLike(offset); + } + + token = scanner.scan(); + } + + return text; + + function shouldBlankStartOrEndTagLike(offset: number) { + // not null rather than falsy, otherwise it won't work on first tag(0) + return ( + currentStartTagStart !== null && + isInsideExpression(text, currentStartTagStart, offset) + ); + } + + function blankStartOrEndTagLike(offset: number) { + text = text.substring(0, offset) + ' ' + text.substring(offset + 1); + scanner = createScanner(text, offset, ScannerState.WithinTag); + } +} + +export interface AttributeContext { + name: string; + inValue: boolean; + valueRange?: [number, number]; +} + +export function getAttributeContextAtPosition( + document: Document, + position: Position +): AttributeContext | null { + const offset = document.offsetAt(position); + const { html } = document; + const tag = html.findNodeAt(offset); + + if (!inStartTag(offset, tag) || !tag.attributes) { + return null; + } + + const text = document.getText(); + const beforeStartTagEnd = + text.substring(0, tag.start) + preprocess(text.substring(tag.start, tag.startTagEnd)); + + const scanner = createScanner(beforeStartTagEnd, tag.start); + + let token = scanner.scan(); + let currentAttributeName: string | undefined; + const inTokenRange = () => + scanner.getTokenOffset() <= offset && offset <= scanner.getTokenEnd(); + while (token != TokenType.EOS) { + // adopted from https://github.com/microsoft/vscode-html-languageservice/blob/2f7ae4df298ac2c299a40e9024d118f4a9dc0c68/src/services/htmlCompletion.ts#L402 + if (token === TokenType.AttributeName) { + currentAttributeName = scanner.getTokenText(); + + if (inTokenRange()) { + return { + name: currentAttributeName, + inValue: false + }; + } + } else if (token === TokenType.DelimiterAssign) { + if (scanner.getTokenEnd() === offset && currentAttributeName) { + const nextToken = scanner.scan(); + + return { + name: currentAttributeName, + inValue: true, + valueRange: [ + offset, + nextToken === TokenType.AttributeValue ? scanner.getTokenEnd() : offset + ] + }; + } + } else if (token === TokenType.AttributeValue) { + if (inTokenRange() && currentAttributeName) { + let start = scanner.getTokenOffset(); + let end = scanner.getTokenEnd(); + const char = text[start]; + + if (char === '"' || char === "'") { + start++; + end--; + } + + return { + name: currentAttributeName, + inValue: true, + valueRange: [start, end] + }; + } + currentAttributeName = undefined; + } + token = scanner.scan(); + } + + return null; +} + +function inStartTag(offset: number, node: Node) { + return offset > node.start && node.startTagEnd != undefined && offset < node.startTagEnd; +} diff --git a/vscode/packages/server/src/core/documents/utils.ts b/vscode/packages/server/src/core/documents/utils.ts new file mode 100644 index 0000000000..6c69014d58 --- /dev/null +++ b/vscode/packages/server/src/core/documents/utils.ts @@ -0,0 +1,139 @@ +import { Position } from 'vscode-html-languageservice'; +import { clamp } from '../../utils'; + +/** + * Gets word range at position. + * Delimiter is by default a whitespace, but can be adjusted. + */ +export function getWordRangeAt( + str: string, + pos: number, + delimiterRegex = { left: /\S+$/, right: /\s/ } +): { start: number; end: number } { + let start = str.slice(0, pos).search(delimiterRegex.left); + if (start < 0) { + start = pos; + } + + let end = str.slice(pos).search(delimiterRegex.right); + if (end < 0) { + end = str.length; + } else { + end = end + pos; + } + + return { start, end }; +} + +/** + * Gets word at position. + * Delimiter is by default a whitespace, but can be adjusted. + */ +export function getWordAt( + str: string, + pos: number, + delimiterRegex = { left: /\S+$/, right: /\s/ } +): string { + const { start, end } = getWordRangeAt(str, pos, delimiterRegex); + return str.slice(start, end); +} + +/** + * Gets index of first-non-whitespace character. + */ +export function getFirstNonWhitespaceIndex(str: string): number { + return str.length - str.trimStart().length; +} + +/** checks if a position is currently inside of an expression */ +export function isInsideExpression(html: string, tagStart: number, position: number) { + const charactersInNode = html.substring(tagStart, position); + return charactersInNode.lastIndexOf('{') > charactersInNode.lastIndexOf('}'); +} + +/** + * Returns if a given offset is inside of the document frontmatter + */ +export function isInsideFrontmatter( + text: string, + offset: number +): boolean { + let start = text.slice(0, offset).trim().split('---').length; + let end = text.slice(offset).trim().split('---').length; + + return start > 1 && start < 3 && end >= 1; +} + +/** + * Get the line and character based on the offset + * @param offset The index of the position + * @param text The text for which the position should be retrived + */ +export function positionAt(offset: number, text: string): Position { + offset = clamp(offset, 0, text.length); + + const lineOffsets = getLineOffsets(text); + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return Position.create(0, offset); + } + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + return Position.create(line, offset - lineOffsets[line]); +} + +/** + * Get the offset of the line and character position + * @param position Line and character position + * @param text The text for which the offset should be retrived + */ +export function offsetAt(position: Position, text: string): number { + const lineOffsets = getLineOffsets(text); + + if (position.line >= lineOffsets.length) { + return text.length; + } else if (position.line < 0) { + return 0; + } + + const lineOffset = lineOffsets[position.line]; + const nextLineOffset = + position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : text.length; + + return clamp(nextLineOffset, lineOffset, lineOffset + position.character); +} + +function getLineOffsets(text: string) { + const lineOffsets = []; + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + return lineOffsets; +} diff --git a/vscode/packages/server/src/index.ts b/vscode/packages/server/src/index.ts index dda3699290..f72ad550ba 100644 --- a/vscode/packages/server/src/index.ts +++ b/vscode/packages/server/src/index.ts @@ -1,32 +1,104 @@ -import { getLanguageService } from 'vscode-html-languageservice'; -import { createConnection, ProposedFeatures, TextDocuments, TextDocumentSyncKind } from 'vscode-languageserver'; -import { TextDocument } from 'vscode-languageserver-textdocument'; +import { RequestType, TextDocumentPositionParams, createConnection, ProposedFeatures, TextDocumentSyncKind, TextDocumentIdentifier } from 'vscode-languageserver'; +import { Document, DocumentManager } from './core/documents'; +import { ConfigManager } from './core/config'; +import { PluginHost, HTMLPlugin, TypeScriptPlugin, AppCompletionItem, AstroPlugin } from './plugins'; +import { urlToPath } from './utils'; -let connection = createConnection(ProposedFeatures.all); -let documents: TextDocuments = new TextDocuments(TextDocument); +const TagCloseRequest: RequestType = new RequestType('html/tag'); -const htmlLanguageService = getLanguageService(); +/** */ +export function startServer() { + let connection = createConnection(ProposedFeatures.all); -connection.onInitialize(() => { - return { - capabilities: { - textDocumentSync: TextDocumentSyncKind.Full, - completionProvider: { - resolveProvider: false, + const docManager = new DocumentManager(({ uri, text }: { uri: string; text: string }) => new Document(uri, text)); + const configManager = new ConfigManager(); + const pluginHost = new PluginHost(docManager); + + connection.onInitialize((evt) => { + const workspaceUris = evt.workspaceFolders?.map((folder) => folder.uri.toString()) ?? [evt.rootUri ?? '']; + + pluginHost.register(new AstroPlugin(docManager, configManager)); + pluginHost.register(new HTMLPlugin(docManager, configManager)); + pluginHost.register(new TypeScriptPlugin(docManager, configManager, workspaceUris)); + configManager.updateEmmetConfig(evt.initializationOptions?.configuration?.emmet || evt.initializationOptions?.emmetConfig || {}); + + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + foldingRangeProvider: true, + completionProvider: { + resolveProvider: false, + triggerCharacters: [ + '.', + '"', + "'", + '`', + '/', + '@', + '<', + + // Emmet + '>', + '*', + '#', + '$', + '+', + '^', + '(', + '[', + '@', + '-', + // No whitespace because + // it makes for weird/too many completions + // of other completion providers + + // Astro + ':', + ], + }, }, - }, - }; -}); + }; + }); -connection.onCompletion(async (textDocumentPosition, token) => { - console.log(token); - const document = documents.get(textDocumentPosition.textDocument.uri); - if (!document) { - return null; - } + // Documents + connection.onDidOpenTextDocument((evt) => { + docManager.openDocument(evt.textDocument); + docManager.markAsOpenedInClient(evt.textDocument.uri); + }); - return htmlLanguageService.doComplete(document, textDocumentPosition.position, htmlLanguageService.parseHTMLDocument(document)); -}); + connection.onDidCloseTextDocument((evt) => docManager.closeDocument(evt.textDocument.uri)); -documents.listen(connection); -connection.listen(); + connection.onDidChangeTextDocument((evt) => docManager.updateDocument(evt.textDocument.uri, evt.contentChanges)); + + connection.onDidChangeWatchedFiles((evt) => { + const params = evt.changes.map(change => ({ + fileName: urlToPath(change.uri), + changeType: change.type + })).filter(change => !!change.fileName) + + pluginHost.onWatchFileChanges(params); + }); + + // Config + connection.onDidChangeConfiguration(({ settings }) => { + configManager.updateEmmetConfig(settings.emmet); + }); + + // Features + connection.onCompletion((evt) => pluginHost.getCompletions(evt.textDocument, evt.position, evt.context)); + connection.onCompletionResolve((completionItem) => { + const data = (completionItem as AppCompletionItem).data as TextDocumentIdentifier; + + if (!data) { + return completionItem; + } + + return pluginHost.resolveCompletion(data, completionItem); + }); + connection.onFoldingRanges((evt) => pluginHost.getFoldingRanges(evt.textDocument)); + connection.onRequest(TagCloseRequest, (evt: any) => pluginHost.doTagComplete(evt.textDocument, evt.position)); + + connection.listen(); +} + +startServer(); diff --git a/vscode/packages/server/src/plugins/PluginHost.ts b/vscode/packages/server/src/plugins/PluginHost.ts new file mode 100644 index 0000000000..72f098ca1e --- /dev/null +++ b/vscode/packages/server/src/plugins/PluginHost.ts @@ -0,0 +1,166 @@ + +import { + CompletionContext, + CompletionItem, + CompletionList, + Position, + TextDocumentIdentifier, +} from 'vscode-languageserver'; +import type { DocumentManager } from '../core/documents'; +import type * as d from './interfaces'; +import { flatten } from '../utils'; +import { FoldingRange } from 'vscode-languageserver-types'; + +// eslint-disable-next-line no-shadow +enum ExecuteMode { + None, + FirstNonNull, + Collect +} + +export class PluginHost { + private plugins: d.Plugin[] = []; + + constructor(private documentsManager: DocumentManager) {} + + register(plugin: d.Plugin) { + this.plugins.push(plugin); + } + + async getCompletions( + textDocument: TextDocumentIdentifier, + position: Position, + completionContext?: CompletionContext + ): Promise { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + const completions = ( + await this.execute( + 'getCompletions', + [document, position, completionContext], + ExecuteMode.Collect + ) + ).filter((completion) => completion != null); + + let flattenedCompletions = flatten(completions.map((completion) => completion.items)); + const isIncomplete = completions.reduce( + (incomplete, completion) => incomplete || completion.isIncomplete, + false as boolean + ); + + return CompletionList.create(flattenedCompletions, isIncomplete); + } + + async resolveCompletion( + textDocument: TextDocumentIdentifier, + completionItem: d.AppCompletionItem + ): Promise { + const document = this.getDocument(textDocument.uri); + + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + const result = await this.execute( + 'resolveCompletion', + [document, completionItem], + ExecuteMode.FirstNonNull + ); + + return result ?? completionItem; + } + + async doTagComplete( + textDocument: TextDocumentIdentifier, + position: Position + ): Promise { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + return this.execute( + 'doTagComplete', + [document, position], + ExecuteMode.FirstNonNull + ); + } + + async getFoldingRanges( + textDocument: TextDocumentIdentifier + ): Promise { + const document = this.getDocument(textDocument.uri); + if (!document) { + throw new Error('Cannot call methods on an unopened document'); + } + + const foldingRanges = flatten(await this.execute( + 'getFoldingRanges', + [document], + ExecuteMode.Collect + )).filter((completion) => completion != null) + + return foldingRanges; + } + + onWatchFileChanges(onWatchFileChangesParams: any[]): void { + for (const support of this.plugins) { + support.onWatchFileChanges?.(onWatchFileChangesParams); + } + } + + private getDocument(uri: string) { + return this.documentsManager.get(uri); + } + + private execute( + name: keyof d.LSProvider, + args: any[], + mode: ExecuteMode.FirstNonNull + ): Promise; + private execute( + name: keyof d.LSProvider, + args: any[], + mode: ExecuteMode.Collect + ): Promise; + private execute(name: keyof d.LSProvider, args: any[], mode: ExecuteMode.None): Promise; + private async execute( + name: keyof d.LSProvider, + args: any[], + mode: ExecuteMode + ): Promise<(T | null) | T[] | void> { + const plugins = this.plugins.filter((plugin) => typeof plugin[name] === 'function'); + + switch (mode) { + case ExecuteMode.FirstNonNull: + for (const plugin of plugins) { + const res = await this.tryExecutePlugin(plugin, name, args, null); + if (res != null) { + return res; + } + } + return null; + case ExecuteMode.Collect: + return Promise.all( + plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, [])) + ); + case ExecuteMode.None: + await Promise.all( + plugins.map((plugin) => this.tryExecutePlugin(plugin, name, args, null)) + ); + return; + } + } + + private async tryExecutePlugin(plugin: any, fnName: string, args: any[], failValue: any) { + try { + return await plugin[fnName](...args); + } catch (e) { + console.error(e); + return failValue; + } + } +} diff --git a/vscode/packages/server/src/plugins/astro/AstroPlugin.ts b/vscode/packages/server/src/plugins/astro/AstroPlugin.ts new file mode 100644 index 0000000000..0696504fc7 --- /dev/null +++ b/vscode/packages/server/src/plugins/astro/AstroPlugin.ts @@ -0,0 +1,107 @@ +import type { Document, DocumentManager } from '../../core/documents'; +import type { ConfigManager } from '../../core/config'; +import type { CompletionsProvider, AppCompletionItem, AppCompletionList, FoldingRangeProvider } from '../interfaces'; +import { CompletionContext, Position, CompletionList, CompletionItem, CompletionItemKind, InsertTextFormat, FoldingRange, TextEdit } from 'vscode-languageserver'; +import { isPossibleClientComponent } from '../../utils'; +import { FoldingRangeKind } from 'vscode-languageserver-types'; + +export class AstroPlugin implements CompletionsProvider, FoldingRangeProvider { + private readonly docManager: DocumentManager; + private readonly configManager: ConfigManager; + + constructor(docManager: DocumentManager, configManager: ConfigManager) { + this.docManager = docManager; + this.configManager = configManager; + } + + async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise { + const doc = this.docManager.get(document.uri); + if (!doc) return null; + + let items: CompletionItem[] = []; + + if (completionContext?.triggerCharacter === '-') { + const frontmatter = this.getComponentScriptCompletion(doc, position, completionContext); + if (frontmatter) items.push(frontmatter); + } + + if (completionContext?.triggerCharacter === ':') { + const clientHint = this.getClientHintCompletion(doc, position, completionContext); + if (clientHint) items.push(...clientHint); + } + + return CompletionList.create(items, true); + } + + async getFoldingRanges(document: Document): Promise { + const foldingRanges: FoldingRange[] = []; + const { frontmatter } = document.astro; + + // Currently editing frontmatter, don't fold + if (frontmatter.state !== 'closed') return foldingRanges; + + const start = document.positionAt(frontmatter.startOffset as number); + const end = document.positionAt((frontmatter.endOffset as number) - 3); + return [ + { + startLine: start.line, + startCharacter: start.character, + endLine: end.line, + endCharacter: end.character, + kind: FoldingRangeKind.Imports, + } + ]; + } + + private getClientHintCompletion(document: Document, position: Position, completionContext?: CompletionContext): CompletionItem[] | null { + const node = document.html.findNodeAt(document.offsetAt(position)); + if (!isPossibleClientComponent(node)) return null; + + return [ + { + label: ':load', + insertText: 'load', + commitCharacters: ['l'], + }, + { + label: ':idle', + insertText: 'idle', + commitCharacters: ['i'], + }, + { + label: ':visible', + insertText: 'visible', + commitCharacters: ['v'], + }, + ]; + } + + private getComponentScriptCompletion(document: Document, position: Position, completionContext?: CompletionContext): CompletionItem | null { + const base = { + kind: CompletionItemKind.Snippet, + label: '---', + sortText: '\0', + preselect: true, + detail: 'Component script', + insertTextFormat: InsertTextFormat.Snippet, + commitCharacters: ['-'], + }; + const prefix = document.getLineUntilOffset(document.offsetAt(position)); + + if (document.astro.frontmatter.state === null) { + return { + ...base, + insertText: '---\n$0\n---', + textEdit: prefix.match(/^\s*\-+/) ? TextEdit.replace({ start: { ...position, character: 0 }, end: position }, '---\n$0\n---') : undefined, + }; + } + if (document.astro.frontmatter.state === 'open') { + return { + ...base, + insertText: '---', + textEdit: prefix.match(/^\s*\-+/) ? TextEdit.replace({ start: { ...position, character: 0 }, end: position }, '---') : undefined, + }; + } + return null; + } +} diff --git a/vscode/packages/server/src/plugins/html/HTMLPlugin.ts b/vscode/packages/server/src/plugins/html/HTMLPlugin.ts new file mode 100644 index 0000000000..5114eda1c9 --- /dev/null +++ b/vscode/packages/server/src/plugins/html/HTMLPlugin.ts @@ -0,0 +1,135 @@ +import { CompletionsProvider, FoldingRangeProvider } from '../interfaces'; +import { getEmmetCompletionParticipants, VSCodeEmmetConfig } from 'vscode-emmet-helper'; +import { getLanguageService, HTMLDocument, CompletionItem as HtmlCompletionItem, Node, FoldingRange } from 'vscode-html-languageservice'; +import { CompletionList, Position, CompletionItem, CompletionItemKind, TextEdit } from 'vscode-languageserver'; +import type { Document, DocumentManager } from '../../core/documents'; +import { isInsideExpression, isInsideFrontmatter } from '../../core/documents/utils'; +import type { ConfigManager } from '../../core/config'; + +export class HTMLPlugin implements CompletionsProvider, FoldingRangeProvider { + private lang = getLanguageService(); + private documents = new WeakMap(); + private styleScriptTemplate = new Set(['template', 'style', 'script']); + private configManager: ConfigManager; + + constructor(docManager: DocumentManager, configManager: ConfigManager) { + docManager.on('documentChange', (document) => { + this.documents.set(document, document.html); + }); + this.configManager = configManager; + } + + getCompletions(document: Document, position: Position): CompletionList | null { + const html = this.documents.get(document); + + if (!html) { + return null; + } + + if (this.isInsideFrontmatter(document, position) || this.isInsideExpression(html, document, position)) { + return null; + } + + const emmetResults: CompletionList = { + isIncomplete: true, + items: [], + }; + this.lang.setCompletionParticipants([ + getEmmetCompletionParticipants( + document, + position, + 'html', + this.configManager.getEmmetConfig(), + emmetResults + ) + ]); + + const results = this.lang.doComplete(document, position, html); + const items = this.toCompletionItems(results.items); + + return CompletionList.create( + [...this.toCompletionItems(items), ...this.getLangCompletions(items), ...emmetResults.items], + // Emmet completions change on every keystroke, so they are never complete + emmetResults.items.length > 0 + ); + } + + getFoldingRanges(document: Document): FoldingRange[]|null { + const html = this.documents.get(document); + if (!html) { + return null; + } + + return this.lang.getFoldingRanges(document); + + } + + doTagComplete(document: Document, position: Position): string | null { + const html = this.documents.get(document); + if (!html) { + return null; + } + + if (this.isInsideFrontmatter(document, position) || this.isInsideExpression(html, document, position)) { + return null; + } + + return this.lang.doTagComplete(document, position, html); + } + + /** + * The HTML language service uses newer types which clash + * without the stable ones. Transform to the stable types. + */ + private toCompletionItems(items: HtmlCompletionItem[]): CompletionItem[] { + return items.map((item) => { + if (!item.textEdit || TextEdit.is(item.textEdit)) { + return item as CompletionItem; + } + return { + ...item, + textEdit: TextEdit.replace(item.textEdit.replace, item.textEdit.newText), + }; + }); + } + + private getLangCompletions(completions: CompletionItem[]): CompletionItem[] { + const styleScriptTemplateCompletions = completions.filter((completion) => completion.kind === CompletionItemKind.Property && this.styleScriptTemplate.has(completion.label)); + const langCompletions: CompletionItem[] = []; + addLangCompletion('style', ['scss', 'sass']); + return langCompletions; + + /** Add language completions */ + function addLangCompletion(tag: string, languages: string[]) { + const existingCompletion = styleScriptTemplateCompletions.find((completion) => completion.label === tag); + if (!existingCompletion) { + return; + } + + languages.forEach((lang) => + langCompletions.push({ + ...existingCompletion, + label: `${tag} (lang="${lang}")`, + insertText: existingCompletion.insertText && `${existingCompletion.insertText} lang="${lang}"`, + textEdit: + existingCompletion.textEdit && TextEdit.is(existingCompletion.textEdit) + ? { + range: existingCompletion.textEdit.range, + newText: `${existingCompletion.textEdit.newText} lang="${lang}"`, + } + : undefined, + }) + ); + } + } + + private isInsideExpression(html: HTMLDocument, document: Document, position: Position) { + const offset = document.offsetAt(position); + const node = html.findNodeAt(offset); + return isInsideExpression(document.getText(), node.start, offset); + } + + private isInsideFrontmatter(document: Document, position: Position) { + return isInsideFrontmatter(document.getText(), document.offsetAt(position)); + } +} diff --git a/vscode/packages/server/src/plugins/index.ts b/vscode/packages/server/src/plugins/index.ts new file mode 100644 index 0000000000..c1b8a4062f --- /dev/null +++ b/vscode/packages/server/src/plugins/index.ts @@ -0,0 +1,5 @@ +export * from './PluginHost'; +export * from './astro/AstroPlugin'; +export * from './html/HTMLPlugin'; +export * from './typescript/TypeScriptPlugin'; +export * from './interfaces'; diff --git a/vscode/packages/server/src/plugins/interfaces.ts b/vscode/packages/server/src/plugins/interfaces.ts new file mode 100644 index 0000000000..31aafdc3ec --- /dev/null +++ b/vscode/packages/server/src/plugins/interfaces.ts @@ -0,0 +1,217 @@ +import { + CompletionContext, + FileChangeType, + LinkedEditingRanges, + SemanticTokens, + SignatureHelpContext, + TextDocumentContentChangeEvent +} from 'vscode-languageserver'; +import { + CodeAction, + CodeActionContext, + Color, + ColorInformation, + ColorPresentation, + CompletionItem, + CompletionList, + DefinitionLink, + Diagnostic, + FormattingOptions, + Hover, + Location, + Position, + Range, + ReferenceContext, + SymbolInformation, + TextDocumentIdentifier, + TextEdit, + WorkspaceEdit, + SelectionRange, + SignatureHelp, + FoldingRange +} from 'vscode-languageserver-types'; +import { Document } from '../core/documents'; + +export type Resolvable = T | Promise; + +export interface AppCompletionItem extends CompletionItem { + data?: T; +} + +export interface AppCompletionList extends CompletionList { + items: Array>; +} + +export interface DiagnosticsProvider { + getDiagnostics(document: Document): Resolvable; +} + +export interface HoverProvider { + doHover(document: Document, position: Position): Resolvable; +} + +export interface FoldingRangeProvider { + getFoldingRanges(document: Document): Resolvable; +} + +export interface CompletionsProvider { + getCompletions( + document: Document, + position: Position, + completionContext?: CompletionContext + ): Resolvable | null>; + + resolveCompletion?( + document: Document, + completionItem: AppCompletionItem + ): Resolvable>; +} + +export interface FormattingProvider { + formatDocument(document: Document, options: FormattingOptions): Resolvable; +} + +export interface TagCompleteProvider { + doTagComplete(document: Document, position: Position): Resolvable; +} + +export interface DocumentColorsProvider { + getDocumentColors(document: Document): Resolvable; +} + +export interface ColorPresentationsProvider { + getColorPresentations( + document: Document, + range: Range, + color: Color + ): Resolvable; +} + +export interface DocumentSymbolsProvider { + getDocumentSymbols(document: Document): Resolvable; +} + +export interface DefinitionsProvider { + getDefinitions(document: Document, position: Position): Resolvable; +} + +export interface BackwardsCompatibleDefinitionsProvider { + getDefinitions( + document: Document, + position: Position + ): Resolvable; +} + +export interface CodeActionsProvider { + getCodeActions( + document: Document, + range: Range, + context: CodeActionContext + ): Resolvable; + executeCommand?( + document: Document, + command: string, + args?: any[] + ): Resolvable; +} + +export interface FileRename { + oldUri: string; + newUri: string; +} + +export interface UpdateImportsProvider { + updateImports(fileRename: FileRename): Resolvable; +} + +export interface RenameProvider { + rename( + document: Document, + position: Position, + newName: string + ): Resolvable; + prepareRename(document: Document, position: Position): Resolvable; +} + +export interface FindReferencesProvider { + findReferences( + document: Document, + position: Position, + context: ReferenceContext + ): Promise; +} + +export interface SignatureHelpProvider { + getSignatureHelp( + document: Document, + position: Position, + context: SignatureHelpContext | undefined + ): Resolvable; +} + +export interface SelectionRangeProvider { + getSelectionRange(document: Document, position: Position): Resolvable; +} + +export interface SemanticTokensProvider { + getSemanticTokens(textDocument: Document, range?: Range): Resolvable; +} + +export interface LinkedEditingRangesProvider { + getLinkedEditingRanges( + document: Document, + position: Position + ): Resolvable; +} + +export interface OnWatchFileChangesPara { + fileName: string; + changeType: FileChangeType; +} + +export interface OnWatchFileChanges { + onWatchFileChanges(onWatchFileChangesParas: OnWatchFileChangesPara[]): void; +} + +export interface UpdateTsOrJsFile { + updateTsOrJsFile(fileName: string, changes: TextDocumentContentChangeEvent[]): void; +} + +type ProviderBase = DiagnosticsProvider & + HoverProvider & + CompletionsProvider & + FormattingProvider & + FoldingRangeProvider & + TagCompleteProvider & + DocumentColorsProvider & + ColorPresentationsProvider & + DocumentSymbolsProvider & + UpdateImportsProvider & + CodeActionsProvider & + FindReferencesProvider & + RenameProvider & + SignatureHelpProvider & + SemanticTokensProvider & + LinkedEditingRangesProvider; + +export type LSProvider = ProviderBase & BackwardsCompatibleDefinitionsProvider; + +export interface LSPProviderConfig { + /** + * Whether or not completion lists that are marked as imcomplete + * should be filtered server side. + */ + filterIncompleteCompletions: boolean; + /** + * Whether or not getDefinitions supports the LocationLink interface. + */ + definitionLinkSupport: boolean; +} + +export type Plugin = Partial< + ProviderBase & + DefinitionsProvider & + OnWatchFileChanges & + SelectionRangeProvider & + UpdateTsOrJsFile +>; diff --git a/vscode/packages/server/src/plugins/typescript/LanguageServiceManager.ts b/vscode/packages/server/src/plugins/typescript/LanguageServiceManager.ts new file mode 100644 index 0000000000..60dec606c5 --- /dev/null +++ b/vscode/packages/server/src/plugins/typescript/LanguageServiceManager.ts @@ -0,0 +1,82 @@ +import * as ts from 'typescript'; +import type { Document, DocumentManager } from '../../core/documents'; +import type { ConfigManager } from '../../core/config'; +import { urlToPath, pathToUrl, debounceSameArg } from '../../utils'; +import { getLanguageService, getLanguageServiceForDocument, LanguageServiceContainer, LanguageServiceDocumentContext } from './languageService'; +import { DocumentSnapshot, SnapshotManager } from './SnapshotManager'; + +export class LanguageServiceManager { + private readonly docManager: DocumentManager; + private readonly configManager: ConfigManager; + private readonly workspaceUris: string[]; + private docContext: LanguageServiceDocumentContext; + + constructor(docManager: DocumentManager, configManager: ConfigManager, workspaceUris: string[]) { + this.docManager = docManager; + this.configManager = configManager; + this.workspaceUris = workspaceUris; + this.docContext = { + getWorkspaceRoot: (fileName: string) => this.getWorkspaceRoot(fileName), + createDocument: this.createDocument, + }; + + const handleDocumentChange = (document: Document) => { + // This refreshes the document in the ts language service + this.getTypeScriptDoc(document); + }; + + docManager.on( + 'documentChange', + debounceSameArg(handleDocumentChange, (newDoc, prevDoc) => newDoc.uri === prevDoc?.uri, 1000) + ); + docManager.on('documentOpen', handleDocumentChange); + } + + private getWorkspaceRoot(fileName: string) { + if (this.workspaceUris.length === 1) return urlToPath(this.workspaceUris[0]) as string; + return this.workspaceUris.reduce((found, curr) => { + const url = urlToPath(curr) as string; + if (fileName.startsWith(url) && curr.length < url.length) return url; + return found; + }, '') + } + + private createDocument = (fileName: string, content: string) => { + const uri = pathToUrl(fileName); + const document = this.docManager.openDocument({ + languageId: 'astro', + version: 0, + text: content, + uri, + }); + return document; + }; + + async getSnapshot(document: Document): Promise; + async getSnapshot(pathOrDoc: string | Document): Promise; + async getSnapshot(pathOrDoc: string | Document) { + const filePath = typeof pathOrDoc === 'string' ? pathOrDoc : pathOrDoc.getFilePath() || ''; + const tsService = await this.getTypeScriptLanguageService(filePath); + return tsService.updateDocument(pathOrDoc); + } + + async getTypeScriptDoc( + document: Document + ): Promise<{ + tsDoc: DocumentSnapshot; + lang: ts.LanguageService; + }> { + const lang = await getLanguageServiceForDocument(document, this.workspaceUris, this.docContext); + const tsDoc = await this.getSnapshot(document); + + return { tsDoc, lang }; + } + + async getSnapshotManager(filePath: string): Promise { + return (await this.getTypeScriptLanguageService(filePath)).snapshotManager; + } + + private getTypeScriptLanguageService(filePath: string): Promise { + return getLanguageService(filePath, this.workspaceUris, this.docContext); + } +} diff --git a/vscode/packages/server/src/plugins/typescript/SnapshotManager.ts b/vscode/packages/server/src/plugins/typescript/SnapshotManager.ts new file mode 100644 index 0000000000..aac26d96ea --- /dev/null +++ b/vscode/packages/server/src/plugins/typescript/SnapshotManager.ts @@ -0,0 +1,333 @@ +import * as ts from 'typescript'; +import { TextDocumentContentChangeEvent, Position } from 'vscode-languageserver'; +import { Document } from '../../core/documents'; +import { positionAt, offsetAt } from '../../core/documents/utils'; +import { pathToUrl } from '../../utils'; +import { getScriptKindFromFileName, isAstroFilePath, toVirtualAstroFilePath } from './utils'; + +export interface TsFilesSpec { + include?: readonly string[]; + exclude?: readonly string[]; +} + +export class SnapshotManager { + private documents: Map = new Map(); + private lastLogged = new Date(new Date().getTime() - 60_001); + + private readonly watchExtensions = [ + ts.Extension.Dts, + ts.Extension.Js, + ts.Extension.Jsx, + ts.Extension.Ts, + ts.Extension.Tsx, + ts.Extension.Json + ]; + + constructor( + private projectFiles: string[], + private fileSpec: TsFilesSpec, + private workspaceRoot: string + ) { + + } + + updateProjectFiles() { + const { include, exclude } = this.fileSpec; + + if (include?.length === 0) return; + + const projectFiles = ts.sys.readDirectory( + this.workspaceRoot, + this.watchExtensions, + exclude, + include + ); + + this.projectFiles = Array.from(new Set([...this.projectFiles, ...projectFiles])); + } + + updateProjectFile(fileName: string, changes?: TextDocumentContentChangeEvent[]): void { + const previousSnapshot = this.get(fileName); + + if (changes) { + if (!(previousSnapshot instanceof TypeScriptDocumentSnapshot)) { + return; + } + previousSnapshot.update(changes); + } else { + const newSnapshot = createDocumentSnapshot(fileName); + + if (previousSnapshot) { + newSnapshot.version = previousSnapshot.version + 1; + } else { + // ensure it's greater than initial version + // so that ts server picks up the change + newSnapshot.version += 1; + } + this.set(fileName, newSnapshot); + } + } + + has(fileName: string) { + return this.projectFiles.includes(fileName) || this.getFileNames().includes(fileName); + } + + get(fileName: string) { + return this.documents.get(fileName); + } + + set(fileName: string, snapshot: DocumentSnapshot) { + // const prev = this.get(fileName); + this.logStatistics(); + return this.documents.set(fileName, snapshot); + } + + delete(fileName: string) { + this.projectFiles = this.projectFiles.filter((s) => s !== fileName); + return this.documents.delete(fileName); + } + + getFileNames() { + return Array.from(this.documents.keys()).map(fileName => toVirtualAstroFilePath(fileName)); + } + + getProjectFileNames() { + return [...this.projectFiles]; + } + + private logStatistics() { + const date = new Date(); + // Don't use setInterval because that will keep tests running forever + if (date.getTime() - this.lastLogged.getTime() > 60_000) { + this.lastLogged = date; + + const projectFiles = this.getProjectFileNames(); + const allFiles = Array.from(new Set([...projectFiles, ...this.getFileNames()])); + console.log( + 'SnapshotManager File Statistics:\n' + + `Project files: ${projectFiles.length}\n` + + `Astro files: ${ + allFiles.filter((name) => name.endsWith('.astro')).length + }\n` + + `From node_modules: ${ + allFiles.filter((name) => name.includes('node_modules')).length + }\n` + + `Total: ${allFiles.length}` + ); + } + } +} + +export interface DocumentSnapshot extends ts.IScriptSnapshot { + version: number; + filePath: string; + scriptKind: ts.ScriptKind; + positionAt(offset: number): Position; + /** + * Instantiates a source mapper. + * `destroyFragment` needs to be called when + * it's no longer needed / the class should be cleaned up + * in order to prevent memory leaks. + */ + getFragment(): Promise; + /** + * Needs to be called when source mapper + * is no longer needed / the class should be cleaned up + * in order to prevent memory leaks. + */ + destroyFragment(): void; + /** + * Convenience function for getText(0, getLength()) + */ + getFullText(): string; +} + +export const createDocumentSnapshot = (filePath: string, createDocument?: (_filePath: string, text: string) => Document): DocumentSnapshot => { + const text = ts.sys.readFile(filePath) ?? ''; + + if (isAstroFilePath(filePath)) { + if (!createDocument) throw new Error('Astro documents require the "createDocument" utility to be provided'); + const snapshot = new AstroDocumentSnapshot(createDocument(filePath, text)); + return snapshot; + } + + return new TypeScriptDocumentSnapshot(0, filePath, text); + +} + +class AstroDocumentSnapshot implements DocumentSnapshot { + + version = this.doc.version; + scriptKind = ts.ScriptKind.Unknown; + + constructor(private doc: Document) {} + + async getFragment(): Promise { + return new DocumentFragmentSnapshot(this.doc); + } + + async destroyFragment() { + return; + } + + get text() { + return this.doc.getText(); + } + + get filePath() { + return this.doc.getFilePath() || ''; + } + + getText(start: number, end: number) { + return this.text.substring(start, end); + } + + getLength() { + return this.text.length; + } + + getFullText() { + return this.text; + } + + getChangeRange() { + return undefined; + } + + positionAt(offset: number) { + return positionAt(offset, this.text); + } + + getLineContainingOffset(offset: number) { + const chunks = this.getText(0, offset).split('\n'); + return chunks[chunks.length - 1]; + } + + offsetAt(position: Position) { + return offsetAt(position, this.text); + } + +} + +class DocumentFragmentSnapshot implements Omit { + + version: number; + filePath: string; + url: string; + text: string; + + scriptKind = ts.ScriptKind.TSX; + scriptInfo = null; + + constructor( + private doc: Document + ) { + const filePath = doc.getFilePath(); + if (!filePath) throw new Error('Cannot create a document fragment from a non-local document'); + const text = doc.getText(); + this.version = doc.version; + this.filePath = toVirtualAstroFilePath(filePath); + this.url = toVirtualAstroFilePath(filePath); + this.text = this.transformContent(text); + } + + /** @internal */ + private transformContent(content: string) { + return content.replace(/---/g, '///'); + } + + getText(start: number, end: number) { + return this.text.substring(start, end); + } + + getLength() { + return this.text.length; + } + + getFullText() { + return this.text; + } + + getChangeRange() { + return undefined; + } + + positionAt(offset: number) { + return positionAt(offset, this.text); + } + + getLineContainingOffset(offset: number) { + const chunks = this.getText(0, offset).split('\n'); + return chunks[chunks.length - 1]; + } + + offsetAt(position: Position): number { + return offsetAt(position, this.text); + } +} + +class TypeScriptDocumentSnapshot implements DocumentSnapshot { + + scriptKind = getScriptKindFromFileName(this.filePath); + scriptInfo = null; + url: string; + + + constructor(public version: number, public readonly filePath: string, private text: string) { + this.url = pathToUrl(filePath) + } + + getText(start: number, end: number) { + return this.text.substring(start, end); + } + + getLength() { + return this.text.length; + } + + getFullText() { + return this.text; + } + + getChangeRange() { + return undefined; + } + + positionAt(offset: number) { + return positionAt(offset, this.text); + } + + offsetAt(position: Position): number { + return offsetAt(position, this.text); + } + + async getFragment(): Promise { + return this as unknown as any; + } + + destroyFragment() { + // nothing to clean up + } + + getLineContainingOffset(offset: number) { + const chunks = this.getText(0, offset).split('\n'); + return chunks[chunks.length - 1]; + } + + update(changes: TextDocumentContentChangeEvent[]): void { + for (const change of changes) { + let start = 0; + let end = 0; + if ('range' in change) { + start = this.offsetAt(change.range.start); + end = this.offsetAt(change.range.end); + } else { + end = this.getLength(); + } + + this.text = this.text.slice(0, start) + change.text + this.text.slice(end); + } + + this.version++; + } +} diff --git a/vscode/packages/server/src/plugins/typescript/TypeScriptPlugin.ts b/vscode/packages/server/src/plugins/typescript/TypeScriptPlugin.ts new file mode 100644 index 0000000000..018e8bfda2 --- /dev/null +++ b/vscode/packages/server/src/plugins/typescript/TypeScriptPlugin.ts @@ -0,0 +1,89 @@ +import type { Document, DocumentManager } from '../../core/documents'; +import type { ConfigManager } from '../../core/config'; +import type { CompletionsProvider, AppCompletionItem, AppCompletionList } from '../interfaces'; +import { + CompletionContext, + Position, + FileChangeType +} from 'vscode-languageserver'; +import * as ts from 'typescript'; +import { CompletionsProviderImpl, CompletionEntryWithIdentifer } from './features/CompletionsProvider'; +import { LanguageServiceManager } from './LanguageServiceManager'; +import { SnapshotManager } from './SnapshotManager'; +import { getScriptKindFromFileName } from './utils'; + +export class TypeScriptPlugin implements CompletionsProvider { + private readonly docManager: DocumentManager; + private readonly configManager: ConfigManager; + private readonly languageServiceManager: LanguageServiceManager; + + private readonly completionProvider: CompletionsProviderImpl; + + constructor( + docManager: DocumentManager, + configManager: ConfigManager, + workspaceUris: string[] + ) { + this.docManager = docManager; + this.configManager = configManager; + this.languageServiceManager = new LanguageServiceManager(docManager, configManager, workspaceUris); + + this.completionProvider = new CompletionsProviderImpl(this.languageServiceManager); + } + + async getCompletions( + document: Document, + position: Position, + completionContext?: CompletionContext + ): Promise | null> { + const completions = await this.completionProvider.getCompletions( + document, + position, + completionContext + ); + + return completions; + } + + async resolveCompletion( + document: Document, + completionItem: AppCompletionItem + ): Promise> { + return this.completionProvider.resolveCompletion(document, completionItem); + } + + async onWatchFileChanges(onWatchFileChangesParams: any[]): Promise { + const doneUpdateProjectFiles = new Set(); + + for (const { fileName, changeType } of onWatchFileChangesParams) { + const scriptKind = getScriptKindFromFileName(fileName); + + if (scriptKind === ts.ScriptKind.Unknown) { + // We don't deal with svelte files here + continue; + } + + const snapshotManager = await this.getSnapshotManager(fileName); + if (changeType === FileChangeType.Created) { + if (!doneUpdateProjectFiles.has(snapshotManager)) { + snapshotManager.updateProjectFiles(); + doneUpdateProjectFiles.add(snapshotManager); + } + } else if (changeType === FileChangeType.Deleted) { + snapshotManager.delete(fileName); + return; + } + + snapshotManager.updateProjectFile(fileName); + } + } + + /** + * + * @internal + */ + public async getSnapshotManager(fileName: string) { + return this.languageServiceManager.getSnapshotManager(fileName); + } +} + diff --git a/vscode/packages/server/src/plugins/typescript/astro-sys.ts b/vscode/packages/server/src/plugins/typescript/astro-sys.ts new file mode 100644 index 0000000000..0459528c53 --- /dev/null +++ b/vscode/packages/server/src/plugins/typescript/astro-sys.ts @@ -0,0 +1,42 @@ +import * as ts from 'typescript'; +import { DocumentSnapshot } from './SnapshotManager'; +import { ensureRealAstroFilePath, isAstroFilePath, isVirtualAstroFilePath, toRealAstroFilePath } from './utils'; + +/** + * This should only be accessed by TS Astro module resolution. + */ +export function createAstroSys(getSnapshot: (fileName: string) => DocumentSnapshot) { + const AstroSys: ts.System = { + ...ts.sys, + fileExists(path: string) { + if (isAstroFilePath(path) || isVirtualAstroFilePath(path)) { + console.log('fileExists', path, ts.sys.fileExists(ensureRealAstroFilePath(path))); + } + return ts.sys.fileExists(ensureRealAstroFilePath(path)); + }, + readFile(path: string) { + if (isAstroFilePath(path) || isVirtualAstroFilePath(path)) { + console.log('readFile', path); + } + const snapshot = getSnapshot(path); + return snapshot.getFullText(); + }, + readDirectory(path, extensions, exclude, include, depth) { + const extensionsWithAstro = (extensions ?? []).concat(...['.astro']); + const result = ts.sys.readDirectory(path, extensionsWithAstro, exclude, include, depth);; + return result; + } + }; + + if (ts.sys.realpath) { + const realpath = ts.sys.realpath; + AstroSys.realpath = function (path) { + if (isVirtualAstroFilePath(path)) { + return realpath(toRealAstroFilePath(path)) + '.ts'; + } + return realpath(path); + }; + } + + return AstroSys; +} diff --git a/vscode/packages/server/src/plugins/typescript/features/CompletionsProvider.ts b/vscode/packages/server/src/plugins/typescript/features/CompletionsProvider.ts new file mode 100644 index 0000000000..ebbc16e317 --- /dev/null +++ b/vscode/packages/server/src/plugins/typescript/features/CompletionsProvider.ts @@ -0,0 +1,123 @@ +import { isInsideFrontmatter } from '../../../core/documents/utils'; +import { Document } from '../../../core/documents'; +import * as ts from 'typescript'; +import { CompletionContext, CompletionList, CompletionItem, Position, TextDocumentIdentifier, TextEdit, MarkupKind, MarkupContent } from 'vscode-languageserver'; +import { AppCompletionItem, AppCompletionList, CompletionsProvider } from '../../interfaces'; +import type { LanguageServiceManager } from '../LanguageServiceManager'; +import { scriptElementKindToCompletionItemKind, getCommitCharactersForScriptElement } from '../utils'; + +export interface CompletionEntryWithIdentifer extends ts.CompletionEntry, TextDocumentIdentifier { + position: Position; +} + +export class CompletionsProviderImpl implements CompletionsProvider { + constructor(private lang: LanguageServiceManager) {} + + async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise | null> { + // TODO: handle inside expression + if (!isInsideFrontmatter(document.getText(), document.offsetAt(position))) { + return null; + } + + const filePath = document.getFilePath(); + if (!filePath) throw new Error(); + + const { tsDoc, lang } = await this.lang.getTypeScriptDoc(document); + const fragment = await tsDoc.getFragment(); + + const { entries } = lang.getCompletionsAtPosition(fragment.filePath, document.offsetAt(position), {}) ?? { entries: [] }; + const completionItems = entries + .map((entry: ts.CompletionEntry) => this.toCompletionItem(fragment, entry, document.uri, position, new Set())) + .filter((i) => i) as CompletionItem[]; + + return CompletionList.create(completionItems, true); + } + + async resolveCompletion(document: Document, completionItem: AppCompletionItem): Promise> { + const { data: comp } = completionItem; + const { tsDoc, lang } = await this.lang.getTypeScriptDoc(document); + + const filePath = tsDoc.filePath; + + if (!comp || !filePath) { + return completionItem; + } + + const fragment = await tsDoc.getFragment(); + const detail = lang.getCompletionEntryDetails(filePath, fragment.offsetAt(comp.position), comp.name, {}, comp.source, {}); + + if (detail) { + const { detail: itemDetail, documentation: itemDocumentation } = this.getCompletionDocument(detail); + + completionItem.detail = itemDetail; + completionItem.documentation = itemDocumentation; + } + + // const actions = detail?.codeActions; + // const isImport = !!detail?.source; + + // TODO: handle actions + // if (actions) { + // const edit: TextEdit[] = []; + + // for (const action of actions) { + // for (const change of action.changes) { + // edit.push( + // ...this.codeActionChangesToTextEdit( + // document, + // fragment, + // change, + // isImport, + // isInsideFrontmatter(fragment.getFullText(), fragment.offsetAt(comp.position)) + // ) + // ); + // } + // } + + // completionItem.additionalTextEdits = edit; + // } + + return completionItem; + } + + private toCompletionItem( + fragment: any, + comp: ts.CompletionEntry, + uri: string, + position: Position, + existingImports: Set + ): AppCompletionItem | null { + return { + label: comp.name, + insertText: comp.insertText, + kind: scriptElementKindToCompletionItemKind(comp.kind), + commitCharacters: getCommitCharactersForScriptElement(comp.kind), + // Make sure svelte component takes precedence + sortText: comp.sortText, + preselect: comp.isRecommended, + // pass essential data for resolving completion + data: { + ...comp, + uri, + position + }, + }; + } + + private getCompletionDocument(compDetail: ts.CompletionEntryDetails) { + const { source, documentation: tsDocumentation, displayParts, tags } = compDetail; + let detail: string = ts.displayPartsToString(displayParts); + + if (source) { + const importPath = ts.displayPartsToString(source); + detail = `Auto import from ${importPath}\n${detail}`; + } + + const documentation: MarkupContent | undefined = tsDocumentation ? { value: tsDocumentation.join('\n'), kind: MarkupKind.Markdown } : undefined; + + return { + documentation, + detail, + }; + } +} diff --git a/vscode/packages/server/src/plugins/typescript/languageService.ts b/vscode/packages/server/src/plugins/typescript/languageService.ts new file mode 100644 index 0000000000..c1663ea465 --- /dev/null +++ b/vscode/packages/server/src/plugins/typescript/languageService.ts @@ -0,0 +1,179 @@ +/* eslint-disable require-jsdoc */ + +import * as ts from 'typescript'; +import { basename } from 'path'; +import { ensureRealAstroFilePath, findTsConfigPath, isAstroFilePath, toVirtualAstroFilePath } from './utils'; +import { Document } from '../../core/documents'; +import { createDocumentSnapshot, SnapshotManager, DocumentSnapshot } from './SnapshotManager'; +import { createAstroSys } from './astro-sys'; + +const services = new Map>(); + +export interface LanguageServiceContainer { + readonly tsconfigPath: string; + readonly snapshotManager: SnapshotManager; + getService(): ts.LanguageService; + updateDocument(documentOrFilePath: Document | string): ts.IScriptSnapshot; + deleteDocument(filePath: string): void; +} + +export interface LanguageServiceDocumentContext { + getWorkspaceRoot(fileName: string): string; + createDocument: (fileName: string, content: string) => Document; +} + +export async function getLanguageService(path: string, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise { + const tsconfigPath = findTsConfigPath(path, workspaceUris); + const workspaceRoot = docContext.getWorkspaceRoot(path); + + let service: LanguageServiceContainer; + if (services.has(tsconfigPath)) { + service = (await services.get(tsconfigPath)) as LanguageServiceContainer; + } else { + const newService = createLanguageService(tsconfigPath, workspaceRoot, docContext); + services.set(tsconfigPath, newService); + service = await newService; + } + + return service; +} + +export async function getLanguageServiceForDocument(document: Document, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise { + return getLanguageServiceForPath(document.getFilePath() || '', workspaceUris, docContext); +} + +export async function getLanguageServiceForPath(path: string, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise { + return (await getLanguageService(path, workspaceUris, docContext)).getService(); +} + +async function createLanguageService(tsconfigPath: string, workspaceRoot: string, docContext: LanguageServiceDocumentContext): Promise { + const parseConfigHost: ts.ParseConfigHost = { + ...ts.sys, + readDirectory: (path, extensions, exclude, include, depth) => { + return ts.sys.readDirectory(path, [...extensions, '.vue', '.svelte', '.astro', '.js', '.jsx'], exclude, include, depth); + }, + }; + + let configJson = (tsconfigPath && ts.readConfigFile(tsconfigPath, ts.sys.readFile).config) || getDefaultJsConfig(); + if (!configJson.extends) { + configJson = Object.assign( + { + exclude: getDefaultExclude() + }, + configJson + ); + } + + const project = ts.parseJsonConfigFileContent( + configJson, + parseConfigHost, + workspaceRoot, + {}, + basename(tsconfigPath), + undefined, + [ + { extension: '.vue', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }, + { extension: '.svelte', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred }, + { extension: '.astro', isMixedContent: true, scriptKind: ts.ScriptKind.Deferred } + ] + ); + + let projectVersion = 0; + const snapshotManager = new SnapshotManager(project.fileNames, { exclude: ['node_modules', '_site'], include: ['astro'] }, workspaceRoot || process.cwd()); + const astroSys = createAstroSys(updateDocument); + + const host: ts.LanguageServiceHost = { + getNewLine: () => ts.sys.newLine, + useCaseSensitiveFileNames: () => ts.sys.useCaseSensitiveFileNames, + readFile: astroSys.readFile, + writeFile: astroSys.writeFile, + fileExists: astroSys.fileExists, + directoryExists: astroSys.directoryExists, + getDirectories: astroSys.getDirectories, + readDirectory: astroSys.readDirectory, + realpath: astroSys.realpath, + + getCompilationSettings: () => project.options, + getCurrentDirectory: () => workspaceRoot, + getDefaultLibFileName: () => ts.getDefaultLibFilePath(project.options), + + getProjectVersion: () => `${projectVersion}`, + getScriptFileNames: () => Array.from(new Set([...snapshotManager.getFileNames(), ...snapshotManager.getProjectFileNames()])), + getScriptSnapshot, + getScriptVersion: (fileName: string) => getScriptSnapshot(fileName).version.toString() + }; + + const languageService = ts.createLanguageService(host); + const languageServiceProxy = new Proxy(languageService, { + get(target, prop) { + return Reflect.get(target, prop); + } + }) + + return { + tsconfigPath, + snapshotManager, + getService: () => languageServiceProxy, + updateDocument, + deleteDocument, + }; + + function deleteDocument(filePath: string) { + snapshotManager.delete(filePath); + } + + function updateDocument(documentOrFilePath: Document | string) { + const filePath = ensureRealAstroFilePath(typeof documentOrFilePath === 'string' ? documentOrFilePath : documentOrFilePath.getFilePath() || ''); + const document = typeof documentOrFilePath === 'string' ? undefined : documentOrFilePath; + + if (!filePath) { + throw new Error(`Unable to find document`); + } + + const previousSnapshot = snapshotManager.get(filePath); + if (document && previousSnapshot?.version.toString() === `${document.version}`) { + return previousSnapshot; + } + + const snapshot = createDocumentSnapshot(filePath, docContext.createDocument); + snapshotManager.set(filePath, snapshot); + return snapshot; + } + + function getScriptSnapshot(fileName: string): DocumentSnapshot { + fileName = ensureRealAstroFilePath(fileName); + + let doc = snapshotManager.get(fileName); + if (doc) { + return doc; + } + + doc = createDocumentSnapshot( + fileName, + docContext.createDocument, + ); + snapshotManager.set(fileName, doc); + return doc; + } +} + +/** + * This should only be used when there's no jsconfig/tsconfig at all + */ +function getDefaultJsConfig(): { + compilerOptions: ts.CompilerOptions; + include: string[]; +} { + return { + compilerOptions: { + maxNodeModuleJsDepth: 2, + allowSyntheticDefaultImports: true, + allowJs: true + }, + include: ['astro'], + }; +} + +function getDefaultExclude() { + return ['_site', 'node_modules']; +} diff --git a/vscode/packages/server/src/plugins/typescript/utils.ts b/vscode/packages/server/src/plugins/typescript/utils.ts new file mode 100644 index 0000000000..0588684742 --- /dev/null +++ b/vscode/packages/server/src/plugins/typescript/utils.ts @@ -0,0 +1,182 @@ +import * as ts from 'typescript'; +import { CompletionItemKind, DiagnosticSeverity } from 'vscode-languageserver'; +import { dirname } from 'path'; +import { pathToUrl } from '../../utils'; + +export function scriptElementKindToCompletionItemKind( + kind: ts.ScriptElementKind +): CompletionItemKind { + switch (kind) { + case ts.ScriptElementKind.primitiveType: + case ts.ScriptElementKind.keyword: + return CompletionItemKind.Keyword; + case ts.ScriptElementKind.constElement: + return CompletionItemKind.Constant; + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + case ts.ScriptElementKind.localVariableElement: + case ts.ScriptElementKind.alias: + return CompletionItemKind.Variable; + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + return CompletionItemKind.Field; + case ts.ScriptElementKind.functionElement: + return CompletionItemKind.Function; + case ts.ScriptElementKind.memberFunctionElement: + case ts.ScriptElementKind.constructSignatureElement: + case ts.ScriptElementKind.callSignatureElement: + case ts.ScriptElementKind.indexSignatureElement: + return CompletionItemKind.Method; + case ts.ScriptElementKind.enumElement: + return CompletionItemKind.Enum; + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.externalModuleName: + return CompletionItemKind.Module; + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.typeElement: + return CompletionItemKind.Class; + case ts.ScriptElementKind.interfaceElement: + return CompletionItemKind.Interface; + case ts.ScriptElementKind.warning: + case ts.ScriptElementKind.scriptElement: + return CompletionItemKind.File; + case ts.ScriptElementKind.directory: + return CompletionItemKind.Folder; + case ts.ScriptElementKind.string: + return CompletionItemKind.Constant; + } + return CompletionItemKind.Property; +} + +export function getCommitCharactersForScriptElement( + kind: ts.ScriptElementKind +): string[] | undefined { + const commitCharacters: string[] = []; + switch (kind) { + case ts.ScriptElementKind.memberGetAccessorElement: + case ts.ScriptElementKind.memberSetAccessorElement: + case ts.ScriptElementKind.constructSignatureElement: + case ts.ScriptElementKind.callSignatureElement: + case ts.ScriptElementKind.indexSignatureElement: + case ts.ScriptElementKind.enumElement: + case ts.ScriptElementKind.interfaceElement: + commitCharacters.push('.'); + break; + + case ts.ScriptElementKind.moduleElement: + case ts.ScriptElementKind.alias: + case ts.ScriptElementKind.constElement: + case ts.ScriptElementKind.letElement: + case ts.ScriptElementKind.variableElement: + case ts.ScriptElementKind.localVariableElement: + case ts.ScriptElementKind.memberVariableElement: + case ts.ScriptElementKind.classElement: + case ts.ScriptElementKind.functionElement: + case ts.ScriptElementKind.memberFunctionElement: + commitCharacters.push('.', ','); + commitCharacters.push('('); + break; + } + + return commitCharacters.length === 0 ? undefined : commitCharacters; +} + +export function mapSeverity(category: ts.DiagnosticCategory): DiagnosticSeverity { + switch (category) { + case ts.DiagnosticCategory.Error: + return DiagnosticSeverity.Error; + case ts.DiagnosticCategory.Warning: + return DiagnosticSeverity.Warning; + case ts.DiagnosticCategory.Suggestion: + return DiagnosticSeverity.Hint; + case ts.DiagnosticCategory.Message: + return DiagnosticSeverity.Information; + } + + return DiagnosticSeverity.Error; +} + +export function getScriptKindFromFileName(fileName: string): ts.ScriptKind { + const ext = fileName.substr(fileName.lastIndexOf('.')); + switch (ext.toLowerCase()) { + case ts.Extension.Js: + return ts.ScriptKind.JS; + case ts.Extension.Jsx: + return ts.ScriptKind.JSX; + case ts.Extension.Ts: + return ts.ScriptKind.TS; + case ts.Extension.Tsx: + return ts.ScriptKind.TSX; + case ts.Extension.Json: + return ts.ScriptKind.JSON; + default: + return ts.ScriptKind.Unknown; + } +} + +export function isAstroFilePath(filePath: string) { + return filePath.endsWith('.astro'); +} + +export function isVirtualAstroFilePath(filePath: string) { + return filePath.endsWith('.astro.ts'); +} + +export function toVirtualAstroFilePath(filePath: string) { + return `${filePath}.ts`; +} + +export function toRealAstroFilePath(filePath: string) { + return filePath.slice(0, -'.ts'.length); +} + +export function ensureRealAstroFilePath(filePath: string) { + return isVirtualAstroFilePath(filePath) ? toRealAstroFilePath(filePath) : filePath; +} + +export function findTsConfigPath(fileName: string, rootUris: string[]) { + const searchDir = dirname(fileName); + const path = + ts.findConfigFile(searchDir, ts.sys.fileExists, 'tsconfig.json') || + ts.findConfigFile(searchDir, ts.sys.fileExists, 'jsconfig.json') || + ''; + // Don't return config files that exceed the current workspace context. + return !!path && rootUris.some((rootUri) => isSubPath(rootUri, path)) ? path : ''; +} + +/** */ +export function isSubPath(uri: string, possibleSubPath: string): boolean { + return pathToUrl(possibleSubPath).startsWith(uri); +} + + +/** Substitutes */ +export function substituteWithWhitespace(result: string, start: number, end: number, oldContent: string, before: string, after: string) { + let accumulatedWS = 0; + result += before; + for (let i = start + before.length; i < end; i++) { + let ch = oldContent[i]; + if (ch === '\n' || ch === '\r') { + // only write new lines, skip the whitespace + accumulatedWS = 0; + result += ch; + } else { + accumulatedWS++; + } + } + result = append(result, ' ', accumulatedWS - after.length); + result += after; + return result; +} + +function append(result: string, str: string, n: number): string { + while (n > 0) { + if (n & 1) { + result += str; + } + n >>= 1; + str += str; + } + return result; +} diff --git a/vscode/packages/server/src/utils.ts b/vscode/packages/server/src/utils.ts new file mode 100644 index 0000000000..c764aae13b --- /dev/null +++ b/vscode/packages/server/src/utils.ts @@ -0,0 +1,98 @@ +import { URI } from 'vscode-uri'; +import { Position, Range } from 'vscode-languageserver'; +import { Node } from 'vscode-html-languageservice'; + +/** Normalizes a document URI */ +export function normalizeUri(uri: string): string { + return URI.parse(uri).toString(); +} + +/** Turns a URL into a normalized FS Path */ +export function urlToPath(stringUrl: string): string | null { + const url = URI.parse(stringUrl); + if (url.scheme !== 'file') { + return null; + } + return url.fsPath.replace(/\\/g, '/'); +} + +/** Converts a path to a URL */ +export function pathToUrl(path: string) { + return URI.file(path).toString(); +} + + +/** +* +* The language service is case insensitive, and would provide +* hover info for Svelte components like `Option` which have +* the same name like a html tag. +*/ +export function isPossibleComponent(node: Node): boolean { + return !!node.tag?.[0].match(/[A-Z]/); +} + +/** +* +* The language service is case insensitive, and would provide +* hover info for Svelte components like `Option` which have +* the same name like a html tag. +*/ +export function isPossibleClientComponent(node: Node): boolean { + return isPossibleComponent(node) && (node.tag?.indexOf(':') ?? -1) > -1; +} + +/** Flattens an array */ +export function flatten(arr: T[][]): T[] { + return arr.reduce((all, item) => [...all, ...item], []); +} + +/** Clamps a number between min and max */ +export function clamp(num: number, min: number, max: number): number { + return Math.max(min, Math.min(max, num)); +} + +/** Checks if a position is inside range */ +export function isInRange(positionToTest: Position, range: Range): boolean { + return ( + isBeforeOrEqualToPosition(range.end, positionToTest) && + isBeforeOrEqualToPosition(positionToTest, range.start) + ); +} + +/** */ +export function isBeforeOrEqualToPosition(position: Position, positionToTest: Position): boolean { + return ( + positionToTest.line < position.line || + (positionToTest.line === position.line && positionToTest.character <= position.character) + ); +} + +/** + * Debounces a function but cancels previous invocation only if + * a second function determines it should. + * + * @param fn The function with it's argument + * @param determineIfSame The function which determines if the previous invocation should be canceld or not + * @param miliseconds Number of miliseconds to debounce + */ +export function debounceSameArg( + fn: (arg: T) => void, + shouldCancelPrevious: (newArg: T, prevArg?: T) => boolean, + miliseconds: number +): (arg: T) => void { + let timeout: any; + let prevArg: T | undefined; + + return (arg: T) => { + if (shouldCancelPrevious(arg, prevArg)) { + clearTimeout(timeout); + } + + prevArg = arg; + timeout = setTimeout(() => { + fn(arg); + prevArg = undefined; + }, miliseconds); + }; +} diff --git a/vscode/scripts/esbuild.config.mjs b/vscode/scripts/esbuild.config.mjs index 22f77b377f..334429ffdb 100644 --- a/vscode/scripts/esbuild.config.mjs +++ b/vscode/scripts/esbuild.config.mjs @@ -3,5 +3,5 @@ export default { logLevel: 'error', platform: 'node', format: 'cjs', - external: ['vscode', 'vscode-html-languageservice'], + external: ['vscode', 'vscode-html-languageservice', "vscode-emmet-helper"], }; diff --git a/vscode/syntaxes/astro.tmLanguage.json b/vscode/syntaxes/astro.tmLanguage.json index 76d3d16de3..9c09d0e710 100644 --- a/vscode/syntaxes/astro.tmLanguage.json +++ b/vscode/syntaxes/astro.tmLanguage.json @@ -5,10 +5,10 @@ "foldingStartMarker": "(?x)\n(<(?i:head|body|table|thead|tbody|tfoot|tr|div|select|fieldset|style|script|ul|ol|li|form|dl)\\b.*?>\n|)$\n|<\\?(?:php)?.*\\b(if|for(each)?|while)\\b.+:\n|\\{\\{?(if|foreach|capture|literal|foreach|php|section|strip)\n|\\{\\s*($|\\?>\\s*$|\/\/|\/\\*(.*\\*\/\\s*$|(?!.*?\\*\/)))\n)", "foldingStopMarker": "(?x)\n(<\/(?i:head|body|table|thead|tbody|tfoot|tr|div|select|fieldset|style|script|ul|ol|li|form|dl)>\n|^(?!.*?$\n|<\\?(?:php)?.*\\bend(if|for(each)?|while)\\b\n|\\{\\{?\/(if|foreach|capture|literal|foreach|php|section|strip)\n|^[^{]*\\}\n)", "keyEquivalent": "^~H", - "name": "Astro Component", + "name": "Astro", "patterns": [ { - "include": "#astro-interpolations" + "include": "#astro-expressions" }, { "begin": "(<)([a-zA-Z0-9:-]++)(?=[^>]*><\/\\2>)", @@ -96,7 +96,7 @@ "name": "meta.tag.sgml.html", "patterns": [ { - "begin": "(?i:DOCTYPE)", + "begin": "(?i:DOCTYPE|doctype)", "captures": { "1": { "name": "entity.name.tag.doctype.html" @@ -510,14 +510,24 @@ ], "repository": { "frontmatter": { - "begin": "\\A-{3}\\s*$", + "begin": "\\A(-{3})\\s*$", + "beginCaptures": { + "1": { + "name": "comment.block.html" + } + }, "contentName": "meta.embedded.block.frontmatter", "patterns": [ { "include": "source.tsx" } ], - "end": "(^|\\G)-{3}|\\.{3}\\s*$" + "end": "(^|\\G)(-{3})|\\.{3}\\s*$", + "endCaptures": { + "2": { + "name": "comment.block.html" + } + } }, "entities": { "patterns": [ @@ -613,7 +623,7 @@ "name": "string.quoted.double.html", "patterns": [ { - "include": "#astro-interpolations" + "include": "#astro-expressions" }, { "include": "#entities" @@ -637,7 +647,7 @@ "name": "string.quoted.single.html", "patterns": [ { - "include": "#astro-interpolations" + "include": "#astro-expressions" }, { "include": "#entities" @@ -661,11 +671,11 @@ "include": "#string-single-quoted" }, { - "include": "#astro-interpolations" + "include": "#astro-expressions" } ] }, - "astro-interpolations": { + "astro-expressions": { "patterns": [ { "begin": "\\{",