diff --git a/packages/components/package.json b/packages/components/package.json index 3983954a..7c64463c 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -18,6 +18,7 @@ "vega": "^5.22.1", "vega-embed": "^6.21.0", "vega-lite": "^5.2.0", + "vscode-uri": "^3.0.3", "yup": "^0.32.11" }, "devDependencies": { diff --git a/packages/vscode-ext/.gitignore b/packages/vscode-ext/.gitignore index c712b96a..e1e96097 100644 --- a/packages/vscode-ext/.gitignore +++ b/packages/vscode-ext/.gitignore @@ -1,3 +1,4 @@ /media/vendor /out /*.vsix +/syntaxes/*.json diff --git a/packages/vscode-ext/README.md b/packages/vscode-ext/README.md index afedd282..9d23c1e2 100644 --- a/packages/vscode-ext/README.md +++ b/packages/vscode-ext/README.md @@ -4,6 +4,17 @@ _[marketplace](https://marketplace.visualstudio.com/items?itemName=QURI.vscode-s This extension provides support for [Squiggle](https://www.squiggle-language.com/) in VS Code. +Features: + +- Preview `.squiggle` files in a preview pane +- Syntax highlighting for `.squiggle` and `.squiggleU` files + +# Configuration + +Some preview settings, e.g. whether to show the summary table or types of outputs, can be configurable on in the VS Code settings and persist between different preview sessions. + +Check out the full list of Squiggle settings in the main VS Code settings. + # Build locally We assume you ran `yarn` at the monorepo level for all dependencies. diff --git a/packages/vscode-ext/language-configuration.json b/packages/vscode-ext/language-configuration.json new file mode 100644 index 00000000..727d0444 --- /dev/null +++ b/packages/vscode-ext/language-configuration.json @@ -0,0 +1,18 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "'", "close": "'", "notIn": ["string", "comment"] }, + { "open": "\"", "close": "\"", "notIn": ["string", "comment"] } + ] +} diff --git a/packages/vscode-ext/media/previewWebview.js b/packages/vscode-ext/media/previewWebview.js new file mode 100644 index 00000000..556a893b --- /dev/null +++ b/packages/vscode-ext/media/previewWebview.js @@ -0,0 +1,41 @@ +(function () { + const vscode = acquireVsCodeApi(); + + const container = document.getElementById("root"); + + const root = ReactDOM.createRoot(container); + function updateContent(text, showSettings) { + root.render( + React.createElement(squiggle_components.SquigglePlayground, { + code: text, + showEditor: false, + showTypes: Boolean(showSettings.showTypes), + showControls: Boolean(showSettings.showControls), + showSummary: Boolean(showSettings.showSummary), + }) + ); + } + + // Handle messages sent from the extension to the webview + window.addEventListener("message", (event) => { + const message = event.data; // The json data that the extension sent + switch (message.type) { + case "update": + const { text, showSettings } = message; + + // Update our webview's content + updateContent(text, showSettings); + + // Then persist state information. + // This state is returned in the call to `vscode.getState` below when a webview is reloaded. + vscode.setState({ text, showSettings }); + + return; + } + }); + + const state = vscode.getState(); + if (state) { + updateContent(state.text, state.showSettings); + } +})(); diff --git a/packages/vscode-ext/media/wysiwyg.js b/packages/vscode-ext/media/wysiwygWebview.js similarity index 100% rename from packages/vscode-ext/media/wysiwyg.js rename to packages/vscode-ext/media/wysiwygWebview.js diff --git a/packages/vscode-ext/package.json b/packages/vscode-ext/package.json index bcd18474..af70abda 100644 --- a/packages/vscode-ext/package.json +++ b/packages/vscode-ext/package.json @@ -3,7 +3,7 @@ "displayName": "Squiggle", "description": "Squiggle language support", "license": "MIT", - "version": "0.0.4", + "version": "0.1.2", "publisher": "QURI", "repository": { "type": "git", @@ -18,10 +18,45 @@ "Visualization" ], "activationEvents": [ - "onCustomEditor:squiggle.wysiwyg" + "onCustomEditor:squiggle.wysiwyg", + "onCommand:squiggle.preview" ], "main": "./out/extension.js", "contributes": { + "languages": [ + { + "id": "squiggle", + "extensions": [ + ".squiggle" + ], + "aliases": [ + "Squiggle" + ], + "configuration": "./language-configuration.json" + }, + { + "id": "squiggleU", + "extensions": [ + ".squiggleU" + ], + "aliases": [ + "SquiggleU" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "squiggle", + "scopeName": "source.squiggle", + "path": "./syntaxes/squiggle.tmLanguage.json" + }, + { + "language": "squiggleU", + "scopeName": "source.squiggle", + "path": "./syntaxes/squiggle.tmLanguage.json" + } + ], "customEditors": [ { "viewType": "squiggle.wysiwyg", @@ -31,29 +66,82 @@ "filenamePattern": "*.squiggle" } ], - "priority": "default" + "priority": "option" } ], - "commands": [] + "commands": [ + { + "command": "squiggle.preview", + "title": "Open Preview", + "category": "Squiggle", + "when": "editorLangId == squiggle", + "icon": "$(open-preview)" + } + ], + "menus": { + "editor/title": [ + { + "command": "squiggle.preview", + "when": "editorLangId == squiggle", + "group": "navigation" + } + ], + "commandPalette": [ + { + "command": "squiggle.preview", + "when": "editorLangId == squiggle" + } + ] + }, + "keybindings": [ + { + "command": "squiggle.preview", + "key": "ctrl+k v", + "mac": "cmd+k v", + "when": "editorLangId == squiggle" + } + ], + "configuration": { + "title": "Squiggle", + "properties": { + "squiggle.playground.showTypes": { + "type": "boolean", + "default": false, + "description": "Whether to show the types of outputs in the playground" + }, + "squiggle.playground.showControls": { + "type": "boolean", + "default": false, + "description": "Whether to show the log scale controls in the playground" + }, + "squiggle.playground.showSummary": { + "type": "boolean", + "default": false, + "description": "Whether to show the summary table in the playground" + } + } + } }, "scripts": { "vscode:prepublish": "yarn run compile", "compile:tsc": "tsc -p ./", + "compile:grammar": "js-yaml syntaxes/squiggle.tmLanguage.yaml >syntaxes/squiggle.tmLanguage.json", "compile:vendor": "(cd ../squiggle-lang && yarn run build) && (cd ../components && yarn run bundle && yarn run build:css) && mkdir -p media/vendor && cp ../components/dist/bundle.js media/vendor/components.js && cp ../components/dist/main.css media/vendor/components.css && cp ../../node_modules/react/umd/react.production.min.js media/vendor/react.js && cp ../../node_modules/react-dom/umd/react-dom.production.min.js media/vendor/react-dom.js && cp ../website/static/img/quri-logo.png media/vendor/icon.png", - "compile": "yarn run compile:tsc && yarn run compile:vendor", + "compile": "yarn run compile:tsc && yarn run compile:grammar && yarn run compile:vendor", "watch": "tsc -watch -p ./", "pretest": "yarn run compile && yarn run lint", "lint": "eslint src --ext ts", "format": "eslint src --ext ts --fix" }, "devDependencies": { - "@types/vscode": "^1.68.0", "@types/glob": "^7.2.0", "@types/node": "18.x", + "@types/vscode": "^1.68.0", "@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/parser": "^5.27.0", "eslint": "^8.18.0", "glob": "^8.0.3", + "js-yaml": "^4.1.0", "typescript": "^4.7.4" } } diff --git a/packages/vscode-ext/src/squiggleEditor.ts b/packages/vscode-ext/src/editor.ts similarity index 55% rename from packages/vscode-ext/src/squiggleEditor.ts rename to packages/vscode-ext/src/editor.ts index 3e3d9f2c..4410ba26 100644 --- a/packages/vscode-ext/src/squiggleEditor.ts +++ b/packages/vscode-ext/src/editor.ts @@ -1,14 +1,5 @@ import * as vscode from "vscode"; - -function getNonce() { - let text = ""; - const possible = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); - } - return text; -} +import { getWebviewContent } from "./utils"; export class SquiggleEditorProvider implements vscode.CustomTextEditorProvider { public static register(context: vscode.ExtensionContext): vscode.Disposable { @@ -26,8 +17,6 @@ export class SquiggleEditorProvider implements vscode.CustomTextEditorProvider { /** * Called when our custom editor is opened. - * - * */ public async resolveCustomTextEditor( document: vscode.TextDocument, @@ -37,7 +26,12 @@ export class SquiggleEditorProvider implements vscode.CustomTextEditorProvider { webviewPanel.webview.options = { enableScripts: true, }; - webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview); + webviewPanel.webview.html = getWebviewContent({ + webview: webviewPanel.webview, + script: "media/wysiwygWebview.js", + title: "Squiggle Editor", + context: this.context, + }); function updateWebview() { webviewPanel.webview.postMessage({ @@ -79,57 +73,6 @@ export class SquiggleEditorProvider implements vscode.CustomTextEditorProvider { updateWebview(); } - /** - * Get the static html used for the editor webviews. - */ - private getHtmlForWebview(webview: vscode.Webview): string { - // Local path to main script run in the webview - - const styleUri = webview.asWebviewUri( - vscode.Uri.joinPath( - this.context.extensionUri, - "media/vendor/components.css" - ) - ); - - const scriptUris = [ - // vendor files are copied over by `yarn run compile` - "media/vendor/react.js", - "media/vendor/react-dom.js", - "media/vendor/components.js", - "media/wysiwyg.js", - ].map((script) => - webview.asWebviewUri( - vscode.Uri.joinPath(this.context.extensionUri, script) - ) - ); - - // Use a nonce to whitelist which scripts can be run - const nonce = getNonce(); - - return /* html */ ` - - - - - - - - - Squiggle Editor - - -
- ${scriptUris - .map((uri) => ``) - .join("")} - - `; - } - private updateTextDocument(document: vscode.TextDocument, text: string) { const edit = new vscode.WorkspaceEdit(); diff --git a/packages/vscode-ext/src/extension.ts b/packages/vscode-ext/src/extension.ts index 9759472b..3b07fcfb 100644 --- a/packages/vscode-ext/src/extension.ts +++ b/packages/vscode-ext/src/extension.ts @@ -2,12 +2,15 @@ // Import the module and reference it with the alias vscode in your code below import * as vscode from "vscode"; -import { SquiggleEditorProvider } from "./squiggleEditor"; +import { SquiggleEditorProvider } from "./editor"; +import { registerPreviewCommand } from "./preview"; // this method is called when your extension is activated // your extension is activated the very first time the command is executed export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(SquiggleEditorProvider.register(context)); + + registerPreviewCommand(context); } // this method is called when your extension is deactivated diff --git a/packages/vscode-ext/src/preview.ts b/packages/vscode-ext/src/preview.ts new file mode 100644 index 00000000..81e4da47 --- /dev/null +++ b/packages/vscode-ext/src/preview.ts @@ -0,0 +1,53 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import { getWebviewContent } from "./utils"; + +export const registerPreviewCommand = (context: vscode.ExtensionContext) => { + context.subscriptions.push( + vscode.commands.registerTextEditorCommand("squiggle.preview", (editor) => { + // Create and show a new webview + const title = `Preview ${path.basename(editor.document.uri.path)}`; + + const panel = vscode.window.createWebviewPanel( + "squigglePreview", + title, + vscode.ViewColumn.Beside, + {} // Webview options. More on these later. + ); + + panel.webview.options = { + enableScripts: true, + }; + + panel.webview.html = getWebviewContent({ + context, + webview: panel.webview, + title, + script: "media/previewWebview.js", + }); + + const updateWebview = () => { + panel.webview.postMessage({ + type: "update", + text: editor.document.getText(), + showSettings: + vscode.workspace.getConfiguration("squiggle").playground, + }); + }; + + updateWebview(); + + const changeDocumentSubscription = + vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document.uri.toString() === editor.document.uri.toString()) { + updateWebview(); + } + }); + + // Make sure we get rid of the listener when our editor is closed. + panel.onDidDispose(() => { + changeDocumentSubscription.dispose(); + }); + }) + ); +}; diff --git a/packages/vscode-ext/src/utils.ts b/packages/vscode-ext/src/utils.ts new file mode 100644 index 00000000..7c3f28f4 --- /dev/null +++ b/packages/vscode-ext/src/utils.ts @@ -0,0 +1,59 @@ +import * as vscode from "vscode"; + +const getNonce = () => { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +}; + +export const getWebviewContent = ({ + webview, + title, + script, + context, +}: { + webview: vscode.Webview; + title: string; + script: string; + context: vscode.ExtensionContext; +}) => { + const nonce = getNonce(); + + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, "media/vendor/components.css") + ); + + const scriptUris = [ + // vendor files are copied over by `yarn run compile` + "media/vendor/react.js", + "media/vendor/react-dom.js", + "media/vendor/components.js", + script, + ].map((script) => + webview.asWebviewUri(vscode.Uri.joinPath(context.extensionUri, script)) + ); + + return ` + + + + + + + + + ${title} + + +
+ ${scriptUris + .map((uri) => ``) + .join("")} + + + `; +}; diff --git a/packages/vscode-ext/syntaxes/squiggle.tmLanguage.yaml b/packages/vscode-ext/syntaxes/squiggle.tmLanguage.yaml new file mode 100644 index 00000000..a7f16539 --- /dev/null +++ b/packages/vscode-ext/syntaxes/squiggle.tmLanguage.yaml @@ -0,0 +1,93 @@ +scopeName: source.squiggle +patterns: + - include: "#statement" + - include: "#expression" + - include: "#comment-block" + - include: "#comment-line" +repository: + statement: + patterns: + - include: "#let" + - include: "#defun" + expression: + patterns: + - include: "#integer" + - include: "#float" + - include: "#string" + - include: "#block" + - include: "#function-call" + - include: "#keywords" + let: + match: ^\s*(\w+)\s*= + captures: + "1": + name: variable.other.squiggle + defun: + begin: ^\s*(\w+)\s*(\() + end: (\))\s*= + beginCaptures: + "1": + name: entity.name.function.squiggle + "2": + name: punctuation.definition.arguments.begin.squiggle + endCaptures: + "1": + name: punctuation.definition.arguments.end.squiggle + patterns: + - include: "#array-parameters" + array-parameters: + begin: \b([\$_a-z]+[\$_a-zA-Z0-9]*) + end: \s*(?:(,)|(?=\))) + beginCaptures: + "1": + name: variable.parameter.function.squiggle + function-call: + begin: (\w+)\s*(\() + end: (\)) + beginCaptures: + "1": + name: entity.name.function.squiggle + "2": + name: punctuation.definition.arguments.begin.squiggle + endCaptures: + "1": + name: punctuation.definition.arguments.end.squiggle + patterns: + - include: "$self" + comment-block: + begin: /\* + end: \*/ + name: comment.block.squiggle + comment-line: + patterns: + - include: "#comment-line-double-slash" + - include: "#comment-line-number-sign" + comment-line-double-slash: + match: //.* + name: comment.line.double-slash.squiggle + comment-line-number-sign: + match: "#.*" + name: comment.line.number-sign.squiggle + block: + begin: "{" + end: "}" + beginCaptures: + "0": + name: punctuation.definition.block.squiggle + endCaptures: + "0": + name: punctuation.definition.block.squiggle + patterns: + - include: "$self" + keywords: + match: \b(if|then|else|to)\b + name: keyword.control.squiggle + integer: + match: \b\d+([_a-zA-Z]+[_a-zA-Z0-9]*)? + name: constant.numeric.integer.squiggle + float: + match: \b(\d+\.\d*|\.?\d+)([eE]-?\d+)?([_a-zA-Z]+[_a-zA-Z0-9]*)? + name: constant.numeric.float.squiggle + string: + match: \".*?\" + name: string.quoted.double.squiggle diff --git a/yarn.lock b/yarn.lock index 8221b974..2e4b5319 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17541,7 +17541,6 @@ vega-embed@^6.21.0, vega-embed@^6.5.1: vega-schema-url-parser "^2.2.0" vega-themes "^2.10.0" vega-tooltip "^0.28.0" - yallist "*" vega-encode@~4.9.0: version "4.9.0" @@ -17901,6 +17900,11 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== +vscode-uri@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.3.tgz#a95c1ce2e6f41b7549f86279d19f47951e4f4d84" + integrity sha512-EcswR2S8bpR7fD0YPeS7r2xXExrScVMxg4MedACaWHEtx9ftCF/qHG1xGkolzTPcEmjTavCQgbVzHUIdTMzFGA== + w3c-hr-time@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz#0a89cdf5cc15822df9c360543676963e0cc308cd" @@ -18641,16 +18645,16 @@ y18n@^5.0.5: resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== -yallist@*, yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2: version "1.10.2" resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"