From 4cb6ed6329b3489f6b5cd946b3f9df4e6b4d8f5c Mon Sep 17 00:00:00 2001 From: Michael Klaus Date: Fri, 11 Oct 2024 12:49:01 -0600 Subject: [PATCH 1/6] Cherry pick 9ba4e6f92ce0c2d982763896ed7ed8c9ec3a1436 from QaDeS --- AUTHORS | 2 + README.md | 8 +++ client/index.ts | 10 +++ client/preview.ts | 179 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 32 +++++++++ webpack.config.js | 2 +- 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 client/index.ts create mode 100644 client/preview.ts diff --git a/AUTHORS b/AUTHORS index cc4ecda..48958f5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,3 +9,5 @@ Sven Slootweg (https://github.com/joepie91/) yy0931 (https://github.com/yy0931) Rene Saarsoo (https://github.com/nene/) Lionel Rowe (https://github.com/lionel-rowe/) +Joe Andaverde (https://github.com/dynajoe) +Michael Klaus (https://github.com/qades78) diff --git a/README.md b/README.md index 5153ce2..2d70b69 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,14 @@ of all of the rules in the Outline view. ![Outline](/images/outline.png) +## Live Preview + +Live edit and test your Grammars, optionally starting at the rule under cursor. + +### Tips + +Name your grammar like this for optimal experience: grammar_name.language_extension.peggy. Where language_extension is the extension of the language you're parsing. This will provide syntax highlighting if you have a matching language server installed. + ## Contributing Feel free to contribute to this extension [here](https://github.com/peggyjs/code-peggy-language). diff --git a/client/index.ts b/client/index.ts new file mode 100644 index 0000000..0ad7040 --- /dev/null +++ b/client/index.ts @@ -0,0 +1,10 @@ +import { activate as activateClient, deactivate } from "./client"; +import { ExtensionContext } from "vscode"; +import { activate as activateLivePreview } from "./preview"; + +export function activate(context: ExtensionContext): void { + activateLivePreview(context); + activateClient(context); +} + +export { deactivate }; diff --git a/client/preview.ts b/client/preview.ts new file mode 100644 index 0000000..faccd15 --- /dev/null +++ b/client/preview.ts @@ -0,0 +1,179 @@ +import * as path from "path"; +import * as peggy from "peggy"; + +import { + ExtensionContext, + OutputChannel, + Uri, + ViewColumn, + commands, + window, + workspace, +} from "vscode"; +import { MemFS } from "./memFs"; + +const PEGGY_INPUT_SCHEME = "peggyin"; + +interface GrammarConfig { + name: string; + key: string; + start_rule: string | undefined; + grammar_uri: Uri; + input_uri: Uri; + timeout?: NodeJS.Timer; + grammar_text?: string; + parser?: any; +} + +async function executeAndDisplayResults( + output: OutputChannel, + config: GrammarConfig +): Promise { + output.clear(); + output.show(true); + output.appendLine( + `${config.name} ${config.start_rule ? `(${config.start_rule})` : ""}` + ); + + try { + const [grammar_document, input_document] = [ + await workspace.openTextDocument(config.grammar_uri), + await workspace.openTextDocument(config.input_uri), + ]; + + const grammar_text = grammar_document.getText(); + + config.parser + = grammar_text === config.grammar_text + ? config.parser + : peggy.generate( + grammar_text, + config.start_rule + ? { + allowedStartRules: [config.start_rule], + } + : undefined + ); + + config.grammar_text = grammar_text; + + const input = input_document.getText(); + const result = config.parser.parse( + input, + config.start_rule ? { startRule: config.start_rule } : undefined + ); + + output.appendLine(JSON.stringify(result, null, 3)); + } catch (error) { + output.append(error.toString()); + } +} + +function debounceExecution(output: OutputChannel, config: GrammarConfig): void { + clearTimeout(config.timeout); + + config.timeout = setTimeout(() => { + executeAndDisplayResults(output, config); + }, 300); +} + +export function activate(context: ExtensionContext): void { + const peggy_output = window.createOutputChannel("Peggy"); + const memory_fs = new MemFS(); + const grammars = new Map(); + + function grammarNameFromUri(uri: Uri): string { + return path + .basename(uri.fsPath) + .replace(/.(pegjs|peggy)$/, "") + .replace(/^[(][^)]+[)]__/, ""); + } + + function trackGrammar( + grammar_document_uri: Uri, + start_rule?: string + ): GrammarConfig { + const grammar_name = grammarNameFromUri(grammar_document_uri); + const key = `${grammar_name}:${start_rule || "*"}`; + + /* + Const base_path = path.dirname(grammar_document_uri.toString()); + const input_document_uri = start_rule + ? Uri.parse(`${base_path}/(${start_rule})__${grammar_name}`) + : Uri.parse(`${base_path}/${grammar_name}`); + */ + const input_document_uri = start_rule + ? Uri.parse(`${PEGGY_INPUT_SCHEME}:/(${start_rule})__${grammar_name}`) + : Uri.parse(`${PEGGY_INPUT_SCHEME}:/${grammar_name}`); + + const is_input_document_open = workspace.textDocuments.find( + d => d.uri === input_document_uri + ); + + if (!is_input_document_open) { + workspace.fs.writeFile(input_document_uri, Buffer.from("")).then(() => { + window.showTextDocument(input_document_uri, { + viewColumn: ViewColumn.Beside, + preserveFocus: true, + }); + }); + } + + grammars.set(key, { + name: grammar_name, + key, + start_rule, + grammar_uri: grammar_document_uri, + input_uri: input_document_uri, + }); + + return grammars.get(key); + } + + const documents_changed = workspace.onDidChangeTextDocument(async e => { + const document_uri_string = e.document.uri.toString(); + + for (const config of grammars.values()) { + if ( + config.grammar_uri.toString() === document_uri_string + || config.input_uri.toString() === document_uri_string + ) { + await executeAndDisplayResults(peggy_output, config); + } + } + }); + + const documents_closed = workspace.onDidCloseTextDocument(e => { + const to_remove = [...grammars.values()].filter( + config => config.grammar_uri === e.uri || config.input_uri === e.uri + ); + + to_remove.forEach(config => { + grammars.delete(config.key); + }); + }); + + context.subscriptions.push( + documents_changed, + documents_closed, + peggy_output, + commands.registerTextEditorCommand("editor.peggyLive", editor => { + const grammar_config = trackGrammar(editor.document.uri); + debounceExecution(peggy_output, grammar_config); + }), + commands.registerTextEditorCommand("editor.peggyLiveFromRule", editor => { + const word_range = editor.document.getWordRangeAtPosition( + editor.selection.start, + /[_$a-zA-Z\xA0-\uFFFF][_$a-zA-Z0-9\xA0-\uFFFF]*/ + ); + + if (word_range !== null) { + const rule_name = editor.document.getText(word_range); + const grammar_config = trackGrammar(editor.document.uri, rule_name); + + debounceExecution(peggy_output, grammar_config); + } + }) + ); + workspace.registerFileSystemProvider(PEGGY_INPUT_SCHEME, memory_fs); +} diff --git a/package.json b/package.json index 1e306f9..9ad821e 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "homepage": "https://github.com/peggyjs/code-peggy-language", "categories": [ + "Debuggers", "Programming Languages", "Snippets" ], @@ -22,6 +23,11 @@ "type": "git", "url": "https://github.com/peggyjs/code-peggy-language" }, + "activationEvents": [ + "onLanguage:peggy", + "onCommand:editor.peggyLive", + "onCommand:editor.peggyLiveFromRule" + ], "main": "./out/client/client", "contributes": { "configuration": { @@ -91,6 +97,32 @@ "language": "peggy", "path": "./snippets/snippets.json" } + ], + "menus": { + "editor/context": [ + { + "command": "editor.peggyLive", + "group": "3_preview", + "when": "editorLangId == peggy" + }, + { + "command": "editor.peggyLiveFromRule", + "group": "3_preview", + "when": "editorLangId == peggy" + } + ] + }, + "commands": [ + { + "command": "editor.peggyLive", + "title": "Peggy Live Preview", + "category": "preview" + }, + { + "command": "editor.peggyLiveFromRule", + "title": "Peggy Live from rule under cursor", + "category": "preview" + } ] }, "scripts": { diff --git a/webpack.config.js b/webpack.config.js index 010b6e7..225dcd2 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -44,7 +44,7 @@ module.exports = { devtool: "source-map", entry: { "server": "./server/server.ts", - "client": "./client/client.ts", + "client": "./client/index.ts", }, output: { filename: "[name]/[name].js", From 16a260345cd85a3b6da9aa33af887793b4bcc798 Mon Sep 17 00:00:00 2001 From: Joe Hildebrand Date: Fri, 11 Oct 2024 13:18:04 -0600 Subject: [PATCH 2/6] Move to a vendored version of memFs to make provenance, license, ownership more clear, and to make easier to keep in sync. --- client/preview.ts | 2 +- vendor/vscode-extension-samples/LICENSE | 21 ++ vendor/vscode-extension-samples/REAME.md | 6 + .../fileSystemProvider.ts | 227 ++++++++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 vendor/vscode-extension-samples/LICENSE create mode 100644 vendor/vscode-extension-samples/REAME.md create mode 100644 vendor/vscode-extension-samples/fileSystemProvider.ts diff --git a/client/preview.ts b/client/preview.ts index faccd15..3e038a7 100644 --- a/client/preview.ts +++ b/client/preview.ts @@ -10,7 +10,7 @@ import { window, workspace, } from "vscode"; -import { MemFS } from "./memFs"; +import { MemFS } from "../vendor/vscode-extension-samples/fileSystemProvider"; const PEGGY_INPUT_SCHEME = "peggyin"; diff --git a/vendor/vscode-extension-samples/LICENSE b/vendor/vscode-extension-samples/LICENSE new file mode 100644 index 0000000..293c59e --- /dev/null +++ b/vendor/vscode-extension-samples/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2015 - present Microsoft Corporation + +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. \ No newline at end of file diff --git a/vendor/vscode-extension-samples/REAME.md b/vendor/vscode-extension-samples/REAME.md new file mode 100644 index 0000000..bc890d7 --- /dev/null +++ b/vendor/vscode-extension-samples/REAME.md @@ -0,0 +1,6 @@ +Vendored from: + +https://raw.githubusercontent.com/microsoft/vscode-extension-samples/main/fsprovider-sample/src/fileSystemProvider.ts + +All formatting left intact. One TypeScript type error corrected on line 206 +to change NodeJS.Timer to NodeJS.Timeout. diff --git a/vendor/vscode-extension-samples/fileSystemProvider.ts b/vendor/vscode-extension-samples/fileSystemProvider.ts new file mode 100644 index 0000000..d656d2d --- /dev/null +++ b/vendor/vscode-extension-samples/fileSystemProvider.ts @@ -0,0 +1,227 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import * as path from 'path'; +import * as vscode from 'vscode'; + +export class File implements vscode.FileStat { + + type: vscode.FileType; + ctime: number; + mtime: number; + size: number; + + name: string; + data?: Uint8Array; + + constructor(name: string) { + this.type = vscode.FileType.File; + this.ctime = Date.now(); + this.mtime = Date.now(); + this.size = 0; + this.name = name; + } +} + +export class Directory implements vscode.FileStat { + + type: vscode.FileType; + ctime: number; + mtime: number; + size: number; + + name: string; + entries: Map; + + constructor(name: string) { + this.type = vscode.FileType.Directory; + this.ctime = Date.now(); + this.mtime = Date.now(); + this.size = 0; + this.name = name; + this.entries = new Map(); + } +} + +export type Entry = File | Directory; + +export class MemFS implements vscode.FileSystemProvider { + + root = new Directory(''); + + // --- manage file metadata + + stat(uri: vscode.Uri): vscode.FileStat { + return this._lookup(uri, false); + } + + readDirectory(uri: vscode.Uri): [string, vscode.FileType][] { + const entry = this._lookupAsDirectory(uri, false); + const result: [string, vscode.FileType][] = []; + for (const [name, child] of entry.entries) { + result.push([name, child.type]); + } + return result; + } + + // --- manage file contents + + readFile(uri: vscode.Uri): Uint8Array { + const data = this._lookupAsFile(uri, false).data; + if (data) { + return data; + } + throw vscode.FileSystemError.FileNotFound(); + } + + writeFile(uri: vscode.Uri, content: Uint8Array, options: { create: boolean, overwrite: boolean }): void { + const basename = path.posix.basename(uri.path); + const parent = this._lookupParentDirectory(uri); + let entry = parent.entries.get(basename); + if (entry instanceof Directory) { + throw vscode.FileSystemError.FileIsADirectory(uri); + } + if (!entry && !options.create) { + throw vscode.FileSystemError.FileNotFound(uri); + } + if (entry && options.create && !options.overwrite) { + throw vscode.FileSystemError.FileExists(uri); + } + if (!entry) { + entry = new File(basename); + parent.entries.set(basename, entry); + this._fireSoon({ type: vscode.FileChangeType.Created, uri }); + } + entry.mtime = Date.now(); + entry.size = content.byteLength; + entry.data = content; + + this._fireSoon({ type: vscode.FileChangeType.Changed, uri }); + } + + // --- manage files/folders + + rename(oldUri: vscode.Uri, newUri: vscode.Uri, options: { overwrite: boolean }): void { + + if (!options.overwrite && this._lookup(newUri, true)) { + throw vscode.FileSystemError.FileExists(newUri); + } + + const entry = this._lookup(oldUri, false); + const oldParent = this._lookupParentDirectory(oldUri); + + const newParent = this._lookupParentDirectory(newUri); + const newName = path.posix.basename(newUri.path); + + oldParent.entries.delete(entry.name); + entry.name = newName; + newParent.entries.set(newName, entry); + + this._fireSoon( + { type: vscode.FileChangeType.Deleted, uri: oldUri }, + { type: vscode.FileChangeType.Created, uri: newUri } + ); + } + + delete(uri: vscode.Uri): void { + const dirname = uri.with({ path: path.posix.dirname(uri.path) }); + const basename = path.posix.basename(uri.path); + const parent = this._lookupAsDirectory(dirname, false); + if (!parent.entries.has(basename)) { + throw vscode.FileSystemError.FileNotFound(uri); + } + parent.entries.delete(basename); + parent.mtime = Date.now(); + parent.size -= 1; + this._fireSoon({ type: vscode.FileChangeType.Changed, uri: dirname }, { uri, type: vscode.FileChangeType.Deleted }); + } + + createDirectory(uri: vscode.Uri): void { + const basename = path.posix.basename(uri.path); + const dirname = uri.with({ path: path.posix.dirname(uri.path) }); + const parent = this._lookupAsDirectory(dirname, false); + + const entry = new Directory(basename); + parent.entries.set(entry.name, entry); + parent.mtime = Date.now(); + parent.size += 1; + this._fireSoon({ type: vscode.FileChangeType.Changed, uri: dirname }, { type: vscode.FileChangeType.Created, uri }); + } + + // --- lookup + + private _lookup(uri: vscode.Uri, silent: false): Entry; + private _lookup(uri: vscode.Uri, silent: boolean): Entry | undefined; + private _lookup(uri: vscode.Uri, silent: boolean): Entry | undefined { + const parts = uri.path.split('/'); + let entry: Entry = this.root; + for (const part of parts) { + if (!part) { + continue; + } + let child: Entry | undefined; + if (entry instanceof Directory) { + child = entry.entries.get(part); + } + if (!child) { + if (!silent) { + throw vscode.FileSystemError.FileNotFound(uri); + } else { + return undefined; + } + } + entry = child; + } + return entry; + } + + private _lookupAsDirectory(uri: vscode.Uri, silent: boolean): Directory { + const entry = this._lookup(uri, silent); + if (entry instanceof Directory) { + return entry; + } + throw vscode.FileSystemError.FileNotADirectory(uri); + } + + private _lookupAsFile(uri: vscode.Uri, silent: boolean): File { + const entry = this._lookup(uri, silent); + if (entry instanceof File) { + return entry; + } + throw vscode.FileSystemError.FileIsADirectory(uri); + } + + private _lookupParentDirectory(uri: vscode.Uri): Directory { + const dirname = uri.with({ path: path.posix.dirname(uri.path) }); + return this._lookupAsDirectory(dirname, false); + } + + // --- manage file events + + private _emitter = new vscode.EventEmitter(); + private _bufferedEvents: vscode.FileChangeEvent[] = []; + private _fireSoonHandle?: NodeJS.Timeout; + + readonly onDidChangeFile: vscode.Event = this._emitter.event; + + watch(_resource: vscode.Uri): vscode.Disposable { + // ignore, fires for all changes... + return new vscode.Disposable(() => { }); + } + + private _fireSoon(...events: vscode.FileChangeEvent[]): void { + this._bufferedEvents.push(...events); + + if (this._fireSoonHandle) { + clearTimeout(this._fireSoonHandle); + } + + this._fireSoonHandle = setTimeout(() => { + this._emitter.fire(this._bufferedEvents); + this._bufferedEvents.length = 0; + }, 5); + } +} From 10e243bd03148c52392d4166e694766aa86509e2 Mon Sep 17 00:00:00 2001 From: Joe Hildebrand Date: Fri, 11 Oct 2024 13:38:51 -0600 Subject: [PATCH 3/6] Lint and ts clean --- client/preview.ts | 2 +- eslint.config.mjs | 1 + package.json | 5 ----- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/client/preview.ts b/client/preview.ts index 3e038a7..0fe15a9 100644 --- a/client/preview.ts +++ b/client/preview.ts @@ -20,7 +20,7 @@ interface GrammarConfig { start_rule: string | undefined; grammar_uri: Uri; input_uri: Uri; - timeout?: NodeJS.Timer; + timeout?: NodeJS.Timeout; grammar_text?: string; parser?: any; } diff --git a/eslint.config.mjs b/eslint.config.mjs index 3e943e5..af634bc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,7 @@ export default [ { ignores: [ "out/**", + "vendor/**", ], }, ...commonjs, diff --git a/package.json b/package.json index 9ad821e..a16c3c9 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,6 @@ "type": "git", "url": "https://github.com/peggyjs/code-peggy-language" }, - "activationEvents": [ - "onLanguage:peggy", - "onCommand:editor.peggyLive", - "onCommand:editor.peggyLiveFromRule" - ], "main": "./out/client/client", "contributes": { "configuration": { From 0ab3b20182a1ed4d6869ddb0a57ab4c8620f3e71 Mon Sep 17 00:00:00 2001 From: Joe Hildebrand Date: Sat, 12 Oct 2024 16:22:17 -0600 Subject: [PATCH 4/6] Integrate preview a little better. Ensure that spaces work in input file. Format output as javascript, which isn't quite right, but seems to work. Do deep inspect rather than JSON.stringify. Reuse server's debounce routine. --- client/preview.ts | 79 ++++++++++++------------ {server => common}/debounce.ts | 0 package.json | 1 + pnpm-lock.yaml | 9 +++ server/server.ts | 2 +- vendor/vscode-extension-samples/REAME.md | 3 +- 6 files changed, 50 insertions(+), 44 deletions(-) rename {server => common}/debounce.ts (100%) diff --git a/client/preview.ts b/client/preview.ts index 0fe15a9..2500c7b 100644 --- a/client/preview.ts +++ b/client/preview.ts @@ -1,6 +1,6 @@ import * as path from "path"; import * as peggy from "peggy"; - +import * as util from "node-inspect-extracted"; import { ExtensionContext, OutputChannel, @@ -11,8 +11,9 @@ import { workspace, } from "vscode"; import { MemFS } from "../vendor/vscode-extension-samples/fileSystemProvider"; +import { debounce } from "../common/debounce"; -const PEGGY_INPUT_SCHEME = "peggyin"; +const PEGGY_INPUT_SCHEME = "peggyjsin"; interface GrammarConfig { name: string; @@ -29,11 +30,8 @@ async function executeAndDisplayResults( output: OutputChannel, config: GrammarConfig ): Promise { - output.clear(); output.show(true); - output.appendLine( - `${config.name} ${config.start_rule ? `(${config.start_rule})` : ""}` - ); + let out = `// ${config.name} ${config.start_rule ? `(${config.start_rule})` : ""}\n`; try { const [grammar_document, input_document] = [ @@ -41,21 +39,22 @@ async function executeAndDisplayResults( await workspace.openTextDocument(config.input_uri), ]; + // Never leave it dirty; it's saved in memory anyway. + // Don't bother to wait for the promise. + input_document.save(); const grammar_text = grammar_document.getText(); - config.parser - = grammar_text === config.grammar_text - ? config.parser - : peggy.generate( - grammar_text, - config.start_rule - ? { - allowedStartRules: [config.start_rule], - } - : undefined - ); - - config.grammar_text = grammar_text; + if (grammar_text !== config.grammar_text) { + config.parser = peggy.generate( + grammar_text, + config.start_rule + ? { + allowedStartRules: [config.start_rule], + } + : undefined + ); + config.grammar_text = grammar_text; + } const input = input_document.getText(); const result = config.parser.parse( @@ -63,29 +62,34 @@ async function executeAndDisplayResults( config.start_rule ? { startRule: config.start_rule } : undefined ); - output.appendLine(JSON.stringify(result, null, 3)); + out += util.inspect(result, { + depth: Infinity, + colors: false, + maxArrayLength: Infinity, + maxStringLength: Infinity, + breakLength: 40, + sorted: true, + }); + out += "\n"; } catch (error) { - output.append(error.toString()); + out += error.toString(); + out += "\n"; } + // Replace once, since addLine causes issues with trailing spaces. + output.replace(out); } -function debounceExecution(output: OutputChannel, config: GrammarConfig): void { - clearTimeout(config.timeout); - - config.timeout = setTimeout(() => { - executeAndDisplayResults(output, config); - }, 300); -} +const debounceExecution = debounce(executeAndDisplayResults, 300); export function activate(context: ExtensionContext): void { - const peggy_output = window.createOutputChannel("Peggy"); + const peggy_output = window.createOutputChannel("Peggy Live", "javascript"); const memory_fs = new MemFS(); const grammars = new Map(); function grammarNameFromUri(uri: Uri): string { return path .basename(uri.fsPath) - .replace(/.(pegjs|peggy)$/, "") + .replace(/\.(pegjs|peggy)$/, "") .replace(/^[(][^)]+[)]__/, ""); } @@ -96,12 +100,6 @@ export function activate(context: ExtensionContext): void { const grammar_name = grammarNameFromUri(grammar_document_uri); const key = `${grammar_name}:${start_rule || "*"}`; - /* - Const base_path = path.dirname(grammar_document_uri.toString()); - const input_document_uri = start_rule - ? Uri.parse(`${base_path}/(${start_rule})__${grammar_name}`) - : Uri.parse(`${base_path}/${grammar_name}`); - */ const input_document_uri = start_rule ? Uri.parse(`${PEGGY_INPUT_SCHEME}:/(${start_rule})__${grammar_name}`) : Uri.parse(`${PEGGY_INPUT_SCHEME}:/${grammar_name}`); @@ -118,16 +116,15 @@ export function activate(context: ExtensionContext): void { }); }); } - - grammars.set(key, { + const config = { name: grammar_name, key, start_rule, grammar_uri: grammar_document_uri, input_uri: input_document_uri, - }); - - return grammars.get(key); + }; + grammars.set(key, config); + return config; } const documents_changed = workspace.onDidChangeTextDocument(async e => { diff --git a/server/debounce.ts b/common/debounce.ts similarity index 100% rename from server/debounce.ts rename to common/debounce.ts diff --git a/package.json b/package.json index a16c3c9..3676321 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "webpack-cli": "^5.1.4" }, "dependencies": { + "node-inspect-extracted": "3.0.2", "peggy": "^4.1.1", "source-map-generator": "0.8.0", "vscode-languageclient": "^9.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40a7d01..f12c86d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + node-inspect-extracted: + specifier: 3.0.2 + version: 3.0.2 peggy: specifier: ^4.1.1 version: 4.1.1 @@ -1356,6 +1359,10 @@ packages: node-addon-api@4.3.0: resolution: {integrity: sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==} + node-inspect-extracted@3.0.2: + resolution: {integrity: sha512-lOBe4RMfICoYRKZaCXLVoBt6t8wM93QLEIp2WuvPJ5yDTEzrp+LhquGp4RV283Vd5RJ+vUvBmRrGtRu2HCgWHw==} + engines: {node: '>=18'} + node-releases@2.0.18: resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} @@ -3482,6 +3489,8 @@ snapshots: node-addon-api@4.3.0: optional: true + node-inspect-extracted@3.0.2: {} + node-releases@2.0.18: {} nth-check@2.1.1: diff --git a/server/server.ts b/server/server.ts index 09751e5..f9ff5fd 100644 --- a/server/server.ts +++ b/server/server.ts @@ -23,7 +23,7 @@ import { } from "vscode-languageserver/node"; import { Position, TextDocument } from "vscode-languageserver-textdocument"; import type { SourceNode } from "source-map-generator"; -import { debounce } from "./debounce"; +import { debounce } from "../common/debounce"; function getSession( ast: peggy.ast.Grammar, diff --git a/vendor/vscode-extension-samples/REAME.md b/vendor/vscode-extension-samples/REAME.md index bc890d7..26f78b9 100644 --- a/vendor/vscode-extension-samples/REAME.md +++ b/vendor/vscode-extension-samples/REAME.md @@ -2,5 +2,4 @@ Vendored from: https://raw.githubusercontent.com/microsoft/vscode-extension-samples/main/fsprovider-sample/src/fileSystemProvider.ts -All formatting left intact. One TypeScript type error corrected on line 206 -to change NodeJS.Timer to NodeJS.Timeout. +All formatting left intact. From 954651ce9040caaecc665a665a78dd0ef1b1b267 Mon Sep 17 00:00:00 2001 From: Joe Hildebrand Date: Sat, 12 Oct 2024 18:05:23 -0600 Subject: [PATCH 5/6] Fixes #46 Wait for parsing to finish before updating outline. Depends on #53. --- server/server.ts | 316 +++++++++++++++++++++++---------------------- server/watchMap.ts | 49 +++++++ 2 files changed, 213 insertions(+), 152 deletions(-) create mode 100644 server/watchMap.ts diff --git a/server/server.ts b/server/server.ts index f9ff5fd..b4956cf 100644 --- a/server/server.ts +++ b/server/server.ts @@ -23,6 +23,7 @@ import { } from "vscode-languageserver/node"; import { Position, TextDocument } from "vscode-languageserver-textdocument"; import type { SourceNode } from "source-map-generator"; +import { WatchMap } from "./watchMap"; import { debounce } from "../common/debounce"; function getSession( @@ -35,10 +36,7 @@ function getSession( ast.code = session as unknown as SourceNode; } -interface AstCache { - [uri: string]: any; -} -const AST: AstCache = {}; +const AST = new WatchMap(); const WORD_RE = /[^\s{}[\]()`~!@#%^&*+\-=|\\;:'",./<>?]+/g; const PASSES: peggy.compiler.Stages = { prepare: peggy.compiler.passes.prepare, @@ -158,7 +156,7 @@ const validateTextDocument = debounce((doc: TextDocument): void => { { grammarSource: doc.uri, output: "source-and-map" } ) as unknown as peggy.Session; addProblemDiagnostics(session.problems, diagnostics); - AST[doc.uri] = ast; + AST.set(doc.uri, ast); } catch (error) { if (error instanceof peggy.GrammarError) { addProblemDiagnostics(error.problems, diagnostics); @@ -219,172 +217,186 @@ function ruleNameRange(name: string, ruleRange: Range): Range { }; } -connection.onCompletion((pos: TextDocumentPositionParams): CompletionItem[] => { - const docAST = AST[pos.textDocument.uri]; - if (!docAST || (docAST.rules.length === 0)) { - return null; - } - const document = documents.get(pos.textDocument.uri); - if (!document) { - return null; - } - const word = getWordAtPosition(document, pos.position); - if (word === "") { - return null; - } - - return docAST.rules.filter( - (r: any) => r.name.startsWith(word) - ).map((r: any) => ({ - label: r.name, - })); -}); +connection.onCompletion( + async(pos: TextDocumentPositionParams): Promise => { + const docAST = await AST.waitFor(pos.textDocument.uri); + if (!docAST || (docAST.rules.length === 0)) { + return null; + } + const document = documents.get(pos.textDocument.uri); + if (!document) { + return null; + } + const word = getWordAtPosition(document, pos.position); + if (word === "") { + return null; + } -connection.onDefinition((pos: TextDocumentPositionParams): LocationLink[] => { - const docAST = AST[pos.textDocument.uri]; - if (!docAST || (docAST.rules.length === 0)) { - return null; - } - const document = documents.get(pos.textDocument.uri); - if (!document) { - return null; - } - const word = getWordAtPosition(document, pos.position); - if (word === "") { - return null; + return docAST.rules.filter( + (r: any) => r.name.startsWith(word) + ).map((r: any) => ({ + label: r.name, + })); } +); - const rule = docAST.rules.find((r: any) => r.name === word); - if (!rule) { - return null; - } - const targetRange = peggyLoc_to_vscodeRange(rule.location); - const targetSelectionRange = ruleNameRange(rule.name, targetRange); - - return [ - { - targetUri: pos.textDocument.uri, - targetRange, - targetSelectionRange, - }, - ]; -}); +connection.onDefinition( + async(pos: TextDocumentPositionParams): Promise => { + const docAST = await AST.waitFor(pos.textDocument.uri); + if (!docAST || (docAST.rules.length === 0)) { + return null; + } + const document = documents.get(pos.textDocument.uri); + if (!document) { + return null; + } + const word = getWordAtPosition(document, pos.position); + if (word === "") { + return null; + } -connection.onReferences((pos: TextDocumentPositionParams): Location[] => { - const docAST = AST[pos.textDocument.uri]; - if (!docAST || (docAST.rules.length === 0)) { - return null; - } - const document = documents.get(pos.textDocument.uri); - if (!document) { - return null; - } - const word = getWordAtPosition(document, pos.position); - if (word === "") { - return null; + const rule = docAST.rules.find((r: any) => r.name === word); + if (!rule) { + return null; + } + const targetRange = peggyLoc_to_vscodeRange(rule.location); + const targetSelectionRange = ruleNameRange(rule.name, targetRange); + + return [ + { + targetUri: pos.textDocument.uri, + targetRange, + targetSelectionRange, + }, + ]; } - const results: Location[] = []; - const visit = peggy.compiler.visitor.build({ - rule_ref(node: any): void { - if (node.name !== word) { return; } - results.push({ - uri: pos.textDocument.uri, - range: peggyLoc_to_vscodeRange(node.location), - }); - }, - }); - visit(docAST); +); - return results; -}); +connection.onReferences( + async(pos: TextDocumentPositionParams): Promise => { + const docAST = await AST.get(pos.textDocument.uri); + if (!docAST || (docAST.rules.length === 0)) { + return null; + } + const document = documents.get(pos.textDocument.uri); + if (!document) { + return null; + } + const word = getWordAtPosition(document, pos.position); + if (word === "") { + return null; + } + const results: Location[] = []; + const visit = peggy.compiler.visitor.build({ + rule_ref(node: any): void { + if (node.name !== word) { return; } + results.push({ + uri: pos.textDocument.uri, + range: peggyLoc_to_vscodeRange(node.location), + }); + }, + }); + visit(docAST); -connection.onRenameRequest((pos: RenameParams): WorkspaceEdit => { - const docAST = AST[pos.textDocument.uri]; - if (!docAST || (docAST.rules.length === 0)) { - return null; - } - const document = documents.get(pos.textDocument.uri); - if (!document) { - return null; - } - const word = getWordAtPosition(document, pos.position); - if (word === "") { - return null; + return results; } +); - const edits: TextEdit[] = []; - const visit = peggy.compiler.visitor.build({ - rule_ref(node: any): void { - if (node.name !== word) { return; } - edits.push({ - newText: pos.newName, - range: peggyLoc_to_vscodeRange(node.location), - }); - }, - - rule(node: any): void { - visit(node.expression); - if (node.name !== word) { return; } - edits.push({ - newText: pos.newName, - range: ruleNameRange(node.name, peggyLoc_to_vscodeRange(node.location)), - }); - }, - }); - visit(docAST); +connection.onRenameRequest( + async(pos: RenameParams): Promise => { + const docAST = await AST.get(pos.textDocument.uri); + if (!docAST || (docAST.rules.length === 0)) { + return null; + } + const document = documents.get(pos.textDocument.uri); + if (!document) { + return null; + } + const word = getWordAtPosition(document, pos.position); + if (word === "") { + return null; + } - return { - changes: { - [pos.textDocument.uri]: edits, - }, - }; -}); + const edits: TextEdit[] = []; + const visit = peggy.compiler.visitor.build({ + rule_ref(node: any): void { + if (node.name !== word) { return; } + edits.push({ + newText: pos.newName, + range: peggyLoc_to_vscodeRange(node.location), + }); + }, + + rule(node: any): void { + visit(node.expression); + if (node.name !== word) { return; } + edits.push({ + newText: pos.newName, + range: ruleNameRange( + node.name, + peggyLoc_to_vscodeRange(node.location) + ), + }); + }, + }); + visit(docAST); -connection.onDocumentSymbol((pos: DocumentSymbolParams): DocumentSymbol[] => { - const docAST = AST[pos.textDocument.uri]; - if (!docAST) { - return null; + return { + changes: { + [pos.textDocument.uri]: edits, + }, + }; } +); - const symbols = docAST.rules.map((r: any) => { - const range = peggyLoc_to_vscodeRange(r.location); - const ret: DocumentSymbol = { - name: r.name, - kind: SymbolKind.Function, - range, - selectionRange: ruleNameRange(r.name, range), - }; - if (r.expression.type === "named") { - ret.detail = r.expression.name; +connection.onDocumentSymbol( + async(pos: DocumentSymbolParams): Promise => { + const docAST = await AST.waitFor(pos.textDocument.uri); + if (!docAST) { + return null; } - return ret; - }); - if (docAST.initializer) { - const range = peggyLoc_to_vscodeRange(docAST.initializer.location); - symbols.unshift({ - name: "{Per-parse initializer}", - kind: SymbolKind.Constructor, - range, - selectionRange: ruleNameRange("{", range), - }); - } - if (docAST.topLevelInitializer) { - const range = peggyLoc_to_vscodeRange(docAST.topLevelInitializer.location); - symbols.unshift({ - name: "{{Global initializer}}", - kind: SymbolKind.Constructor, - range, - selectionRange: ruleNameRange("{{", range), + const symbols = docAST.rules.map((r: any) => { + const range = peggyLoc_to_vscodeRange(r.location); + const ret: DocumentSymbol = { + name: r.name, + kind: SymbolKind.Function, + range, + selectionRange: ruleNameRange(r.name, range), + }; + if (r.expression.type === "named") { + ret.detail = r.expression.name; + } + + return ret; }); - } + if (docAST.initializer) { + const range = peggyLoc_to_vscodeRange(docAST.initializer.location); + symbols.unshift({ + name: "{Per-parse initializer}", + kind: SymbolKind.Constructor, + range, + selectionRange: ruleNameRange("{", range), + }); + } + if (docAST.topLevelInitializer) { + const range = peggyLoc_to_vscodeRange( + docAST.topLevelInitializer.location + ); + symbols.unshift({ + name: "{{Global initializer}}", + kind: SymbolKind.Constructor, + range, + selectionRange: ruleNameRange("{{", range), + }); + } - return symbols; -}); + return symbols; + } +); documents.onDidClose(change => { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete AST[change.document.uri.toString()]; + AST.delete(change.document.uri.toString()); }); documents.onDidChangeContent(change => { diff --git a/server/watchMap.ts b/server/watchMap.ts new file mode 100644 index 0000000..b05a8fb --- /dev/null +++ b/server/watchMap.ts @@ -0,0 +1,49 @@ +export type Notification = (key: K, value: V) => void; + +// Light pub-sub over a map +export class WatchMap extends Map { + #pending = new Map[]>(); + + public set(key: K, value: V): this { + super.set(key, value); + const watchers = this.#pending.get(key); + if (watchers) { + this.#pending.delete(key); + for (const w of watchers) { + w.call(this, key, value); + } + } + return this; + } + + public delete(key: K): boolean { + if (super.delete(key)) { + // This should never delete anything + this.#pending.delete(key); + return true; + } + return false; + } + + public waitFor(key: K): Promise { + return new Promise(resolve => { + if (this.has(key)) { + resolve(this.get(key)); + } else { + this.#watch(key, (_k, v) => { + resolve(v); + }); + } + }); + } + + #watch(key: K, cb: Notification): this { + let watchers = this.#pending.get(key); + if (!watchers) { + watchers = []; + this.#pending.set(key, watchers); + } + watchers.push(cb); + return this; + } +} From 85f258dce64ab5d414071409b285d31e8f1606a6 Mon Sep 17 00:00:00 2001 From: Joe Hildebrand Date: Sun, 13 Oct 2024 01:01:42 -0600 Subject: [PATCH 6/6] Perhaps slightly less chance of memory leaks now --- server/server.ts | 4 +++- server/watchMap.ts | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/server/server.ts b/server/server.ts index b4956cf..cbf509b 100644 --- a/server/server.ts +++ b/server/server.ts @@ -141,8 +141,9 @@ function addProblemDiagnostics( } const validateTextDocument = debounce((doc: TextDocument): void => { - const diagnostics: Diagnostic[] = []; + AST.delete(doc.uri); // Cancel any pending and start over. + const diagnostics: Diagnostic[] = []; try { const ast = peggy.parser.parse(doc.getText(), { grammarSource: doc.uri, @@ -177,6 +178,7 @@ const validateTextDocument = debounce((doc: TextDocument): void => { }; diagnostics.push(d); } + AST.set(doc.uri, null); } // Send the computed diagnostics to VS Code. diff --git a/server/watchMap.ts b/server/watchMap.ts index b05a8fb..544b334 100644 --- a/server/watchMap.ts +++ b/server/watchMap.ts @@ -1,11 +1,12 @@ -export type Notification = (key: K, value: V) => void; +type Notification = (key: K, value: V | null) => void; // Light pub-sub over a map -export class WatchMap extends Map { +export class WatchMap extends Map { #pending = new Map[]>(); - public set(key: K, value: V): this { + public set(key: K, value: V | null): this { super.set(key, value); + const watchers = this.#pending.get(key); if (watchers) { this.#pending.delete(key); @@ -17,15 +18,17 @@ export class WatchMap extends Map { } public delete(key: K): boolean { - if (super.delete(key)) { - // This should never delete anything + const watchers = this.#pending.get(key); + if (watchers) { this.#pending.delete(key); - return true; + for (const w of watchers) { + w.call(this, key, null); + } } - return false; + return super.delete(key); } - public waitFor(key: K): Promise { + public waitFor(key: K): Promise { return new Promise(resolve => { if (this.has(key)) { resolve(this.get(key));