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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0fdebbe --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +# Changes + +## 2.4.2 +- Live Preview \ No newline at end of file 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/memFs.ts b/client/memFs.ts new file mode 100644 index 0000000..4587278 --- /dev/null +++ b/client/memFs.ts @@ -0,0 +1,259 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable class-methods-use-this */ +/* eslint-disable @typescript-eslint/member-ordering */ +/* eslint-disable init-declarations */ +/* eslint-disable @typescript-eslint/init-declarations */ +/* eslint-disable new-cap */ +/* eslint-disable no-use-before-define */ +/* eslint-disable @typescript-eslint/explicit-member-accessibility */ +/* --------------------------------------------------------------------------------------------- + * 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 = Directory | File; + +export class MemFS implements vscode.FileSystemProvider { + root = new Directory(""); + + // --- manage file metadata + exists(uri: vscode.Uri): boolean { + return this._lookup(uri, true) !== null; + } + + 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.Timer; + + 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/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 19c37fb..640dcb7 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "peggy-language", "displayName": "Peggy Language", - "description": "Syntax highlighting and error reporting for Peggy", - "version": "2.4.1", + "description": "Syntax highlighting, live preview, and error reporting for Peggy", + "version": "2.4.2", "license": "MIT", "publisher": "PeggyJS", "author": { @@ -11,6 +11,7 @@ }, "homepage": "https://github.com/peggyjs/code-peggy-language", "categories": [ + "Debuggers", "Programming Languages", "Snippets" ], @@ -23,7 +24,9 @@ "url": "https://github.com/peggyjs/code-peggy-language" }, "activationEvents": [ - "onLanguage:peggy" + "onLanguage:peggy", + "onCommand:editor.peggyLive", + "onCommand:editor.peggyLiveFromRule" ], "main": "./out/client/client", "contributes": { @@ -94,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 c70431a..703e93c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -42,7 +42,7 @@ module.exports = { devtool: "source-map", entry: { "server": "./server/server.ts", - "client": "./client/client.ts", + "client": "./client/index.ts", }, output: { filename: "[name]/[name].js",