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
This commit is contained in:
Nate Moore 2021-04-23 17:53:01 -05:00 committed by GitHub
parent 62ddea7bb7
commit 87af0aead8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 3087 additions and 150 deletions

View file

@ -1 +1,2 @@
src/parser/parse/**/*.ts
vscode/**/*.ts

20
.vscode/launch.json vendored
View file

@ -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"]
}
]
}

18
.vscode/tasks.json vendored
View file

@ -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"
]
}
]
}

View file

@ -35,4 +35,4 @@ export let permalink: string;
<!-- Global Stylesheets -->
<link rel="stylesheet" href="/css/app.css" />
<link href="https://fonts.googleapis.com/css2?family=Overpass:wght@400;700;900&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Overpass:wght@400;700;900&display=swap" rel="stylesheet" />

View file

@ -17,4 +17,4 @@ import Menu from './Menu.astro';
</section>
</div>
</BaseLayout>
</BaseLayout>

View file

@ -0,0 +1 @@
console.log('Hello world!');

View file

@ -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": {

BIN
vscode/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

@ -1,6 +1,6 @@
{
"name": "astro",
"version": "0.1.0",
"version": "0.3.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -15,6 +15,7 @@
"version": "file:packages/server",
"dev": true,
"requires": {
"vscode-emmet-helper": "2.1.2",
"vscode-html-languageservice": "^3.0.3",
"vscode-languageserver": "^6.1.1",
"vscode-languageserver-textdocument": "^1.0.1"
@ -22,13 +23,11 @@
"dependencies": {
"astro": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/astro/-/astro-0.1.0.tgz",
"integrity": "sha512-i3Do2oj2y7Lc9ebTq6RUOfFGnXgG9R/KmfUW8IMqxDZh/vjkgLQ3diha5MAD9Lval4nYIMc/mUwQ4pciZywnnA==",
"dependencies": {
"@astro-vscode/client": {
"version": "file:packages/client",
"dev": true,
"requires": {
"vscode-languageclient": "^7.1.0-next.4"
}
"version": "file:packages/client"
}
}
},
@ -103,6 +102,27 @@
}
}
},
"@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=="
},
"balanced-match": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
@ -125,12 +145,26 @@
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
"dev": true
},
"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"
}
},
"esbuild": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.10.0.tgz",
"integrity": "sha512-g+/Fk18bP7GAx0eG2RTfvjbsdB6RSchvvrrokFX8UexrTtPo0ZF0R1KViUu5v0A4Uu8m3I7pZhEyZveiOk0/JA==",
"dev": true
},
"jsonc-parser": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz",
"integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -158,6 +192,26 @@
"lru-cache": "^6.0.0"
}
},
"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=="
}
}
},
"vscode-html-languageservice": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-3.2.0.tgz",

View file

@ -2,7 +2,12 @@
"name": "astro",
"displayName": "Astro",
"description": "Language support for Astro",
"version": "0.1.0",
"icon": "assets/icon.png",
"galleryBanner": {
"color": "#FF5D01",
"theme": "dark"
},
"version": "0.3.0",
"author": "Astro",
"publisher": "astro-build",
"license": "MIT",
@ -16,10 +21,11 @@
"vscode": "^1.52.0"
},
"activationEvents": [
"*"
"onLanguage:astro"
],
"dependencies": {
"vscode-html-languageservice": "^3.0.3"
"vscode-html-languageservice": "^3.0.3",
"vscode-emmet-helper": "2.1.2"
},
"devDependencies": {
"esbuild": "0.10.0",
@ -32,7 +38,29 @@
"languages/",
"syntaxes/"
],
"repository": {
"type": "git",
"directory": "vscode",
"url": "https://github.com/snowpackjs/astro"
},
"contributes": {
"configuration": {
"type": "object",
"title": "Astro configuration",
"properties": {
"astro.trace.server": {
"scope": "window",
"type": "string",
"enum": [
"off",
"messages",
"verbose"
],
"default": "off",
"description": "Traces the communication between VS Code and the language server."
}
}
},
"languages": [
{
"id": "astro",
@ -50,13 +78,17 @@
"language": "astro",
"scopeName": "text.html.astro",
"path": "./syntaxes/astro.tmLanguage.json",
"injectTo": [
"text.html.markdown"
],
"embeddedLanguages": {
"text.html.astro": "astro",
"text.html": "html",
"source.css": "css",
"source.scss": "scss",
"source.sass": "sass",
"source.tsx": "typescriptreact"
"source.tsx": "typescriptreact",
"meta.embedded.block.frontmatter": "typescriptreact"
}
}
]

View file

@ -1,30 +0,0 @@
import * as vscode from 'vscode';
export async function activate() {
onConfigUpdated();
vscode.workspace.onDidChangeConfiguration(onConfigUpdated);
function onConfigUpdated() {
const astro = vscode.extensions.getExtension('skypack.astro');
if (!astro) {
return;
}
const emmet = vscode.extensions.getExtension('vscode.emmet');
if (!emmet) {
return;
}
const emmetIncludeLanguages = getEmmetIncludeLanguages();
if (emmetIncludeLanguages && emmetIncludeLanguages['astro']) {
return;
}
setEmmetIncludeLanguages({ ...emmetIncludeLanguages, astro: 'html' });
}
function getEmmetIncludeLanguages() {
return vscode.workspace.getConfiguration('emmet').get<Record<string, string>>('includeLanguages');
}
function setEmmetIncludeLanguages(value: Record<string, string>) {
return vscode.workspace.getConfiguration('emmet').set('includeLanguages', value);
}
}

View file

@ -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<string>,
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<boolean>(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);
}

View file

@ -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<lsp.TextDocumentPositionParams, string, any> = 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 },
},
],
});
}

View file

@ -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.
"""

View file

@ -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",

View file

@ -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"

View file

@ -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;
}
}

View file

@ -0,0 +1 @@
export * from './ConfigManager';

View file

@ -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;
}
}

View file

@ -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<string>();
private documents: Map<string, Document> = new Map();
private locked = new Set<string>();
private deleteCandidates = new Set<string>();
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);
}
}

View file

@ -0,0 +1,2 @@
export * from './Document';
export * from './DocumentManager';

View file

@ -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 }
}
}
}

View file

@ -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(<any>{ 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;
}
// <Foo checked={a < 1}>
// 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;
}

View file

@ -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;
}

View file

@ -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<TextDocument> = new TextDocuments(TextDocument);
const TagCloseRequest: RequestType<TextDocumentPositionParams, string | null, any> = 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();

View file

@ -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<CompletionList> {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
const completions = (
await this.execute<CompletionList>(
'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<CompletionItem> {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
const result = await this.execute<CompletionItem>(
'resolveCompletion',
[document, completionItem],
ExecuteMode.FirstNonNull
);
return result ?? completionItem;
}
async doTagComplete(
textDocument: TextDocumentIdentifier,
position: Position
): Promise<string | null> {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
return this.execute<string | null>(
'doTagComplete',
[document, position],
ExecuteMode.FirstNonNull
);
}
async getFoldingRanges(
textDocument: TextDocumentIdentifier
): Promise<FoldingRange[]|null> {
const document = this.getDocument(textDocument.uri);
if (!document) {
throw new Error('Cannot call methods on an unopened document');
}
const foldingRanges = flatten(await this.execute<FoldingRange[]>(
'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<T>(
name: keyof d.LSProvider,
args: any[],
mode: ExecuteMode.FirstNonNull
): Promise<T | null>;
private execute<T>(
name: keyof d.LSProvider,
args: any[],
mode: ExecuteMode.Collect
): Promise<T[]>;
private execute(name: keyof d.LSProvider, args: any[], mode: ExecuteMode.None): Promise<void>;
private async execute<T>(
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;
}
}
}

View file

@ -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<AppCompletionList | null> {
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<FoldingRange[]> {
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;
}
}

View file

@ -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<Document, HTMLDocument>();
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));
}
}

View file

@ -0,0 +1,5 @@
export * from './PluginHost';
export * from './astro/AstroPlugin';
export * from './html/HTMLPlugin';
export * from './typescript/TypeScriptPlugin';
export * from './interfaces';

View file

@ -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> = T | Promise<T>;
export interface AppCompletionItem<T extends TextDocumentIdentifier = any> extends CompletionItem {
data?: T;
}
export interface AppCompletionList<T extends TextDocumentIdentifier = any> extends CompletionList {
items: Array<AppCompletionItem<T>>;
}
export interface DiagnosticsProvider {
getDiagnostics(document: Document): Resolvable<Diagnostic[]>;
}
export interface HoverProvider {
doHover(document: Document, position: Position): Resolvable<Hover | null>;
}
export interface FoldingRangeProvider {
getFoldingRanges(document: Document): Resolvable<FoldingRange[]|null>;
}
export interface CompletionsProvider<T extends TextDocumentIdentifier = any> {
getCompletions(
document: Document,
position: Position,
completionContext?: CompletionContext
): Resolvable<AppCompletionList<T> | null>;
resolveCompletion?(
document: Document,
completionItem: AppCompletionItem<T>
): Resolvable<AppCompletionItem<T>>;
}
export interface FormattingProvider {
formatDocument(document: Document, options: FormattingOptions): Resolvable<TextEdit[]>;
}
export interface TagCompleteProvider {
doTagComplete(document: Document, position: Position): Resolvable<string | null>;
}
export interface DocumentColorsProvider {
getDocumentColors(document: Document): Resolvable<ColorInformation[]>;
}
export interface ColorPresentationsProvider {
getColorPresentations(
document: Document,
range: Range,
color: Color
): Resolvable<ColorPresentation[]>;
}
export interface DocumentSymbolsProvider {
getDocumentSymbols(document: Document): Resolvable<SymbolInformation[]>;
}
export interface DefinitionsProvider {
getDefinitions(document: Document, position: Position): Resolvable<DefinitionLink[]>;
}
export interface BackwardsCompatibleDefinitionsProvider {
getDefinitions(
document: Document,
position: Position
): Resolvable<DefinitionLink[] | Location[]>;
}
export interface CodeActionsProvider {
getCodeActions(
document: Document,
range: Range,
context: CodeActionContext
): Resolvable<CodeAction[]>;
executeCommand?(
document: Document,
command: string,
args?: any[]
): Resolvable<WorkspaceEdit | string | null>;
}
export interface FileRename {
oldUri: string;
newUri: string;
}
export interface UpdateImportsProvider {
updateImports(fileRename: FileRename): Resolvable<WorkspaceEdit | null>;
}
export interface RenameProvider {
rename(
document: Document,
position: Position,
newName: string
): Resolvable<WorkspaceEdit | null>;
prepareRename(document: Document, position: Position): Resolvable<Range | null>;
}
export interface FindReferencesProvider {
findReferences(
document: Document,
position: Position,
context: ReferenceContext
): Promise<Location[] | null>;
}
export interface SignatureHelpProvider {
getSignatureHelp(
document: Document,
position: Position,
context: SignatureHelpContext | undefined
): Resolvable<SignatureHelp | null>;
}
export interface SelectionRangeProvider {
getSelectionRange(document: Document, position: Position): Resolvable<SelectionRange | null>;
}
export interface SemanticTokensProvider {
getSemanticTokens(textDocument: Document, range?: Range): Resolvable<SemanticTokens | null>;
}
export interface LinkedEditingRangesProvider {
getLinkedEditingRanges(
document: Document,
position: Position
): Resolvable<LinkedEditingRanges | null>;
}
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
>;

View file

@ -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<DocumentSnapshot>;
async getSnapshot(pathOrDoc: string | Document): Promise<DocumentSnapshot>;
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<SnapshotManager> {
return (await this.getTypeScriptLanguageService(filePath)).snapshotManager;
}
private getTypeScriptLanguageService(filePath: string): Promise<LanguageServiceContainer> {
return getLanguageService(filePath, this.workspaceUris, this.docContext);
}
}

View file

@ -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<string, DocumentSnapshot> = 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<DocumentFragmentSnapshot>;
/**
* 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<DocumentFragmentSnapshot> {
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<DocumentSnapshot, 'getFragment'|'destroyFragment'> {
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<DocumentFragmentSnapshot> {
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++;
}
}

View file

@ -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<AppCompletionList<CompletionEntryWithIdentifer> | null> {
const completions = await this.completionProvider.getCompletions(
document,
position,
completionContext
);
return completions;
}
async resolveCompletion(
document: Document,
completionItem: AppCompletionItem<CompletionEntryWithIdentifer>
): Promise<AppCompletionItem<CompletionEntryWithIdentifer>> {
return this.completionProvider.resolveCompletion(document, completionItem);
}
async onWatchFileChanges(onWatchFileChangesParams: any[]): Promise<void> {
const doneUpdateProjectFiles = new Set<SnapshotManager>();
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);
}
}

View file

@ -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;
}

View file

@ -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<CompletionEntryWithIdentifer> {
constructor(private lang: LanguageServiceManager) {}
async getCompletions(document: Document, position: Position, completionContext?: CompletionContext): Promise<AppCompletionList<CompletionEntryWithIdentifer> | 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<CompletionEntryWithIdentifer>): Promise<AppCompletionItem<CompletionEntryWithIdentifer>> {
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<string>
): AppCompletionItem<CompletionEntryWithIdentifer> | 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,
};
}
}

View file

@ -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<string, Promise<LanguageServiceContainer>>();
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<LanguageServiceContainer> {
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<ts.LanguageService> {
return getLanguageServiceForPath(document.getFilePath() || '', workspaceUris, docContext);
}
export async function getLanguageServiceForPath(path: string, workspaceUris: string[], docContext: LanguageServiceDocumentContext): Promise<ts.LanguageService> {
return (await getLanguageService(path, workspaceUris, docContext)).getService();
}
async function createLanguageService(tsconfigPath: string, workspaceRoot: string, docContext: LanguageServiceDocumentContext): Promise<LanguageServiceContainer> {
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'];
}

View file

@ -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;
}

View file

@ -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<T>(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<T>(
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);
};
}

View file

@ -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"],
};

View file

@ -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|<!--(?!.*--\\s*>)\n|^<!--\\ \\#tminclude\\ (?>.*?-->)$\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|^(?!.*?<!--).*?--\\s*>\n|^<!--\\ end\\ tminclude\\ -->$\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": "\\{",