├── .gitattributes ├── .gitignore ├── src ├── lsp │ ├── README.md │ ├── LICENSE │ └── index.ts ├── main.zig ├── theme.ts ├── workers │ ├── runner.ts │ ├── zig.ts │ └── zls.ts ├── utils.ts ├── zls.zig └── editor.ts ├── vite.config.js ├── README.md ├── zig.patch ├── package.json ├── .github └── workflows │ └── deploy.yml ├── index.html ├── LICENSE └── style ├── reset.css ├── zig-theme.css ├── zigtools-theme.css └── style.css /.gitattributes: -------------------------------------------------------------------------------- 1 | *.zig text=auto eol=lf 2 | *.zon text=auto eol=lf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | repos 5 | .zig-cache 6 | zig-out 7 | -------------------------------------------------------------------------------- /src/lsp/README.md: -------------------------------------------------------------------------------- 1 | Rewritten version of https://github.com/FurqanSoftware/codemirror-languageserver 2 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | export default defineConfig({ 3 | plugins: [], 4 | }); -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn main() !void { 4 | try std.fs.File.stdout().writeAll("Hello, World!\n"); 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zigtools Playground 2 | 3 | Run and explore Zig in your browser, with compiler and LSP support built in. 4 | 5 | ## Installation 6 | 7 | You can either: 8 | 9 | - Use it online: https://playground.zigtools.org/ 10 | - Run it locally: 11 | 12 | Requires Zig `0.15.1`. Will automatically fetch Zig along with ZLS, compiling both for webassembly. 13 | 14 | ```bash 15 | zig build 16 | npm install 17 | npm run dev 18 | ``` 19 | 20 | Enjoy! 21 | -------------------------------------------------------------------------------- /zig.patch: -------------------------------------------------------------------------------- 1 | diff --git a/src/dev.zig b/src/dev.zig 2 | index f4be5a36..ae4e914b 100644 3 | --- a/src/dev.zig 4 | +++ b/src/dev.zig 5 | @@ -150,12 +150,13 @@ pub const Env = enum { 6 | else => Env.sema.supports(feature), 7 | }, 8 | .wasm => switch (feature) { 9 | - .stdio_listen, 10 | - .incremental, 11 | .wasm_backend, 12 | .wasm_linker, 13 | + .build_exe_command, 14 | + .sema, 15 | + .ast_gen, 16 | => true, 17 | - else => Env.sema.supports(feature), 18 | + else => false, 19 | }, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playground", 3 | "type": "module", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "vite build", 7 | "preview": "vite preview" 8 | }, 9 | "dependencies": { 10 | "@andrewbranch/untar.js": "^1.0.3", 11 | "@bjorn3/browser_wasi_shim": "^0.4.1", 12 | "@codemirror/autocomplete": "^6.4.2", 13 | "@codemirror/commands": "^6.2.2", 14 | "@codemirror/language": "^6.6.0", 15 | "@codemirror/lint": "^6.2.0", 16 | "@codemirror/state": "^6.2.0", 17 | "@codemirror/view": "^6.9.3", 18 | "codemirror": "^6.0.1", 19 | "vscode-languageserver-protocol": "^3.17.3" 20 | }, 21 | "devDependencies": { 22 | "typescript": "~5.7.2", 23 | "vite": "^6.3.1" 24 | }, 25 | "browserslist": [ 26 | "since 2017-06" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/theme.ts: -------------------------------------------------------------------------------- 1 | import { EditorView } from "@codemirror/view"; 2 | 3 | export const editorTheme = EditorView.theme( 4 | { 5 | "&": { 6 | backgroundColor: "var(--code-background-color)", 7 | color: "var(--code-text-color)", 8 | }, 9 | ".cm-gutter": { backgroundColor: "var(--code-background-color)" }, 10 | ".cm-gutterElement": { 11 | backgroundColor: "var(--code-background-color)", 12 | color: "var(--code-text-color)", 13 | }, 14 | ".cm-activeLine": { backgroundColor: "transparent" }, 15 | "&.cm-focused .cm-matchingBracket": { 16 | backgroundColor: "#FFFFFF30", 17 | }, 18 | ".cm-content": { caretColor: "var(--code-text-color)" }, 19 | ".cm-cursor, .cm-dropCursor": { borderLeftColor: "var(--code-text-color)" }, 20 | ".cm-tooltip": { 21 | backgroundColor: "var(--tooltip-background)", 22 | color: "var(--tooltip-text)", 23 | } 24 | }, 25 | {} 26 | ); 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | pages: write 12 | id-token: write 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions/setup-node@v4 22 | with: 23 | node-version: 22 24 | 25 | - uses: mlugg/setup-zig@v2 26 | 27 | - name: Install binaryen 28 | run: | 29 | sudo apt-get update 30 | sudo apt-get install binaryen 31 | 32 | - run: | 33 | zig build -Drelease -Dwasm-opt 34 | npm ci 35 | npm run build 36 | 37 | - uses: actions/upload-pages-artifact@v3 38 | with: 39 | path: "dist/" 40 | 41 | - uses: actions/deploy-pages@v4 42 | if: github.ref == 'refs/heads/main' 43 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Zigtools Playground 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 | 22 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 zls-in-the-browser contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/workers/runner.ts: -------------------------------------------------------------------------------- 1 | // Runs compiled Zig code 2 | 3 | import { WASI, PreopenDirectory, OpenFile, File } from "@bjorn3/browser_wasi_shim"; 4 | import { stderrOutput } from "../utils"; 5 | 6 | async function run(wasmData: Uint8Array) { 7 | let args = ["main.wasm"]; 8 | let env = []; 9 | let fds = [ 10 | new OpenFile(new File([])), // stdin 11 | stderrOutput(), // stdout 12 | stderrOutput(), // stderr 13 | new PreopenDirectory(".", new Map([])), 14 | ]; 15 | let wasi = new WASI(args, env, fds); 16 | 17 | let { instance } = await WebAssembly.instantiate(wasmData, { 18 | "wasi_snapshot_preview1": wasi.wasiImport, 19 | });; 20 | 21 | try { 22 | // @ts-ignore 23 | const exitCode = wasi.start(instance); 24 | 25 | postMessage({ 26 | stderr: `\n\n---\nexit with exit code ${exitCode}\n---\n`, 27 | }); 28 | } catch (err) { 29 | postMessage({ stderr: `${err}` }); 30 | } 31 | 32 | postMessage({ 33 | done: true, 34 | }); 35 | } 36 | 37 | onmessage = (event) => { 38 | if (event.data.run) { 39 | run(event.data.run); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /style/reset.css: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, body, div, span, applet, object, iframe, 7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, 8 | a, abbr, acronym, address, big, cite, code, 9 | del, dfn, em, img, ins, kbd, q, s, samp, 10 | small, strike, strong, sub, sup, tt, var, 11 | b, u, i, center, 12 | dl, dt, dd, ol, ul, li, 13 | fieldset, form, label, legend, 14 | table, caption, tbody, tfoot, thead, tr, th, td, 15 | article, aside, canvas, details, embed, 16 | figure, figcaption, footer, header, hgroup, 17 | menu, nav, output, ruby, section, summary, 18 | time, mark, audio, video { 19 | margin: 0; 20 | padding: 0; 21 | border: 0; 22 | font-size: 100%; 23 | font: inherit; 24 | color: inherit; 25 | text-decoration: none; 26 | vertical-align: baseline; 27 | } 28 | /* HTML5 display-role reset for older browsers */ 29 | article, aside, details, figcaption, figure, 30 | footer, header, hgroup, menu, nav, section { 31 | display: block; 32 | } 33 | body { 34 | line-height: 1; 35 | } 36 | ol, ul { 37 | list-style: none; 38 | } 39 | blockquote, q { 40 | quotes: none; 41 | } 42 | blockquote:before, blockquote:after, 43 | q:before, q:after { 44 | content: ''; 45 | content: none; 46 | } 47 | table { 48 | border-collapse: collapse; 49 | border-spacing: 0; 50 | } 51 | -------------------------------------------------------------------------------- /style/zig-theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --code-background-color: #f8f8f8; 3 | --code-text-color: black; 4 | } 5 | 6 | .st-comment { 7 | color: #aa7; 8 | } 9 | .st-keyword { 10 | color: #333; 11 | font-weight: bold; 12 | } 13 | .st-number, .st-keywordLiteral { 14 | color: #008080; 15 | } 16 | .st-string { 17 | color: #d14; 18 | } 19 | .st-escapeSequence { 20 | color: hsl(57, 90%, 25%); /* TODO */ 21 | } 22 | .st-label { 23 | font-style: italic; 24 | } 25 | .st-builtin { 26 | color: #0086b3; 27 | } 28 | .st-parameter { 29 | font-style: italic; 30 | } 31 | .st-type, 32 | .st-typeParameter, 33 | .st-enum, 34 | .st-class, 35 | .st-struct { 36 | color: #458; 37 | font-weight: bold; 38 | } 39 | .st-function, 40 | .st-method { 41 | color: #900; 42 | font-weight: bold; 43 | } 44 | .st-method { 45 | font-style: italic; 46 | } 47 | 48 | @media (prefers-color-scheme: dark) { 49 | :root { 50 | --code-background-color: #1e1e1e; 51 | --code-text-color: #ccc; 52 | } 53 | 54 | .st-comment { 55 | color: #aa7; 56 | } 57 | .st-keyword { 58 | color: #ccc; 59 | } 60 | .st-number, .st-keywordLiteral { 61 | color: #ff8080; 62 | } 63 | .st-string { 64 | color: #2e5; 65 | } 66 | .st-escapeSequence { 67 | color: #5ee; 68 | } 69 | .st-builtin { 70 | color: #ff894c; 71 | } 72 | .st-type, 73 | .st-typeParameter, 74 | .st-enum, 75 | .st-class, 76 | .st-struct { 77 | color: #68f; 78 | } 79 | .st-function, 80 | .st-method { 81 | color: #b1a0f8; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/lsp/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021, Mahmud Ridwan 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the library nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /style/zigtools-theme.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --code-background-color: white; 3 | --code-text-color: #314750; 4 | } 5 | 6 | .st-comment { 7 | color: hsl(270, 10%, 40%); 8 | } 9 | .st-keyword { 10 | color: hsl(300, 55%, 45%); 11 | } 12 | .st-number, .st-keywordLiteral { 13 | color: hsl(35, 80%, 34%); 14 | } 15 | .st-string { 16 | color: hsl(100, 50%, 33%); 17 | } 18 | .st-operator { 19 | color: hsl(215, 40%, 48%); 20 | } 21 | .st-escapeSequence { 22 | color: hsl(215, 40%, 48%); 23 | } 24 | .st-label { 25 | color: hsl(350, 100%, 20%); 26 | } 27 | .st-builtin { 28 | color: hsl(15, 100%, 43%); 29 | } 30 | .st-parameter, 31 | .st-enumMember, 32 | .st-errorTag { 33 | font-style: italic; 34 | } 35 | .st-type, 36 | .st-typeParameter, 37 | .st-enum, 38 | .st-class, 39 | .st-struct { 40 | color: hsl(215, 70%, 48%); 41 | } 42 | .st-property { 43 | color: hsl(180, 50%, 32%); 44 | } 45 | .st-function, 46 | .st-method { 47 | color: hsl(45, 70%, 33%); 48 | } 49 | .st-method { 50 | font-style: italic; 51 | } 52 | 53 | @media (prefers-color-scheme: dark) { 54 | :root { 55 | --code-background-color: #121212; 56 | --code-text-color: #c8c8c8; 57 | } 58 | 59 | .st-comment { 60 | color: hsl(270, 10%, 55%); 61 | } 62 | .st-keyword { 63 | color: hsl(300, 50%, 65%); 64 | } 65 | .st-number, .st-keywordLiteral { 66 | color: hsl(20, 70%, 60%); 67 | } 68 | .st-string { 69 | color: hsl(100, 40%, 55%); 70 | } 71 | .st-operator { 72 | color: hsl(215, 40%, 55%); 73 | } 74 | .st-escapeSequence { 75 | color: hsl(215, 40%, 55%); 76 | } 77 | .st-label { 78 | color: hsl(350, 90%, 85%); 79 | } 80 | .st-builtin { 81 | color: hsl(35, 70%, 50%); 82 | } 83 | .st-parameter, 84 | .st-enumMember, 85 | .st-errorTag { 86 | font-style: italic; 87 | } 88 | .st-type, 89 | .st-typeParameter, 90 | .st-enum, 91 | .st-class, 92 | .st-struct { 93 | color: hsl(215, 70%, 65%); 94 | } 95 | .st-property { 96 | color: hsl(180, 50%, 50%); 97 | } 98 | .st-function, 99 | .st-method { 100 | color: hsl(45, 80%, 70%); 101 | } 102 | .st-method { 103 | font-style: italic; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { untar } from "@andrewbranch/untar.js"; 2 | import { Directory, File, ConsoleStdout, wasi as wasi_defs } from "@bjorn3/browser_wasi_shim"; 3 | 4 | export async function getLatestZigArchive() { 5 | const response = await fetch(new URL("../zig-out/zig.tar.gz", import.meta.url)); 6 | let arrayBuffer = await response.arrayBuffer(); 7 | const magicNumber = new Uint8Array(arrayBuffer).slice(0, 2); 8 | if (magicNumber[0] == 0x1F && magicNumber[1] == 0x8B) { // gzip 9 | const ds = new DecompressionStream("gzip"); 10 | const response = new Response(new Response(arrayBuffer).body!.pipeThrough(ds)); 11 | arrayBuffer = await response.arrayBuffer(); 12 | } else { 13 | // already decompressed 14 | } 15 | const entries = untar(arrayBuffer); 16 | 17 | let root: TreeNode = new Map(); 18 | 19 | for (const e of entries) { 20 | if (!e.filename.startsWith("lib/")) continue; 21 | const path = e.filename.slice("lib/".length); 22 | const splitPath = path.split("/"); 23 | 24 | let c = root; 25 | for (const segment of splitPath.slice(0, -1)) { 26 | if (!c.has(segment)) { 27 | c.set(segment, new Map()); 28 | } 29 | c = c.get(segment) as TreeNode; 30 | } 31 | 32 | 33 | c.set(splitPath[splitPath.length - 1], e.fileData); 34 | } 35 | 36 | return convert(root); 37 | } 38 | 39 | type TreeNode = Map; 40 | 41 | function convert(node: TreeNode): Directory { 42 | return new Directory( 43 | [...node.entries()].map(([key, value]) => { 44 | if (value instanceof Uint8Array) { 45 | return [key, new File(value)]; 46 | } else { 47 | return [key, convert(value)]; 48 | } 49 | }) 50 | ) 51 | } 52 | 53 | export function stderrOutput(): ConsoleStdout { 54 | const dec = new TextDecoder("utf-8", { fatal: false }); 55 | const stderr = new ConsoleStdout((buffer) => { 56 | postMessage({ stderr: dec.decode(buffer, { stream: true }) }); 57 | }); 58 | stderr.fd_pwrite = (data, offset) => { 59 | return { ret: wasi_defs.ERRNO_SPIPE, nwritten: 0 }; 60 | } 61 | return stderr; 62 | } 63 | -------------------------------------------------------------------------------- /src/workers/zig.ts: -------------------------------------------------------------------------------- 1 | import { WASI, PreopenDirectory, Fd, File, OpenFile, Inode } from "@bjorn3/browser_wasi_shim"; 2 | import { getLatestZigArchive, stderrOutput } from "../utils"; 3 | 4 | let currentlyRunning = false; 5 | async function run(source: string) { 6 | if (currentlyRunning) return; 7 | 8 | currentlyRunning = true; 9 | 10 | const libDirectory = await getLatestZigArchive(); 11 | 12 | // -fno-llvm -fno-lld is set explicitly to ensure the native WASM backend is 13 | // used in preference to LLVM. This may be removable once the non-LLVM 14 | // backends become more mature. 15 | let args = [ 16 | "zig.wasm", 17 | "build-exe", 18 | "main.zig", 19 | "-fno-llvm", 20 | "-fno-lld", 21 | "-fno-ubsan-rt", 22 | "-fno-entry", // prevent the native webassembly backend from adding a start function to the module 23 | ]; 24 | let env = []; 25 | let fds = [ 26 | new OpenFile(new File([])), // stdin 27 | stderrOutput(), // stdout 28 | stderrOutput(), // stderr 29 | new PreopenDirectory(".", new Map([ 30 | ["main.zig", new File(new TextEncoder().encode(source))], 31 | ])), 32 | new PreopenDirectory("/lib", libDirectory.contents), 33 | new PreopenDirectory("/cache", new Map()), 34 | ] satisfies Fd[]; 35 | let wasi = new WASI(args, env, fds, { debug: false }); 36 | 37 | const { instance } = await WebAssembly.instantiateStreaming(fetch(new URL("../../zig-out/bin/zig.wasm", import.meta.url)), { 38 | "wasi_snapshot_preview1": wasi.wasiImport, 39 | }); 40 | 41 | postMessage({ 42 | stderr: "Compiling...\n", 43 | }); 44 | 45 | try { 46 | // @ts-ignore 47 | const exitCode = wasi.start(instance); 48 | 49 | if (exitCode == 0) { 50 | const cwd = wasi.fds[3] as PreopenDirectory; 51 | const mainWasm = cwd.dir.contents.get("main.wasm") as File | undefined; 52 | if (mainWasm) { 53 | postMessage({ compiled: mainWasm.data }); 54 | } 55 | } 56 | } catch (err) { 57 | postMessage({ 58 | stderr: `${err}`, 59 | }); 60 | postMessage({ failed: true }); 61 | } 62 | 63 | currentlyRunning = false; 64 | } 65 | 66 | onmessage = (event) => { 67 | if (event.data.run) { 68 | run(event.data.run); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/workers/zls.ts: -------------------------------------------------------------------------------- 1 | import { WASI, PreopenDirectory, Fd, ConsoleStdout } from "@bjorn3/browser_wasi_shim"; 2 | import { getLatestZigArchive } from "../utils"; 3 | 4 | class Stdio extends Fd { 5 | constructor() { 6 | super(); 7 | } 8 | 9 | fd_write(slice: Uint8Array): { ret: number; nwritten: number } { 10 | throw new Error("Cannot write"); 11 | } 12 | 13 | fd_read(size: number): { ret: number; data: Uint8Array; } { 14 | throw new Error("Cannot read"); 15 | } 16 | } 17 | 18 | let instance: any; 19 | let bufferedMessages: string[] = []; 20 | 21 | function sendMessage(message: string) { 22 | const inputMessageBuffer = new TextEncoder().encode(message); 23 | const ptr = instance.exports.allocMessage(inputMessageBuffer.length); 24 | new Uint8Array(instance.exports.memory.buffer).set(inputMessageBuffer, ptr); 25 | instance.exports.call(); 26 | 27 | const outputMessageCount = instance.exports.outputMessageCount(); 28 | for (let i = 0; i < outputMessageCount; i++) { 29 | const start = instance.exports.outputMessagePtr(i); 30 | const end = start + instance.exports.outputMessageLen(i); 31 | const outputMessageBuffer = new Uint8Array(instance.exports.memory.buffer).slice(start, end); 32 | postMessage(new TextDecoder().decode(outputMessageBuffer)); 33 | } 34 | } 35 | 36 | onmessage = (event) => { 37 | if (instance) { 38 | sendMessage(event.data); 39 | } else { 40 | bufferedMessages.push(event.data); 41 | } 42 | }; 43 | 44 | (async () => { 45 | let libDirectory = await getLatestZigArchive(); 46 | 47 | let args = ["zls.wasm"]; 48 | let env = []; 49 | let fds = [ 50 | new Stdio(), // stdin 51 | new Stdio(), // stdout 52 | ConsoleStdout.lineBuffered((line) => postMessage(JSON.stringify({ stderr: line }))), // stderr 53 | new PreopenDirectory(".", new Map([])), 54 | new PreopenDirectory("/lib", libDirectory.contents), 55 | new PreopenDirectory("/cache", new Map()), 56 | ]; 57 | let wasi = new WASI(args, env, fds, { debug: false }); 58 | 59 | const { instance: localInstance } = await WebAssembly.instantiateStreaming(fetch(new URL("../../zig-out/bin/zls.wasm", import.meta.url)), { 60 | "wasi_snapshot_preview1": wasi.wasiImport, 61 | }); 62 | 63 | // @ts-ignore 64 | wasi.inst = localInstance; 65 | 66 | // @ts-ignore 67 | localInstance.exports.createServer(); 68 | 69 | instance = localInstance; 70 | 71 | for (const bufferedMessage of bufferedMessages) { 72 | sendMessage(bufferedMessage); 73 | } 74 | })(); 75 | -------------------------------------------------------------------------------- /src/zls.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zls = @import("zls"); 3 | 4 | const allocator = std.heap.wasm_allocator; 5 | 6 | pub const std_options: std.Options = .{ 7 | // Always set this to debug to make std.log call into our handler, then control the runtime 8 | // value in logFn itself 9 | .log_level = .debug, 10 | .logFn = logFn, 11 | }; 12 | 13 | fn logFn( 14 | comptime level: std.log.Level, 15 | comptime scope: @Type(.enum_literal), 16 | comptime format: []const u8, 17 | args: anytype, 18 | ) void { 19 | _ = scope; 20 | var buffer: [4096]u8 = undefined; 21 | comptime std.debug.assert(buffer.len >= zls.lsp.minimum_logging_buffer_size); 22 | 23 | const lsp_message_type: zls.lsp.types.MessageType = switch (level) { 24 | .err => .Error, 25 | .warn => .Warning, 26 | .info => .Info, 27 | .debug => .Debug, 28 | }; 29 | const json_message = zls.lsp.bufPrintLogMessage(&buffer, lsp_message_type, format, args); 30 | transport.writeJsonMessage(json_message) catch {}; 31 | } 32 | 33 | var transport: zls.lsp.Transport = .{ 34 | .vtable = &.{ 35 | .readJsonMessage = readJsonMessage, 36 | .writeJsonMessage = writeJsonMessage, 37 | }, 38 | }; 39 | 40 | fn readJsonMessage(_: *zls.lsp.Transport, _: std.mem.Allocator) (std.mem.Allocator.Error || zls.lsp.Transport.ReadError)![]u8 { 41 | unreachable; 42 | } 43 | 44 | fn writeJsonMessage(_: *zls.lsp.Transport, json_message: []const u8) zls.lsp.Transport.WriteError!void { 45 | output_message_starts.append(allocator, output_message_bytes.items.len) catch return error.NoSpaceLeft; 46 | output_message_bytes.appendSlice(allocator, json_message) catch return error.NoSpaceLeft; 47 | } 48 | 49 | var server: *zls.Server = undefined; 50 | 51 | var input_bytes: std.ArrayList(u8) = .empty; 52 | 53 | var output_message_starts: std.ArrayList(usize) = .empty; 54 | var output_message_bytes: std.ArrayList(u8) = .empty; 55 | 56 | export fn createServer() void { 57 | server = zls.Server.create(.{ 58 | .allocator = allocator, 59 | .transport = null, 60 | .config = null, 61 | }) catch @panic("server creation failed"); 62 | server.setTransport(&transport); 63 | } 64 | 65 | export fn allocMessage(len: usize) [*]const u8 { 66 | input_bytes.clearRetainingCapacity(); 67 | input_bytes.resize(allocator, len) catch @panic("OOM"); 68 | return input_bytes.items.ptr; 69 | } 70 | 71 | export fn call() void { 72 | output_message_starts.clearRetainingCapacity(); 73 | output_message_bytes.clearRetainingCapacity(); 74 | 75 | allocator.free( 76 | server.sendJsonMessageSync(input_bytes.items) catch |err| 77 | std.debug.panic("{}", .{err}) orelse 78 | return, 79 | ); 80 | } 81 | 82 | export fn outputMessageCount() usize { 83 | return output_message_starts.items.len; 84 | } 85 | 86 | export fn outputMessagePtr(index: usize) [*]const u8 { 87 | return output_message_bytes.items[output_message_starts.items[index]..].ptr; 88 | } 89 | 90 | export fn outputMessageLen(index: usize) usize { 91 | const next_start = if (index < output_message_starts.items.len - 1) 92 | output_message_starts.items[index + 1] 93 | else 94 | output_message_bytes.items.len; 95 | return next_start - output_message_starts.items[index]; 96 | } 97 | -------------------------------------------------------------------------------- /style/style.css: -------------------------------------------------------------------------------- 1 | @import "reset.css"; 2 | @import "zig-theme.css"; 3 | 4 | :root { 5 | --button-background: hsl(40, 75%, 40%); 6 | --output-background: hsl(0, 0%, 90%); 7 | --footer-background: hsl(0, 0%, 95%); 8 | --footer-text: black; 9 | --output-text: black; 10 | --resizer-color: hsl(0, 0%, 20%); 11 | --resizer-pivot-color: hsl(0, 0%, 100%); 12 | 13 | --output-zig-outdated-background: hsl(37, 20%, 25%); 14 | --output-zig-latest-background: hsl(37, 40%, 80%); 15 | --output-runner-outdated-background: hsl(200, 15%, 25%); 16 | --output-runner-latest-background: hsl(200, 30%, 80%); 17 | --output-outdated-text: hsl(0, 0%, 60%); 18 | --output-latest-text: hsl(0, 0%, 0%); 19 | 20 | --tooltip-background: hsl(0, 0%, 100%); 21 | --tooltip-text: hsl(0, 0%, 0%); 22 | 23 | --button-text: white; 24 | --link-text: hsl(200, 100%, 60%); 25 | } 26 | 27 | @media (prefers-color-scheme: dark) { 28 | :root { 29 | --button-background: hsl(40, 75%, 25%); 30 | --output-background: hsl(0, 0%, 8%); 31 | --footer-background: hsl(0, 0%, 10%); 32 | --footer-text: hsl(0, 0%, 80%); 33 | --output-text: hsl(0, 0%, 90%); 34 | --resizer-color: hsl(0, 0%, 18%); 35 | --resizer-pivot-color: hsl(0, 0%, 70%); 36 | 37 | --output-zig-outdated-background: hsl(37, 93%, 5%); 38 | --output-zig-latest-background: hsl(37, 93%, 10%); 39 | --output-runner-outdated-background: hsl(200, 93%, 5%); 40 | --output-runner-latest-background: hsl(200, 93%, 10%); 41 | --output-outdated-text: hsl(0, 0%, 60%); 42 | --output-latest-text: hsl(0, 0%, 100%); 43 | 44 | --tooltip-background: hsl(0, 0%, 0%); 45 | --tooltip-text: hsl(0, 0%, 100%); 46 | } 47 | } 48 | 49 | body, button { 50 | font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; 51 | } 52 | 53 | * { 54 | box-sizing: border-box; 55 | } 56 | 57 | html, body { 58 | margin: 0; 59 | padding: 0; 60 | 61 | font-size: 13pt; 62 | } 63 | 64 | body { 65 | display: flex; 66 | flex-direction: column; 67 | height: 100vh; 68 | } 69 | 70 | main { 71 | height: 100%; 72 | 73 | flex-grow: 1; 74 | 75 | height: calc(100% - 80px); 76 | } 77 | 78 | footer { 79 | display: flex; 80 | padding: 0.5rem; 81 | text-align: center; 82 | align-items: center; 83 | justify-content: space-between; 84 | font-size: 0.85em; 85 | line-height: 1.25; 86 | background-color: var(--footer-background); 87 | color: var(--footer-text); 88 | 89 | &>h1 { 90 | font-size: 1em; 91 | font-weight: 900; 92 | letter-spacing: -1px; 93 | 94 | &>a { 95 | color: #f7a41d; 96 | text-decoration: none; 97 | } 98 | } 99 | 100 | #warning { 101 | margin-right: 0.25rem; 102 | border-radius: 3px; 103 | padding: 0.075rem 0.30rem; 104 | background-color: var(--button-background); 105 | color: var(--button-text); 106 | } 107 | } 108 | 109 | p { 110 | margin: 1rem 0; 111 | } 112 | 113 | strong { 114 | font-weight: 700; 115 | } 116 | 117 | i { 118 | font-style: italic; 119 | } 120 | 121 | a { 122 | color: var(--link-text); 123 | text-decoration: underline; 124 | } 125 | 126 | #split-pane { 127 | display: flex; 128 | 129 | flex-direction: column; 130 | height: 100%; 131 | overflow: hidden; 132 | 133 | &>*:first-child { 134 | height: var(--editor-height-percent); 135 | } 136 | 137 | &>#resize-bar { 138 | height: 0.5rem; 139 | } 140 | 141 | &>*:last-child { 142 | height: calc(100% - 0.5rem - var(--editor-height-percent)); 143 | } 144 | } 145 | 146 | #resize-bar { 147 | position: relative; 148 | width: 100%; 149 | cursor: row-resize; 150 | background-color: var(--resizer-color); 151 | } 152 | 153 | #resize-bar::before { 154 | content: ""; 155 | position: absolute; 156 | width: 100%; 157 | height: 100%; 158 | transform: scale(3); 159 | } 160 | 161 | #resize-bar::after { 162 | content: ""; 163 | position: absolute; 164 | top: 50%; 165 | left: 50%; 166 | width: 5rem; 167 | border: 2px dashed var(--resizer-pivot-color); 168 | border-radius: 100px; 169 | transform: translate(-50%,-50%); 170 | } 171 | 172 | code { 173 | font-family: monospace; 174 | } 175 | 176 | .cm-editor { 177 | height: 100%; 178 | } 179 | 180 | .cm-focused { 181 | outline: none !important; 182 | } 183 | 184 | .cm-tooltip { 185 | padding: 6px; 186 | } 187 | 188 | ul { 189 | list-style: circle; 190 | margin-left: 2rem; 191 | line-height: 1.5; 192 | } 193 | 194 | #run { 195 | position: fixed; 196 | top: 1rem; 197 | right: 1rem; 198 | z-index: 1; 199 | border: none; 200 | border-radius: 3px; 201 | padding: 0.5rem 1.85rem; 202 | font-size: 1em; 203 | font-weight: 700; 204 | cursor: pointer; 205 | background-color: var(--button-background); 206 | color: var(--button-text); 207 | } 208 | 209 | #output { 210 | height: 100%; 211 | overflow-y: auto; 212 | background-color: var(--output-background); 213 | color: var(--output-text); 214 | 215 | &>div { 216 | white-space: pre; 217 | font-family: monospace; 218 | } 219 | 220 | &>.zig-output { 221 | padding: 1rem; 222 | 223 | &.latest { 224 | background-color: var(--output-zig-latest-background); 225 | color: var(--output-latest-text); 226 | } 227 | 228 | &:not(.latest) { 229 | background-color: var(--output-zig-outdated-background); 230 | color: var(--output-outdated-text); 231 | } 232 | } 233 | 234 | &>.runner-output { 235 | padding: 1rem; 236 | 237 | &.latest { 238 | background-color: var(--output-runner-latest-background); 239 | color: var(--output-latest-text); 240 | } 241 | 242 | &:not(.latest) { 243 | background-color: var(--output-runner-outdated-background); 244 | color: var(--output-outdated-text); 245 | } 246 | } 247 | 248 | &>.output-split { 249 | width: 100%; 250 | height: 1px; 251 | background-color: white; 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/editor.ts: -------------------------------------------------------------------------------- 1 | import { EditorState } from "@codemirror/state" 2 | import { keymap } from "@codemirror/view" 3 | import { EditorView, basicSetup } from "codemirror" 4 | import { JsonRpcMessage, LspClient } from "./lsp"; 5 | import { indentWithTab } from "@codemirror/commands"; 6 | import { indentUnit } from "@codemirror/language"; 7 | import { editorTheme } from "./theme.ts"; 8 | // @ts-ignore 9 | import ZLSWorker from './workers/zls.ts?worker'; 10 | // @ts-ignore 11 | import ZigWorker from './workers/zig.ts?worker'; 12 | // @ts-ignore 13 | import RunnerWorker from './workers/runner.ts?worker'; 14 | // @ts-ignore 15 | import zigMainSource from './main.zig?raw'; 16 | 17 | export default class ZlsClient extends LspClient { 18 | public worker: Worker; 19 | 20 | constructor(worker: Worker) { 21 | super("file:///", []); 22 | this.worker = worker; 23 | 24 | this.worker.addEventListener("message", this.messageHandler); 25 | } 26 | 27 | private messageHandler = (ev: MessageEvent) => { 28 | const data = JSON.parse(ev.data); 29 | 30 | if (data.method == "window/logMessage") { 31 | if (!data.stderr) { 32 | switch (data.params.type) { 33 | case 5: 34 | console.debug("ZLS --- ", data.params.message); 35 | break; 36 | case 4: 37 | console.log("ZLS --- ", data.params.message); 38 | break; 39 | case 3: 40 | console.info("ZLS --- ", data.params.message); 41 | break; 42 | case 2: 43 | console.warn("ZLS --- ", data.params.message); 44 | break; 45 | case 1: 46 | console.error("ZLS --- ", data.params.message); 47 | break; 48 | default: 49 | console.error(data.params.message); 50 | break; 51 | } 52 | } 53 | } else { 54 | console.debug("LSP <<-", data); 55 | } 56 | this.handleMessage(data); 57 | }; 58 | 59 | public async sendMessage(message: JsonRpcMessage): Promise { 60 | console.debug("LSP ->>", message); 61 | if (this.worker) { 62 | this.worker.postMessage(JSON.stringify(message)); 63 | } 64 | } 65 | 66 | public async close(): Promise { 67 | super.close(); 68 | this.worker.terminate(); 69 | } 70 | } 71 | 72 | let client = new ZlsClient(new ZLSWorker()); 73 | 74 | let editor = (async () => { 75 | await client.initialize(); 76 | 77 | let editor = new EditorView({ 78 | extensions: [], 79 | parent: document.getElementById("editor")!, 80 | state: EditorState.create({ 81 | doc: zigMainSource, 82 | extensions: [basicSetup, editorTheme, indentUnit.of(" "), client.createPlugin("file:///main.zig", "zig", true), keymap.of([indentWithTab]),], 83 | }), 84 | }); 85 | 86 | await client.plugins[0].updateDecorations(); 87 | await client.plugins[0].updateFoldingRanges(); 88 | editor.update([]); 89 | 90 | return editor; 91 | })(); 92 | 93 | function revealOutputWindow() { 94 | const outputs = document.getElementById("output")!; 95 | outputs.scrollTo(0, outputs.scrollHeight!); 96 | const editorHeightPercent = parseFloat(splitPane.style.getPropertyValue("--editor-height-percent")); 97 | if (editorHeightPercent == 100) { 98 | splitPane.style.setProperty("--editor-height-percent", `${resizeBarPreviousSize}%`); 99 | } 100 | } 101 | 102 | let zigWorker = new ZigWorker(); 103 | 104 | zigWorker.onmessage = ev => { 105 | if (ev.data.stderr) { 106 | document.querySelector(".zig-output:last-child")!.textContent += ev.data.stderr; 107 | revealOutputWindow(); 108 | return; 109 | } else if (ev.data.failed) { 110 | const outputSplit = document.createElement("div"); 111 | outputSplit.classList.add("output-split"); 112 | document.getElementById("output")!.appendChild(outputSplit); 113 | } else if (ev.data.compiled) { 114 | let runnerWorker = new RunnerWorker(); 115 | 116 | const zigOutput = document.createElement("div"); 117 | zigOutput.classList.add("runner-output"); 118 | zigOutput.classList.add("latest"); 119 | document.getElementById("output")!.appendChild(zigOutput); 120 | 121 | runnerWorker.postMessage({ run: ev.data.compiled }); 122 | 123 | runnerWorker.onmessage = rev => { 124 | if (rev.data.stderr) { 125 | document.querySelector(".runner-output:last-child")!.textContent += rev.data.stderr; 126 | revealOutputWindow(); 127 | return; 128 | } else if (rev.data.done) { 129 | runnerWorker.terminate(); 130 | const outputSplit = document.createElement("div"); 131 | outputSplit.classList.add("output-split"); 132 | document.getElementById("output")!.appendChild(outputSplit); 133 | } 134 | } 135 | } 136 | } 137 | 138 | const splitPane = document.getElementById("split-pane")! as HTMLDivElement; 139 | const resizeBar = document.getElementById("resize-bar")! as HTMLDivElement; 140 | let resizeBarPreviousSize = 70; 141 | 142 | let resizing = false; 143 | resizeBar.addEventListener("mousedown", event => { 144 | if (event.buttons & 1) { 145 | resizing = true; 146 | document.body.style.userSelect = "none"; 147 | document.body.style.cursor = "row-resize"; 148 | } 149 | }); 150 | window.addEventListener("mousemove", event => { 151 | if (resizing) { 152 | const percent = Math.min(Math.max(10, event.clientY / splitPane.getBoundingClientRect().height * 100), 100); 153 | splitPane.style.setProperty("--editor-height-percent", `${percent}%`); 154 | } 155 | }); 156 | window.addEventListener("mouseup", event => { 157 | resizing = false; 158 | document.body.style.removeProperty("user-select"); 159 | document.body.style.removeProperty("cursor"); 160 | 161 | // fully close the output window when it's almost closed 162 | const editorHeightPercent = parseFloat(splitPane.style.getPropertyValue("--editor-height-percent")); 163 | if (editorHeightPercent >= 90) { 164 | splitPane.style.setProperty("--editor-height-percent", "100%"); 165 | } 166 | }); 167 | resizeBar.addEventListener("dblclick", event => { 168 | const editorHeightPercent = parseFloat(splitPane.style.getPropertyValue("--editor-height-percent")); 169 | if (editorHeightPercent == 100) { 170 | splitPane.style.setProperty("--editor-height-percent", `${resizeBarPreviousSize}%`); 171 | } else { 172 | resizeBarPreviousSize = editorHeightPercent; 173 | splitPane.style.setProperty("--editor-height-percent", `100%`); 174 | } 175 | }); 176 | 177 | const outputsRun = document.getElementById("run")! as HTMLButtonElement; 178 | outputsRun.addEventListener("click", async () => { 179 | for (const zo of document.querySelectorAll(".zig-output")) { 180 | zo.classList.remove("latest"); 181 | } 182 | for (const ro of document.querySelectorAll(".runner-output")) { 183 | ro.classList.remove("latest"); 184 | } 185 | 186 | const zigOutput = document.createElement("div"); 187 | zigOutput.classList.add("zig-output"); 188 | zigOutput.classList.add("latest"); 189 | document.getElementById("output")!.appendChild(zigOutput); 190 | revealOutputWindow(); 191 | 192 | zigWorker.postMessage({ 193 | run: (await editor).state.doc.toString(), 194 | }); 195 | }); 196 | -------------------------------------------------------------------------------- /src/lsp/index.ts: -------------------------------------------------------------------------------- 1 | import { autocompletion } from "@codemirror/autocomplete"; 2 | import { setDiagnostics } from "@codemirror/lint"; 3 | import { ChangeSpec, Facet, Prec, RangeSetBuilder, StateEffect, StateField } from "@codemirror/state"; 4 | import { EditorView, ViewPlugin, Tooltip, hoverTooltip, keymap, DecorationSet, Decoration } from '@codemirror/view'; 5 | import { 6 | DiagnosticSeverity, 7 | CompletionItemKind, 8 | CompletionTriggerKind, 9 | } from "vscode-languageserver-protocol"; 10 | 11 | import type { 12 | Completion, 13 | CompletionContext, 14 | CompletionResult, 15 | } from '@codemirror/autocomplete'; 16 | import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'; 17 | import type { ViewUpdate, PluginValue } from '@codemirror/view'; 18 | import { Text } from '@codemirror/state'; 19 | import type * as LSP from 'vscode-languageserver-protocol'; 20 | import { SemanticTokenTypes } from 'vscode-languageserver-protocol'; 21 | import { foldService } from "@codemirror/language"; 22 | 23 | const CompletionItemKindMap = Object.fromEntries( 24 | Object.entries(CompletionItemKind).map(([key, value]) => [value, key]) 25 | ) as Record; 26 | 27 | const useLast = (values: readonly any[]) => values.reduce((_, v) => v, ''); 28 | 29 | const client = Facet.define({ combine: useLast }); 30 | const documentUri = Facet.define({ combine: useLast }); 31 | const languageId = Facet.define({ combine: useLast }); 32 | 33 | export type JsonRpcId = string | number; 34 | type OutboundRequest = { 35 | promise: Promise; 36 | resolve: Function; 37 | reject: Function; 38 | }; 39 | 40 | export type JsonRpcMessage = { 41 | jsonrpc: "2.0", 42 | id?: JsonRpcId, 43 | method?: string, 44 | params?: any, 45 | result?: any, 46 | error?: any, 47 | }; 48 | 49 | const setDecorations = StateEffect.define({}); 50 | 51 | export abstract class LspClient { 52 | public id: number; 53 | public outboundRequests: Map; 54 | 55 | public rootUri: string; 56 | public workspaceFolders: LSP.WorkspaceFolder[]; 57 | 58 | public autoClose?: boolean; 59 | public plugins: LspPlugin[]; 60 | 61 | public isOpen: boolean; 62 | /** 63 | * Await initialization cycle completion 64 | */ 65 | public initializePromise: Promise; 66 | /** 67 | * Relies on initializePromise 68 | */ 69 | public capabilities: LSP.ServerCapabilities; 70 | 71 | constructor(rootUri: string, workspaceFolders: LSP.WorkspaceFolder[]) { 72 | this.id = 0; 73 | this.outboundRequests = new Map(); 74 | 75 | this.rootUri = rootUri; 76 | this.workspaceFolders = workspaceFolders; 77 | 78 | this.autoClose = true; 79 | this.plugins = []; 80 | 81 | this.isOpen = false; 82 | } 83 | 84 | abstract sendMessage(data: JsonRpcMessage): Promise; 85 | 86 | public async initialize() { 87 | const { capabilities } = await this.request("initialize", { 88 | capabilities: { 89 | textDocument: { 90 | publishDiagnostics: {}, 91 | semanticTokens: { 92 | requests: {}, 93 | tokenTypes: Object.values(SemanticTokenTypes), 94 | tokenModifiers: [], 95 | formats: ["relative"], 96 | overlappingTokenSupport: true, 97 | }, 98 | hover: { 99 | dynamicRegistration: true, 100 | contentFormat: ['plaintext', 'markdown'], 101 | }, 102 | moniker: {}, 103 | synchronization: { 104 | dynamicRegistration: true, 105 | willSave: false, 106 | didSave: false, 107 | willSaveWaitUntil: false, 108 | }, 109 | completion: { 110 | dynamicRegistration: true, 111 | completionItem: { 112 | snippetSupport: false, 113 | commitCharactersSupport: true, 114 | documentationFormat: ['plaintext', 'markdown'], 115 | deprecatedSupport: false, 116 | preselectSupport: false, 117 | }, 118 | contextSupport: false, 119 | }, 120 | signatureHelp: { 121 | dynamicRegistration: true, 122 | signatureInformation: { 123 | documentationFormat: ['plaintext', 'markdown'], 124 | }, 125 | }, 126 | declaration: { 127 | dynamicRegistration: true, 128 | linkSupport: true, 129 | }, 130 | definition: { 131 | dynamicRegistration: true, 132 | linkSupport: true, 133 | }, 134 | typeDefinition: { 135 | dynamicRegistration: true, 136 | linkSupport: true, 137 | }, 138 | implementation: { 139 | dynamicRegistration: true, 140 | linkSupport: true, 141 | }, 142 | }, 143 | workspace: { 144 | configuration: true, 145 | }, 146 | }, 147 | initializationOptions: null, 148 | processId: null, 149 | rootUri: this.rootUri, 150 | workspaceFolders: this.workspaceFolders, 151 | }); 152 | this.capabilities = capabilities; 153 | this.notify("initialized", {}); 154 | this.isOpen = true; 155 | } 156 | 157 | async close() { 158 | await this.request("shutdown", void{}); 159 | await this.notify("exit", void{}); 160 | } 161 | 162 | textDocumentDidOpen(params: LSP.DidOpenTextDocumentParams) { 163 | return this.notify("textDocument/didOpen", params); 164 | } 165 | 166 | textDocumentDidChange(params: LSP.DidChangeTextDocumentParams) { 167 | return this.notify("textDocument/didChange", params); 168 | } 169 | 170 | textDocumentHover(params: LSP.HoverParams) { 171 | return this.request("textDocument/hover", params); 172 | } 173 | 174 | textDocumentCompletion(params: LSP.CompletionParams) { 175 | return this.request("textDocument/completion", params); 176 | } 177 | 178 | textDocumentFoldingRange(params: LSP.FoldingRangeParams) { 179 | return this.request("textDocument/foldingRange", params); 180 | } 181 | 182 | textDocumentSemanticTokensFull(params: LSP.SemanticTokensParams) { 183 | return this.request("textDocument/semanticTokens/full", params); 184 | } 185 | 186 | attachPlugin(plugin: LspPlugin) { 187 | this.plugins.push(plugin); 188 | } 189 | 190 | detachPlugin(plugin: LspPlugin) { 191 | const i = this.plugins.indexOf(plugin); 192 | if (i === -1) return; 193 | this.plugins.splice(i, 1); 194 | if (this.autoClose) this.close(); 195 | } 196 | 197 | public async request(method: string, params: P): Promise { 198 | const id = this.id++; 199 | 200 | let resolve; 201 | let reject; 202 | let promise = new Promise((a, b) => { 203 | resolve = a; 204 | reject = b; 205 | }); 206 | 207 | this.outboundRequests.set(id, {promise, resolve, reject}) 208 | this.sendMessage({ 209 | jsonrpc: "2.0", 210 | id, 211 | method, 212 | params, 213 | }); 214 | 215 | const result = await promise; 216 | this.outboundRequests.delete(id); 217 | return result as R; 218 | } 219 | 220 | public async notify

(method: string, params: P) { 221 | await this.sendMessage({ 222 | jsonrpc: "2.0", 223 | method, 224 | params, 225 | }); 226 | } 227 | 228 | public handleMessage(message: JsonRpcMessage) { 229 | if (message.method === "workspace/configuration") { 230 | const configParams = message.params as LSP.ConfigurationParams; 231 | let resp: unknown[] = []; 232 | 233 | for (const item of configParams.items) { 234 | if (item.section === "zls.prefer_ast_check_as_child_process") { 235 | resp.push(false); 236 | } else { 237 | resp.push(null); 238 | } 239 | } 240 | 241 | this.sendMessage({ 242 | jsonrpc: "2.0", 243 | id: message.id, 244 | result: resp, 245 | }) 246 | 247 | return; 248 | } 249 | 250 | if (message.id !== undefined && message.method === undefined) { 251 | const req = this.outboundRequests.get(message.id); 252 | if (req) { 253 | if (message.error) req.reject(message.error); 254 | else req.resolve(message.result); 255 | } else { 256 | console.error("Got non-answer"); 257 | } 258 | } 259 | 260 | for (const plugin of this.plugins) 261 | plugin.handleMessage(message); 262 | } 263 | 264 | public createPlugin(docUri: string, langId: string, allowHtmlContent: boolean) { 265 | let plugin: LspPlugin | null = null; 266 | 267 | const decorations = StateField.define({ 268 | create() { 269 | return Decoration.none; 270 | }, 271 | update(decorations, tr) { 272 | for (let e of tr.effects) if (e.is(setDecorations)) { 273 | decorations = e.value; 274 | } 275 | return decorations; 276 | }, 277 | provide: f => EditorView.decorations.from(f) 278 | }); 279 | 280 | return [ 281 | client.of(this), 282 | documentUri.of(docUri), 283 | languageId.of(langId), 284 | decorations.extension, 285 | ViewPlugin.define((view) => (plugin = new LspPlugin(view, allowHtmlContent)), {}), 286 | hoverTooltip( 287 | (view, pos) => plugin?.requestHoverTooltip( 288 | view, 289 | offsetToPos(view.state.doc, pos) 290 | ) ?? null 291 | ), 292 | foldService.of((state, lineStart, lineEnd) => { 293 | const startLine = state.doc.lineAt(lineStart); 294 | const range = plugin?.foldingRangeMap.get(startLine.number - 1); 295 | if (range) { 296 | if (range.endLine > state.doc.lines) return null; 297 | const endLine = state.doc.line(range.endLine + 1); 298 | return {from: range.startCharacter != undefined ? lineStart + range.startCharacter : startLine.to, to: range.endCharacter != undefined ? endLine.from + range.endCharacter : endLine.to}; 299 | } 300 | 301 | return null; 302 | }), 303 | 304 | autocompletion({ 305 | override: [ 306 | async (context) => { 307 | if (plugin == null) return null; 308 | 309 | const { state, pos, explicit } = context; 310 | const line = state.doc.lineAt(pos); 311 | let trigKind: CompletionTriggerKind = 312 | CompletionTriggerKind.Invoked; 313 | let trigChar: string | undefined; 314 | if ( 315 | !explicit && 316 | plugin.client.capabilities?.completionProvider?.triggerCharacters?.includes( 317 | line.text[pos - line.from - 1] 318 | ) 319 | ) { 320 | trigKind = CompletionTriggerKind.TriggerCharacter; 321 | trigChar = line.text[pos - line.from - 1]; 322 | } 323 | if ( 324 | trigKind === CompletionTriggerKind.Invoked && 325 | !context.matchBefore(/\w+$/) 326 | ) { 327 | return null; 328 | } 329 | return await plugin.requestCompletion( 330 | context, 331 | offsetToPos(state.doc, pos), 332 | { 333 | triggerKind: trigKind, 334 | triggerCharacter: trigChar, 335 | } 336 | ); 337 | }, 338 | ], 339 | }), 340 | Prec.highest( 341 | keymap.of([{ 342 | key: "Mod-s", 343 | run(view) { 344 | plugin!.requestFormat(view); 345 | return true; 346 | } 347 | }]) 348 | ) 349 | ]; 350 | } 351 | } 352 | 353 | class LspPlugin implements PluginValue { 354 | public client: LspClient; 355 | 356 | private documentUri: string; 357 | private languageId: string; 358 | private documentVersion: number; 359 | 360 | public decorations: DecorationSet; 361 | public foldingRangeMap: Map; 362 | 363 | constructor(private view: EditorView, private allowHtmlContent: boolean) { 364 | this.client = this.view.state.facet(client); 365 | this.documentUri = this.view.state.facet(documentUri); 366 | this.languageId = this.view.state.facet(languageId); 367 | this.documentVersion = 0; 368 | 369 | this.decorations = Decoration.none; 370 | this.foldingRangeMap = new Map(); 371 | 372 | this.client.attachPlugin(this); 373 | 374 | this.initialize({ 375 | documentText: this.view.state.doc.toString(), 376 | }); 377 | } 378 | 379 | update(update: ViewUpdate) { 380 | if (!update.docChanged) return; 381 | this.foldingRangeMap.clear(); 382 | (async () => { 383 | await this.sendChange({ 384 | documentText: this.view.state.doc.toString(), 385 | }); 386 | await this.updateDecorations(); 387 | await this.updateFoldingRanges(); 388 | })(); 389 | } 390 | 391 | destroy() { 392 | this.client.detachPlugin(this); 393 | } 394 | 395 | async initialize({ documentText }: { documentText: string }) { 396 | if (this.client.initializePromise) { 397 | await this.client.initializePromise; 398 | } 399 | this.client.textDocumentDidOpen({ 400 | textDocument: { 401 | uri: this.documentUri, 402 | languageId: this.languageId, 403 | text: documentText, 404 | version: this.documentVersion, 405 | } 406 | }); 407 | await this.updateDecorations(); 408 | await this.updateFoldingRanges(); 409 | } 410 | 411 | async sendChange({ documentText }: { documentText: string }) { 412 | if (!this.client.isOpen) return; 413 | try { 414 | await this.client.textDocumentDidChange({ 415 | textDocument: { 416 | uri: this.documentUri, 417 | version: this.documentVersion++, 418 | }, 419 | contentChanges: [{ text: documentText }], 420 | }); 421 | } catch (e) { 422 | console.error(e); 423 | } 424 | } 425 | 426 | public async updateDecorations(): Promise { 427 | // TODO: Look into using incremental semantic 428 | // tokens using view.visibleRanges 429 | 430 | const semanticTokens = await this.client.textDocumentSemanticTokensFull({ 431 | textDocument: { 432 | uri: this.documentUri, 433 | } 434 | }); 435 | 436 | if (!semanticTokens) return console.log("No semantic tokens!"); 437 | 438 | const tokenTypes = this.client.capabilities.semanticTokensProvider!.legend.tokenTypes; 439 | const tokenModifiers = this.client.capabilities.semanticTokensProvider!.legend.tokenModifiers; 440 | 441 | let builder = new RangeSetBuilder(); 442 | 443 | let line = 0; 444 | let col = 0; 445 | 446 | const data = semanticTokens.data; 447 | for (let i = 0; i < data.length; i += 5) { 448 | const deltaLine = data[i]; 449 | const deltaStartChar = data[i + 1]; 450 | const length = data[i + 2]; 451 | const tokenType = data[i + 3]; 452 | const tokenModifierBitSet = data[i + 4]; 453 | 454 | line += deltaLine; 455 | if (deltaLine == 0) { // same line 456 | col += deltaStartChar; 457 | } else { 458 | col = deltaStartChar; 459 | } 460 | 461 | let className = `st-${tokenTypes[tokenType]}`; 462 | 463 | { 464 | let value = tokenModifierBitSet; 465 | let index = 0; 466 | while (value != 0) { 467 | if (value & 1) { 468 | className += ` sm-${tokenModifiers[index]}`; 469 | } 470 | value = value >> 1; 471 | index += 1; 472 | } 473 | } 474 | 475 | const l = this.view.state.doc.line(line + 1).from; 476 | builder.add(l + col, l + col + length, Decoration.mark({ 477 | class: className, 478 | })); 479 | } 480 | this.decorations = builder.finish() 481 | this.view.dispatch({effects: [setDecorations.of(this.decorations)]}); 482 | } 483 | 484 | public async updateFoldingRanges(): Promise { 485 | const ranges = await this.client.textDocumentFoldingRange({ 486 | textDocument: { 487 | uri: this.documentUri, 488 | } 489 | }); 490 | 491 | this.foldingRangeMap.clear(); 492 | if (ranges) { 493 | for (const range of ranges) { 494 | this.foldingRangeMap.set(range.startLine, range); 495 | } 496 | } 497 | } 498 | 499 | async requestFormat(view: EditorView): Promise { 500 | const formattingResult = await this.client.request("textDocument/formatting", { 501 | options: { 502 | insertSpaces: true, 503 | tabSize: 4, 504 | }, 505 | textDocument: { 506 | uri: this.documentUri, 507 | } 508 | }); 509 | 510 | this.foldingRangeMap.clear(); 511 | 512 | if (formattingResult) { 513 | const text = this.view.state.doc; 514 | 515 | let changes: ChangeSpec[] = []; 516 | for (const n of formattingResult) { 517 | changes.push({ from: posToOffset(text, n.range.start)!, to: posToOffset(text, n.range.end)!, insert: n.newText }); 518 | } 519 | if (changes.length > 0) { 520 | this.view.dispatch({ 521 | changes, 522 | }); 523 | } 524 | } 525 | 526 | await this.updateFoldingRanges(); 527 | } 528 | 529 | async requestHoverTooltip( 530 | view: EditorView, 531 | { line, character }: { line: number; character: number } 532 | ): Promise { 533 | if (!this.client.isOpen || !this.client.capabilities!.hoverProvider) return null; 534 | 535 | const result = await this.client.textDocumentHover({ 536 | textDocument: { uri: this.documentUri }, 537 | position: { line, character }, 538 | }); 539 | if (!result) return null; 540 | 541 | const { contents, range } = result; 542 | let pos = posToOffset(view.state.doc, { line, character })!; 543 | let end: number = pos; 544 | if (range) { 545 | pos = posToOffset(view.state.doc, range.start)!; 546 | end = posToOffset(view.state.doc, range.end) ?? end; 547 | } 548 | if (pos === null) return null; 549 | return { pos, end, create () { 550 | const dom = document.createElement("div"); 551 | dom.textContent = formatContents(contents); 552 | return {dom}; 553 | }, above: true }; 554 | } 555 | 556 | async requestCompletion( 557 | context: CompletionContext, 558 | { line, character }: { line: number; character: number }, 559 | { 560 | triggerKind, 561 | triggerCharacter, 562 | }: { 563 | triggerKind: CompletionTriggerKind; 564 | triggerCharacter: string | undefined; 565 | } 566 | ): Promise { 567 | if (!this.client.isOpen || !this.client.capabilities!.completionProvider) return null; 568 | this.sendChange({ 569 | documentText: context.state.doc.toString(), 570 | }); 571 | 572 | const result = await this.client.textDocumentCompletion({ 573 | textDocument: { uri: this.documentUri }, 574 | position: { line, character }, 575 | context: { 576 | triggerKind, 577 | triggerCharacter, 578 | } 579 | }); 580 | 581 | if (!result) return null; 582 | 583 | const items = 'items' in result ? result.items : result; 584 | 585 | let options = items.map( 586 | ({ 587 | detail, 588 | label, 589 | kind, 590 | textEdit, 591 | documentation, 592 | sortText, 593 | filterText, 594 | }) => { 595 | const completion: Completion & { 596 | filterText: string; 597 | sortText?: string; 598 | apply: string; 599 | } = { 600 | label, 601 | detail, 602 | apply: textEdit?.newText ?? label, 603 | type: kind && CompletionItemKindMap[kind].toLowerCase(), 604 | sortText: sortText ?? label, 605 | filterText: filterText ?? label, 606 | }; 607 | if (documentation) { 608 | completion.info = formatContents(documentation); 609 | } 610 | return completion; 611 | } 612 | ); 613 | 614 | const [span, match] = prefixMatch(options); 615 | const token = context.matchBefore(match); 616 | let { pos } = context; 617 | 618 | if (token) { 619 | pos = token.from; 620 | const word = token.text.toLowerCase(); 621 | if (/^\w+$/.test(word)) { 622 | options = options 623 | .filter(({ filterText }) => 624 | filterText.toLowerCase().startsWith(word) 625 | ) 626 | .sort(({ apply: a }, { apply: b }) => { 627 | switch (true) { 628 | case a.startsWith(token.text) && 629 | !b.startsWith(token.text): 630 | return -1; 631 | case !a.startsWith(token.text) && 632 | b.startsWith(token.text): 633 | return 1; 634 | } 635 | return 0; 636 | }); 637 | } 638 | } 639 | return { 640 | from: pos, 641 | options, 642 | }; 643 | } 644 | 645 | handleMessage(message: JsonRpcMessage) { 646 | try { 647 | switch (message.method) { 648 | case "textDocument/publishDiagnostics": 649 | this.handleDiagnostics(message.params); 650 | break; 651 | } 652 | } catch (error) { 653 | console.error(error); 654 | } 655 | } 656 | 657 | handleDiagnostics(params: PublishDiagnosticsParams) { 658 | if (params.uri !== this.documentUri) return; 659 | 660 | const diagnostics = params.diagnostics 661 | .map(({ range, message, severity }) => ({ 662 | from: posToOffset(this.view.state.doc, range.start)!, 663 | to: posToOffset(this.view.state.doc, range.end)!, 664 | severity: ({ 665 | [DiagnosticSeverity.Error]: 'error', 666 | [DiagnosticSeverity.Warning]: 'warning', 667 | [DiagnosticSeverity.Information]: 'info', 668 | [DiagnosticSeverity.Hint]: 'info', 669 | } as const)[severity!], 670 | message, 671 | })) 672 | .filter(({ from, to }) => from !== null && to !== null && from !== undefined && to !== undefined) 673 | .sort((a, b) => { 674 | switch (true) { 675 | case a.from < b.from: 676 | return -1; 677 | case a.from > b.from: 678 | return 1; 679 | } 680 | return 0; 681 | }); 682 | 683 | this.view.dispatch(setDiagnostics(this.view.state, diagnostics)); 684 | } 685 | } 686 | 687 | interface LanguageServerBaseOptions { 688 | rootUri: string | null; 689 | workspaceFolders: LSP.WorkspaceFolder[] | null; 690 | documentUri: string; 691 | languageId: string; 692 | } 693 | 694 | function posToOffset(doc: Text, pos: { line: number; character: number }) { 695 | if (pos.line >= doc.lines) return; 696 | const offset = doc.line(pos.line + 1).from + pos.character; 697 | if (offset > doc.length) return; 698 | return offset; 699 | } 700 | 701 | function offsetToPos(doc: Text, offset: number) { 702 | const line = doc.lineAt(offset); 703 | return { 704 | line: line.number - 1, 705 | character: offset - line.from, 706 | }; 707 | } 708 | 709 | function formatContents( 710 | contents: LSP.MarkupContent | LSP.MarkedString | LSP.MarkedString[] 711 | ): string { 712 | if (Array.isArray(contents)) { 713 | return contents.map((c) => formatContents(c) + '\n\n').join(''); 714 | } else if (typeof contents === 'string') { 715 | return contents; 716 | } else { 717 | return contents.value; 718 | } 719 | } 720 | 721 | function toSet(chars: Set) { 722 | let preamble = ''; 723 | let flat = Array.from(chars).join(''); 724 | const words = /\w/.test(flat); 725 | if (words) { 726 | preamble += '\\w'; 727 | flat = flat.replace(/\w/g, ''); 728 | } 729 | return `[${preamble}${flat.replace(/[^\w\s]/g, '\\$&')}]`; 730 | } 731 | 732 | function prefixMatch(options: Completion[]) { 733 | const first = new Set(); 734 | const rest = new Set(); 735 | 736 | for (const { apply } of options) { 737 | const [initial, ...restStr] = apply as string; 738 | first.add(initial); 739 | for (const char of restStr) { 740 | rest.add(char); 741 | } 742 | } 743 | 744 | const source = toSet(first) + toSet(rest) + '*$'; 745 | return [new RegExp('^' + source), new RegExp(source)]; 746 | } 747 | --------------------------------------------------------------------------------