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..2500c7b --- /dev/null +++ b/client/preview.ts @@ -0,0 +1,176 @@ +import * as path from "path"; +import * as peggy from "peggy"; +import * as util from "node-inspect-extracted"; +import { + ExtensionContext, + OutputChannel, + Uri, + ViewColumn, + commands, + window, + workspace, +} from "vscode"; +import { MemFS } from "../vendor/vscode-extension-samples/fileSystemProvider"; +import { debounce } from "../common/debounce"; + +const PEGGY_INPUT_SCHEME = "peggyjsin"; + +interface GrammarConfig { + name: string; + key: string; + start_rule: string | undefined; + grammar_uri: Uri; + input_uri: Uri; + timeout?: NodeJS.Timeout; + grammar_text?: string; + parser?: any; +} + +async function executeAndDisplayResults( + output: OutputChannel, + config: GrammarConfig +): Promise { + output.show(true); + let out = `// ${config.name} ${config.start_rule ? `(${config.start_rule})` : ""}\n`; + + try { + const [grammar_document, input_document] = [ + await workspace.openTextDocument(config.grammar_uri), + 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(); + + 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( + input, + config.start_rule ? { startRule: config.start_rule } : undefined + ); + + out += util.inspect(result, { + depth: Infinity, + colors: false, + maxArrayLength: Infinity, + maxStringLength: Infinity, + breakLength: 40, + sorted: true, + }); + out += "\n"; + } catch (error) { + out += error.toString(); + out += "\n"; + } + // Replace once, since addLine causes issues with trailing spaces. + output.replace(out); +} + +const debounceExecution = debounce(executeAndDisplayResults, 300); + +export function activate(context: ExtensionContext): void { + 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(/^[(][^)]+[)]__/, ""); + } + + function trackGrammar( + grammar_document_uri: Uri, + start_rule?: string + ): GrammarConfig { + const grammar_name = grammarNameFromUri(grammar_document_uri); + const key = `${grammar_name}:${start_rule || "*"}`; + + 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, + }); + }); + } + const config = { + name: grammar_name, + key, + start_rule, + grammar_uri: grammar_document_uri, + input_uri: input_document_uri, + }; + grammars.set(key, config); + return config; + } + + 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/server/debounce.ts b/common/debounce.ts similarity index 100% rename from server/debounce.ts rename to common/debounce.ts 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 1e306f9..3676321 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "homepage": "https://github.com/peggyjs/code-peggy-language", "categories": [ + "Debuggers", "Programming Languages", "Snippets" ], @@ -91,6 +92,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": { @@ -120,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..cbf509b 100644 --- a/server/server.ts +++ b/server/server.ts @@ -23,7 +23,8 @@ import { } from "vscode-languageserver/node"; import { Position, TextDocument } from "vscode-languageserver-textdocument"; import type { SourceNode } from "source-map-generator"; -import { debounce } from "./debounce"; +import { WatchMap } from "./watchMap"; +import { debounce } from "../common/debounce"; function getSession( ast: peggy.ast.Grammar, @@ -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, @@ -143,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, @@ -158,7 +157,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); @@ -179,6 +178,7 @@ const validateTextDocument = debounce((doc: TextDocument): void => { }; diagnostics.push(d); } + AST.set(doc.uri, null); } // Send the computed diagnostics to VS Code. @@ -219,172 +219,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; + return results; } - const word = getWordAtPosition(document, pos.position); - if (word === "") { - return null; - } - - 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..544b334 --- /dev/null +++ b/server/watchMap.ts @@ -0,0 +1,52 @@ +type Notification = (key: K, value: V | null) => void; + +// Light pub-sub over a map +export class WatchMap extends Map { + #pending = new Map[]>(); + + public set(key: K, value: V | null): 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 { + const watchers = this.#pending.get(key); + if (watchers) { + this.#pending.delete(key); + for (const w of watchers) { + w.call(this, key, null); + } + } + return super.delete(key); + } + + 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; + } +} 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..26f78b9 --- /dev/null +++ b/vendor/vscode-extension-samples/REAME.md @@ -0,0 +1,5 @@ +Vendored from: + +https://raw.githubusercontent.com/microsoft/vscode-extension-samples/main/fsprovider-sample/src/fileSystemProvider.ts + +All formatting left intact. 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); + } +} 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",