├── .gitignore ├── demo.gif ├── package.json ├── tsconfig.json ├── index.html ├── README.md ├── bun.lock └── lsp-server.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jamesbvaughan/bidirectional-number-editor/HEAD/demo.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bdne", 3 | "author": "James Vaughan ", 4 | "private": true, 5 | "dependencies": { 6 | "vscode-languageserver": "^9.0.1", 7 | "vscode-languageserver-textdocument": "^1.0.12" 8 | }, 9 | "devDependencies": { 10 | "@types/bun": "latest", 11 | "typescript": "^5" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // Environment setup & latest features 4 | "lib": ["ESNext"], 5 | "target": "ESNext", 6 | "module": "ESNext", 7 | "moduleDetection": "force", 8 | "jsx": "react-jsx", 9 | "allowJs": true, 10 | 11 | // Bundler mode 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | // Best practices 18 | "strict": true, 19 | "skipLibCheck": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedIndexedAccess": true, 22 | 23 | // Some stricter flags (disabled by default) 24 | "noUnusedLocals": false, 25 | "noUnusedParameters": false, 26 | "noPropertyAccessFromIndexSignature": false 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Bidirectional Number Editor Demo 6 | 7 | 8 |

Bidirectional Number Editor Demo

9 | 10 |
11 | Current Value: 12 | 13 |
14 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bdne: bidirectional number editor 2 | 3 | This repo contains a demonstration of bidirectional editing between a text 4 | document and a web interface, facilitated by an LSP server. 5 | 6 | [Blog post with more info](https://jamesbvaughan.com/bidirectional-editing/) 7 | 8 | ![demo screen recording](demo.gif) 9 | 10 | It was inspired by [Kevin Lynagh's updates on his work on 11 | codeCAD](https://kevinlynagh.com/newsletter/2025_06_03_prototyping_a_language/). 12 | 13 | This is meant to be a proof-of-concept for one way you might implement 14 | bidirectional editing in a CAD system that allows for editing via either a text 15 | file or a graphical UI, keeping each in sync with the other. 16 | 17 | ## How does it work? 18 | 19 | [The LSP server](lsp-server.ts) is not just an LSP server. It also serves the 20 | web client over HTTP and communicates with the web client over a WebSocket. 21 | 22 | ``` 23 | ┌───────────────┐ ┌──────────────┐ ┌───────┐ 24 | │ │ LSP │ │ HTTP │ │ 25 | │ Text Editor │ ◄─────► │ LSP Server │ ◄───────► │ GUI │ 26 | │ │ │ │ WebSocket │ │ 27 | └───────────────┘ └──────────────┘ └───────┘ 28 | ``` 29 | 30 | The language server expects a file whose entire contents are a single number. 31 | 32 | This keeps things incredibly simple for this demo because I'm just reading and 33 | writing the entire file for each change, but language servers are capable of 34 | much more structured and targeted editing. 35 | It's easy to imagine a more complex language where the values relate to 36 | parameters in a CAD model that can be manipulated in either direction like this. 37 | 38 | ## How to try it out 39 | 40 | Here's the config I'm using for Neovim: 41 | 42 | ```lua 43 | local lspconfig = require("lspconfig") 44 | local configs = require("lspconfig.configs") 45 | 46 | -- Add a "bdne" filetype for *.bdne files 47 | vim.filetype.add({ extension = { bdne = "bdne" } }) 48 | 49 | -- Tell Neovim how to use this server 50 | configs.bdne = { 51 | default_config = { 52 | cmd = { "bun", "/Users/james/code/bdne/lsp-server.ts", "--stdio" }, 53 | filetypes = { "bdne" }, 54 | root_dir = function() 55 | return vim.loop.cwd() 56 | end, 57 | }, 58 | } 59 | 60 | -- Tell Neovim to use this server 61 | lspconfig.bdne.setup({}) 62 | ``` 63 | 64 | I'm not familiar with the LSP clients in other editors, but I'd expect something 65 | similar to be necessary. 66 | -------------------------------------------------------------------------------- /bun.lock: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1, 3 | "workspaces": { 4 | "": { 5 | "name": "bdne", 6 | "dependencies": { 7 | "vscode-languageserver": "^9.0.1", 8 | "vscode-languageserver-textdocument": "^1.0.12", 9 | "ws": "^8.18.3", 10 | }, 11 | "devDependencies": { 12 | "@types/bun": "latest", 13 | }, 14 | "peerDependencies": { 15 | "typescript": "^5", 16 | }, 17 | }, 18 | }, 19 | "packages": { 20 | "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], 21 | 22 | "@types/node": ["@types/node@24.0.7", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-YIEUUr4yf8q8oQoXPpSlnvKNVKDQlPMWrmOcgzoduo7kvA2UF0/BwJ/eMKFTiTtkNL17I0M6Xe2tvwFU7be6iw=="], 23 | 24 | "bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], 25 | 26 | "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], 27 | 28 | "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], 29 | 30 | "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], 31 | 32 | "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], 33 | 34 | "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], 35 | 36 | "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], 37 | 38 | "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], 39 | 40 | "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /lsp-server.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createConnection, 3 | TextDocuments, 4 | ProposedFeatures, 5 | TextDocumentSyncKind, 6 | type TextDocumentChangeEvent, 7 | Range, 8 | Position, 9 | TextEdit, 10 | type Diagnostic, 11 | DiagnosticSeverity, 12 | } from "vscode-languageserver/node"; 13 | import { TextDocument } from "vscode-languageserver-textdocument"; 14 | import type { ServerWebSocket } from "bun"; 15 | 16 | let currentValue = 0; 17 | const WEB_PORT = 3001; 18 | 19 | const webClients = new Set>(); 20 | const lspConnection = createConnection(ProposedFeatures.all); 21 | const files = new TextDocuments(TextDocument); 22 | 23 | function broadcastValueToWebClient(client: ServerWebSocket) { 24 | client.send(JSON.stringify({ value: currentValue })); 25 | } 26 | 27 | function setValueInDocuments() { 28 | files.all().forEach((file) => { 29 | lspConnection.workspace.applyEdit({ 30 | changes: { 31 | [file.uri]: [ 32 | TextEdit.replace( 33 | Range.create( 34 | Position.create(0, 0), 35 | Position.create(file.lineCount, 0), 36 | ), 37 | currentValue.toString(), 38 | ), 39 | ], 40 | }, 41 | }); 42 | }); 43 | } 44 | 45 | lspConnection.onInitialize(() => ({ 46 | capabilities: { textDocumentSync: TextDocumentSyncKind.Full }, 47 | })); 48 | 49 | files.onDidChangeContent((e: TextDocumentChangeEvent) => { 50 | const documentContent = e.document.getText().trim(); 51 | const value = Number(documentContent); 52 | const isValid = !isNaN(value) && documentContent !== ""; 53 | 54 | let diagnostics: Diagnostic[] = []; 55 | if (!isValid) { 56 | const lines = e.document.getText().split("\n"); 57 | const lastLineIndex = lines.length - 1; 58 | const lastLine = lines[lastLineIndex]!; 59 | diagnostics = [ 60 | { 61 | severity: DiagnosticSeverity.Error, 62 | range: { 63 | start: { line: 0, character: 0 }, 64 | end: { line: lastLineIndex, character: lastLine.length }, 65 | }, 66 | message: "Document must contain a single numeric value.", 67 | source: "bdne", 68 | }, 69 | ]; 70 | } 71 | lspConnection.sendDiagnostics({ 72 | uri: e.document.uri, 73 | diagnostics, 74 | }); 75 | 76 | if (!isValid) return; 77 | 78 | currentValue = value; 79 | webClients.forEach(broadcastValueToWebClient); 80 | }); 81 | 82 | files.listen(lspConnection); 83 | lspConnection.listen(); 84 | 85 | Bun.serve({ 86 | port: WEB_PORT, 87 | async fetch(req, server) { 88 | const path = new URL(req.url).pathname; 89 | switch (path) { 90 | case "/": 91 | return new Response(Bun.file("index.html")); 92 | 93 | case "/socket": 94 | server.upgrade(req); 95 | return; 96 | 97 | default: 98 | return new Response("Not found", { status: 404 }); 99 | } 100 | }, 101 | websocket: { 102 | open(ws) { 103 | webClients.add(ws); 104 | broadcastValueToWebClient(ws); 105 | }, 106 | message(_ws, msg: string) { 107 | currentValue = JSON.parse(msg).value; 108 | setValueInDocuments(); 109 | }, 110 | close(ws) { 111 | webClients.delete(ws); 112 | }, 113 | }, 114 | }); 115 | 116 | // Open the web client in the browser on startup 117 | Bun.spawn(["open", `http://localhost:${WEB_PORT}/`]); 118 | --------------------------------------------------------------------------------