From 6abb0fd3dd5ba3e28445af403e656958dd486741 Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Mon, 27 Jul 2026 17:02:41 +0200 Subject: [PATCH] VS Code: decoration-based matching, Ctrl+K bindings, README overhaul (from dev f8451715e657) Co-Authored-By: Claude Fable 5 --- README.md | 2 +- doc/edit/shared/klammertext_ls.py | 20 +++++ doc/edit/vscode/README.md | 99 +++++++++++++++------ doc/edit/vscode/extension.js | 57 ++++++++++++ doc/edit/vscode/language-configuration.json | 13 ++- doc/edit/vscode/package.json | 28 +++++- doc/install/linux_container_install.md | 7 +- doc/install/macos_container_install.md | 7 +- tst/editor/ls_test.py | 27 ++++++ tst/editor/vscode_ext_test.js | 34 ++++++- 10 files changed, 255 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 7a5521c..321b842 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ are regenerated on each release — patches cannot be merged directly. Report problems (or send patches) to the author; accepted changes are applied to the development tree and appear in a following snapshot. -This snapshot was assembled from development commit `83e472a96e36`. +This snapshot was assembled from development commit `f84517152b7f`. ## License diff --git a/doc/edit/shared/klammertext_ls.py b/doc/edit/shared/klammertext_ls.py index 3645796..3f7b522 100644 --- a/doc/edit/shared/klammertext_ls.py +++ b/doc/edit/shared/klammertext_ls.py @@ -283,6 +283,26 @@ class Server: 'kind': 1}) self.reply(msg_id, highlights) + def on_klammertext_matchInfo(self, msg_id, params): + """Custom request: the full matching story for a cursor position — + token, matching token, and whether the pair mismatches. The VS Code + extension draws its live match/mismatch decorations from this + (documentHighlight is word-gated in VS Code, so a bare @ close would + never trigger it; and it cannot carry the mismatch flag).""" + uri = params['textDocument']['uri'] + text = self.docs.get(uri, '') + m = KE.match_at(text, pos_to_offset(text, params['position'])) + if m is None: + self.reply(msg_id, None) + return + self.reply(msg_id, { + 'token': offsets_to_range(text, *m['token']), + 'matchToken': (offsets_to_range(text, *m['match_token']) + if m['match_token'] is not None else None), + 'mismatch': m['mismatch'], + 'message': m['message'], + }) + def on_textDocument_definition(self, msg_id, params): uri = params['textDocument']['uri'] text = self.docs.get(uri, '') diff --git a/doc/edit/vscode/README.md b/doc/edit/vscode/README.md index 26e8786..79faad7 100644 --- a/doc/edit/vscode/README.md +++ b/doc/edit/vscode/README.md @@ -52,7 +52,23 @@ support built in, so there is no conflict out of the box; if you install a Kotlin extension, the two will contend for `.kt` and you can decide per file with the language-mode picker (or `files.associations`). -## What you get +## VS Code commands for Klammertext + +| Command | Menu | Key | Cursor position | +| --- | --- | --- | --- | +| Format Document | Right-click | `Ctrl+Shift+I` | Anywhere in the document | +| Format Selection | Right-click | `Ctrl+K Ctrl+F` | Lines selected | +| Toggle Line Comment | Edit menu | `Ctrl+/` | In the line to remove with `#` | +| Toggle Block Comment | Edit menu | `Ctrl+Shift+A` | Region to remove selected (`#[ ... ]#`) | +| Klammertext: Jump to Matching Delimiter | Right-click | `Ctrl+K J` | On the opening `@name` or the closing `name@` / `@` | +| Klammertext: Align Table | Right-click | `Ctrl+K A` | Anywhere inside the `@table` | +| Delimiter diagnostics | Problems panel | — | Automatic, as you type | + +All commands are also in the Command Palette (`Ctrl+Shift+P`). Keys shown +are the Linux defaults: the Klammertext commands use `Cmd+K` on macOS, and +the built-in formatting and comment keys differ per OS. + +## Features **Syntax highlighting** — the same token classes as the Emacs, Sublime Text, and Vim support: text removal (`#`, `##`, nestable `#[ ... ]#`), the @@ -65,42 +81,71 @@ Klammertext palette (application blue / definition green / system orange, opens bright and closes darker), add `editor.tokenColorCustomizations` rules for the `*.klammertext` scopes in your settings. -**Diagnostics** — unclosed and mismatched delimiters appear in the -Problems panel as you type. - -**Formatting** — **Format Document** / **Format Selection** reindent -structurally (2 spaces per nesting level; bar runs and closing delimiters -sit at their opener's column; `@document` content stays at the margin; -verbatim `@code` interiors, `@eval` code, and removed text are never -touched). Reindentation is **explicit-only**: there is deliberately no +**Structural reindentation** — Format Document and Format Selection +reindent per the Klammertext convention: 2 spaces per nesting level; a +line beginning with a bar run or a closing delimiter sits at its opener's +column; `@document` content stays at the margin; verbatim `@code` +interiors, `@eval` code, and removed text are never touched. +Reindentation is **explicit-only**: there is deliberately no format-on-type, because whitespace is content in Klammertext. -**Delimiter matching** — with the cursor on an application delimiter, the -matching delimiter highlights (occurrences highlighting); **Go to -Definition** on a delimiter goes to its match. Literal klammers match by -name (`@code` ↔ `code@`) with their verbatim content opaque; everything -else matches by depth. +**Delimiter matching** — with the cursor on an application delimiter +(opening `@name`, named close `name@`, or a bare `@` close), the delimiter +and its match are boxed; a mismatched named close or an unbalanced +delimiter is boxed in **red** with a status-bar message, as in the Emacs, +Sublime, and Vim support. **Go to Definition** on a delimiter goes to its +match, so Jump to Matching Delimiter has a second home on `F12`. Literal +klammers match by name (`@code` ↔ `code@`) with their verbatim content +opaque — a stray `@` in the verbatim interior cannot confuse them; +everything else matches by depth. Double-click selects a whole delimiter +token. -**Commands and keybindings** (when editing Klammertext): +**Delimiter diagnostics** — the automatic Problems-panel entries cover +all three `@`-tiers: a closing delimiter with no opening, a named close +that disagrees with its opening (`ul@` closing `@ol`), a close of the +wrong tier (`@@` closing `@name`), openings never closed, and unclosed +`@code` and `#[` regions. -| Key | Command | -|---|---| -| `Ctrl+Alt+J` (`Cmd+Alt+J`) | Klammertext: Jump to Matching Delimiter | -| `Ctrl+Alt+A` (`Cmd+Alt+A`) | Klammertext: Align Table | +**Table alignment** — pads the cells of the `@table` enclosing the cursor +so the `|` separators line up, with the rules shared across the editors: +rows end with `||`; a row with a cell over 30 characters or spanning lines +is left untouched; beyond 100 aligned columns the command declines; bars +inside a nested klammer belong to that klammer, not the table; and no +whitespace is ever inserted inside a bar run (`||` is a row separator, +`| |` an empty cell). -Table alignment pads the cells of the `@table` enclosing the cursor so the -`|` separators line up, with the shared rules: rows end with `||`; a row -with a cell over 30 characters or spanning lines is left untouched; beyond -100 columns the command declines; bars inside a nested klammer are not -separators; no whitespace is ever inserted inside a bar run. +**Text removal** — the Toggle Comment commands are VS Code's names; in +Klammertext they toggle `#` line removal and `#[ ... ]#` block removal +(the `#` does not "comment out": it removes text from processing). -**Text removal toggling** — `Ctrl+/` toggles `#` line removal and -`Shift+Alt+A` wraps the selection in `#[ ... ]#`, via the standard VS Code -comment commands. +**Keybinding notes** — each Klammertext command has two bindings because +some environments never deliver `Ctrl+Alt+letter` chords to VS Code (a +right Alt is usually AltGr, not Alt, and some desktops and input methods +intercept the chord); the two-step `Ctrl+K` chords go through everywhere. +If a key seems to do nothing, run the command from the Command Palette +first: if that works, the chord is being intercepted — open **Keyboard +Shortcuts** (`Ctrl+K Ctrl+S`), search "klammertext", and rebind. ## Settings +A normal installation needs neither setting: the extension runs `python3` +from `PATH` and finds the language server automatically (the copy vendored +next to `extension.js`, then `../shared/`, then +`$KLAMMERTEXT_HOME/doc/edit/shared/`). They exist for unusual setups. +Set them in the Settings UI (`Ctrl+,`, search "klammertext") or in +`settings.json`; the server is spawned when the extension activates, so +reload the window after changing either. + | Setting | Meaning (default) | |---|---| | `klammertext.pythonPath` | Python interpreter for the server (`python3`) | | `klammertext.serverPath` | full path to `klammertext_ls.py` (auto-located) | + +`pythonPath` matters when `python3` is not on the `PATH` VS Code sees — a +VS Code launched from the desktop inherits a different environment than +your shell — or when a specific interpreter is wanted. `serverPath` +matters only when the server file lives outside the search chain above. +If the server cannot be started at all, the extension says so once at +activation; highlighting still works, and everything structural +(diagnostics, formatting, matching, alignment) waits until the path is +fixed. diff --git a/doc/edit/vscode/extension.js b/doc/edit/vscode/extension.js index 6ac1024..310ce3f 100644 --- a/doc/edit/vscode/extension.js +++ b/doc/edit/vscode/extension.js @@ -167,6 +167,11 @@ function activate(context) { } const python = vscode.workspace.getConfiguration('klammertext').get('pythonPath') || 'python3'; client = new LspClient(python, [serverPath], log); + client.proc.on('error', (err) => { + vscode.window.showWarningMessage( + 'Klammertext: could not start the language server (' + err.message + + ') — check the klammertext.pythonPath setting.'); + }); const diagnostics = vscode.languages.createDiagnosticCollection('klammertext'); context.subscriptions.push(diagnostics, output); @@ -271,6 +276,58 @@ function activate(context) { }, })); + // -- live match/mismatch decorations -- + // Drawn on every cursor move from the server's klammertext/matchInfo. + // Deliberately NOT left to occurrence highlighting: VS Code only asks + // documentHighlight providers when the cursor is on a word, so a bare @ + // close would never light up — and a mismatch could not show in red. + const matchDecoration = vscode.window.createTextEditorDecorationType({ + border: '1px solid', + borderColor: new vscode.ThemeColor('editorBracketMatch.border'), + backgroundColor: new vscode.ThemeColor('editorBracketMatch.background'), + }); + const mismatchDecoration = vscode.window.createTextEditorDecorationType({ + border: '1px solid #ff5555', + fontWeight: 'bold', + }); + context.subscriptions.push(matchDecoration, mismatchDecoration); + + const updateMatchDecorations = (editor) => { + if (!editor || !isKt(editor.document)) return; + client.request('klammertext/matchInfo', + Object.assign(docParams(editor.document), + { position: fromVsPosition(editor.selection.active) })) + .then((info) => { + if (!info) { + editor.setDecorations(matchDecoration, []); + editor.setDecorations(mismatchDecoration, []); + return; + } + const ranges = [toVsRange(info.token)]; + if (info.matchToken) ranges.push(toVsRange(info.matchToken)); + if (info.mismatch) { + editor.setDecorations(matchDecoration, []); + editor.setDecorations(mismatchDecoration, ranges); + if (info.message) { + vscode.window.setStatusBarMessage( + 'Klammertext: ' + info.message, 5000); + } + } else { + editor.setDecorations(mismatchDecoration, []); + editor.setDecorations(matchDecoration, ranges); + } + }, () => { /* server gone: leave decorations as they are */ }); + }; + let matchTimer = null; + context.subscriptions.push( + vscode.window.onDidChangeTextEditorSelection((event) => { + if (matchTimer) clearTimeout(matchTimer); + matchTimer = setTimeout( + () => updateMatchDecorations(event.textEditor), 50); + }), + vscode.window.onDidChangeActiveTextEditor( + (editor) => updateMatchDecorations(editor))); + // -- commands -- context.subscriptions.push( vscode.commands.registerCommand('klammertext.jumpToMatch', () => { diff --git a/doc/edit/vscode/language-configuration.json b/doc/edit/vscode/language-configuration.json index e8a3bbb..ca05f7c 100644 --- a/doc/edit/vscode/language-configuration.json +++ b/doc/edit/vscode/language-configuration.json @@ -1,11 +1,18 @@ { "comments": { "lineComment": "#", - "blockComment": ["#[", "]#"] + "blockComment": [ + "#[", + "]#" + ] }, "brackets": [ - ["#[", "]#"] + [ + "#[", + "]#" + ] ], "autoClosingPairs": [], - "surroundingPairs": [] + "surroundingPairs": [], + "wordPattern": "@{1,3}[A-Za-z0-9_]+|[A-Za-z0-9_]+@{1,3}|@{1,3}|[A-Za-z0-9_]+" } diff --git a/doc/edit/vscode/package.json b/doc/edit/vscode/package.json index b9f4a2e..3d47278 100644 --- a/doc/edit/vscode/package.json +++ b/doc/edit/vscode/package.json @@ -2,7 +2,7 @@ "name": "klammertext", "displayName": "Klammertext", "description": "Klammertext language support: syntax highlighting, delimiter matching, structural reindentation, table alignment, and delimiter diagnostics.", - "version": "0.1.0", + "version": "0.1.2", "publisher": "klammertext", "license": "SEE LICENSE IN THE KLAMMERTEXT DISTRIBUTION", "engines": { @@ -53,12 +53,24 @@ } ], "keybindings": [ + { + "command": "klammertext.jumpToMatch", + "key": "ctrl+k j", + "mac": "cmd+k j", + "when": "editorTextFocus && editorLangId == klammertext" + }, { "command": "klammertext.jumpToMatch", "key": "ctrl+alt+j", "mac": "cmd+alt+j", "when": "editorTextFocus && editorLangId == klammertext" }, + { + "command": "klammertext.alignTable", + "key": "ctrl+k a", + "mac": "cmd+k a", + "when": "editorTextFocus && editorLangId == klammertext" + }, { "command": "klammertext.alignTable", "key": "ctrl+alt+a", @@ -80,6 +92,20 @@ "description": "Full path to klammertext_ls.py. Leave blank to auto-locate: a copy next to the extension, ../shared/ relative to it (the Klammertext repository layout), or $KLAMMERTEXT_HOME/doc/edit/shared/." } } + }, + "menus": { + "editor/context": [ + { + "command": "klammertext.jumpToMatch", + "when": "editorLangId == klammertext", + "group": "1_modification@10" + }, + { + "command": "klammertext.alignTable", + "when": "editorLangId == klammertext", + "group": "1_modification@11" + } + ] } } } diff --git a/doc/install/linux_container_install.md b/doc/install/linux_container_install.md index 8541fec..1b2eca2 100644 --- a/doc/install/linux_container_install.md +++ b/doc/install/linux_container_install.md @@ -114,12 +114,13 @@ primitive klammers (`@read`, `@eval`, `@cond`), use `-k none`. ## Editor support (Emacs, Sublime Text) Editing Klammertext is nicer with editor support: syntax highlighting, -delimiter matching, and indentation for Emacs and Sublime Text. It is not +delimiter matching, indentation, table alignment, and diagnostics for +Emacs, Sublime Text, Vim, and Visual Studio Code. It is not inside the container image — it belongs on your machine, next to your editor. Download it from either place: -- — unpacks to `emacs/` and - `sublime/` folders +- — unpacks to `emacs/`, + `sublime/`, `vim/`, and `vscode/` folders, each self-contained - the Klammertext source repository, , directory `doc/edit/` diff --git a/doc/install/macos_container_install.md b/doc/install/macos_container_install.md index ee1d764..b954d05 100644 --- a/doc/install/macos_container_install.md +++ b/doc/install/macos_container_install.md @@ -148,12 +148,13 @@ That's it — you're running Klammertext. ## Editor support (Emacs, Sublime Text) Editing Klammertext is nicer with editor support: syntax highlighting, -delimiter matching, and indentation for Emacs and Sublime Text. It is not +delimiter matching, indentation, table alignment, and diagnostics for +Emacs, Sublime Text, Vim, and Visual Studio Code. It is not inside the container image — it belongs on your Mac, next to your editor. Download it from either place: -- — unpacks to `emacs/` and - `sublime/` folders +- — unpacks to `emacs/`, + `sublime/`, `vim/`, and `vscode/` folders, each self-contained - the Klammertext source repository, , directory `doc/edit/` diff --git a/tst/editor/ls_test.py b/tst/editor/ls_test.py index e782425..fb3211e 100644 --- a/tst/editor/ls_test.py +++ b/tst/editor/ls_test.py @@ -211,6 +211,33 @@ def main(): 'position': {'line': 0, 'character': 4}}) check('documentHighlight off-delimiter is null', off is None, repr(off)) + # -- the custom matchInfo request (drives the VS Code decorations) -- + hl2 = client.request('textDocument/documentHighlight', + {'textDocument': {'uri': uri}, + 'position': {'line': 0, 'character': 7}}) + check('documentHighlight from the bare close', + hl2 is not None and len(hl2) == 2, repr(hl2)) + mi = client.request('klammertext/matchInfo', + {'textDocument': {'uri': uri}, + 'position': {'line': 0, 'character': 7}}) + check('matchInfo from the bare close', + mi is not None and not mi['mismatch'] + and mi['matchToken']['start']['character'] == 0, repr(mi)) + client.notify('textDocument/didChange', + {'textDocument': {'uri': uri, 'version': 6}, + 'contentChanges': [{'text': '@ol x ul@\n'}]}) + client.wait_notification('textDocument/publishDiagnostics') + mi = client.request('klammertext/matchInfo', + {'textDocument': {'uri': uri}, + 'position': {'line': 0, 'character': 0}}) + check('matchInfo reports a mismatch', + mi is not None and mi['mismatch'] and 'ul@' in (mi['message'] or ''), + repr(mi)) + mi = client.request('klammertext/matchInfo', + {'textDocument': {'uri': uri}, + 'position': {'line': 0, 'character': 4}}) + check('matchInfo off-delimiter is null', mi is None, repr(mi)) + # -- alignTable via executeCommand -> applyEdit, on every align fixture -- align_fixtures = ['align_mixed', 'align_empty_cells', 'align_boundary', 'align_colspan', 'align_escapes'] diff --git a/tst/editor/vscode_ext_test.js b/tst/editor/vscode_ext_test.js index 2815d09..ab92c49 100644 --- a/tst/editor/vscode_ext_test.js +++ b/tst/editor/vscode_ext_test.js @@ -59,7 +59,8 @@ class WorkspaceEdit { replace(uri, range, newText) { this.edits.push({ uri, range, newText }); } } -const listeners = { open: [], change: [], close: [] }; +const listeners = { open: [], change: [], close: [], selection: [] }; +const decorationTypes = []; // in creation order: match, mismatch const providers = {}; const commands = {}; const collections = {}; @@ -87,10 +88,20 @@ const vscodeStub = { onDidCloseTextDocument: (fn) => { listeners.close.push(fn); return { dispose() {} }; }, applyEdit: (we) => { appliedEdits.push(we); return Promise.resolve(true); }, }, + ThemeColor: class ThemeColor { + constructor(id) { this.id = id; } + }, window: { createOutputChannel: () => ({ append() {}, dispose() {} }), showWarningMessage: (m) => { statusMessages.push(m); }, setStatusBarMessage: (m) => { statusMessages.push(m); }, + createTextEditorDecorationType: (opts) => { + const t = { opts, dispose() {} }; + decorationTypes.push(t); + return t; + }, + onDidChangeTextEditorSelection: (fn) => { listeners.selection.push(fn); return { dispose() {} }; }, + onDidChangeActiveTextEditor: () => ({ dispose() {} }), activeTextEditor: null, }, languages: { @@ -199,6 +210,27 @@ async function main() { check('jumpToMatch cursor at close', sel.active.character === 7, JSON.stringify(sel)); + // live decorations: a bare close highlights its pair from any position. + const [matchType, mismatchType] = decorationTypes; + const editor = vscodeStub.window.activeTextEditor; + editor.decorations = new Map(); + editor.setDecorations = (type, ranges) => editor.decorations.set(type, ranges); + editor.selection = new Selection(new Position(0, 7), new Position(0, 7)); + listeners.selection.forEach((fn) => fn({ textEditor: editor })); + await waitFor(() => (editor.decorations.get(matchType) || []).length === 2, + 'match decorations'); + check('decorations: bare close boxes the pair', true); + + // mismatch: red decoration type, match type cleared + setDocText('@ol x ul@\n'); + editor.selection = new Selection(new Position(0, 0), new Position(0, 0)); + listeners.selection.forEach((fn) => fn({ textEditor: editor })); + await waitFor(() => (editor.decorations.get(mismatchType) || []).length === 2, + 'mismatch decorations'); + check('decorations: mismatch in the red type', + (editor.decorations.get(matchType) || []).length === 0, + JSON.stringify([...editor.decorations.values()])); + // alignTable round-trips through workspace/applyEdit. const src = fs.readFileSync(path.join(fixDir, 'align_mixed.kt'), 'utf8'); const exp = fs.readFileSync(path.join(fixDir, 'align_mixed_expected.kt'), 'utf8');