VS Code: decoration-based matching, Ctrl+K bindings, README overhaul (from dev f84517152b7f)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 17:02:41 +02:00
parent 2eb16791d2
commit f84603ee19
10 changed files with 255 additions and 39 deletions

View File

@@ -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 Report problems (or send patches) to the author; accepted changes are
applied to the development tree and appear in a following snapshot. 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 ## License

View File

@@ -283,6 +283,26 @@ class Server:
'kind': 1}) 'kind': 1})
self.reply(msg_id, highlights) 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): def on_textDocument_definition(self, msg_id, params):
uri = params['textDocument']['uri'] uri = params['textDocument']['uri']
text = self.docs.get(uri, '') text = self.docs.get(uri, '')

View File

@@ -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 Kotlin extension, the two will contend for `.kt` and you can decide per
file with the language-mode picker (or `files.associations`). 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 **Syntax highlighting** — the same token classes as the Emacs, Sublime
Text, and Vim support: text removal (`#`, `##`, nestable `#[ ... ]#`), the 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` opens bright and closes darker), add `editor.tokenColorCustomizations`
rules for the `*.klammertext` scopes in your settings. rules for the `*.klammertext` scopes in your settings.
**Diagnostics** — unclosed and mismatched delimiters appear in the **Structural reindentation** — Format Document and Format Selection
Problems panel as you type. 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
**Formatting****Format Document** / **Format Selection** reindent column; `@document` content stays at the margin; verbatim `@code`
structurally (2 spaces per nesting level; bar runs and closing delimiters interiors, `@eval` code, and removed text are never touched.
sit at their opener's column; `@document` content stays at the margin; Reindentation is **explicit-only**: there is deliberately no
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. format-on-type, because whitespace is content in Klammertext.
**Delimiter matching** — with the cursor on an application delimiter, the **Delimiter matching** — with the cursor on an application delimiter
matching delimiter highlights (occurrences highlighting); **Go to (opening `@name`, named close `name@`, or a bare `@` close), the delimiter
Definition** on a delimiter goes to its match. Literal klammers match by and its match are boxed; a mismatched named close or an unbalanced
name (`@code``code@`) with their verbatim content opaque; everything delimiter is boxed in **red** with a status-bar message, as in the Emacs,
else matches by depth. 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 | **Table alignment** — pads the cells of the `@table` enclosing the cursor
|---|---| so the `|` separators line up, with the rules shared across the editors:
| `Ctrl+Alt+J` (`Cmd+Alt+J`) | Klammertext: Jump to Matching Delimiter | rows end with `||`; a row with a cell over 30 characters or spanning lines
| `Ctrl+Alt+A` (`Cmd+Alt+A`) | Klammertext: Align Table | 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 **Text removal** — the Toggle Comment commands are VS Code's names; in
`|` separators line up, with the shared rules: rows end with `||`; a row Klammertext they toggle `#` line removal and `#[ ... ]#` block removal
with a cell over 30 characters or spanning lines is left untouched; beyond (the `#` does not "comment out": it removes text from processing).
100 columns the command declines; bars inside a nested klammer are not
separators; no whitespace is ever inserted inside a bar run.
**Text removal toggling**`Ctrl+/` toggles `#` line removal and **Keybinding notes** — each Klammertext command has two bindings because
`Shift+Alt+A` wraps the selection in `#[ ... ]#`, via the standard VS Code some environments never deliver `Ctrl+Alt+letter` chords to VS Code (a
comment commands. 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 ## 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) | | Setting | Meaning (default) |
|---|---| |---|---|
| `klammertext.pythonPath` | Python interpreter for the server (`python3`) | | `klammertext.pythonPath` | Python interpreter for the server (`python3`) |
| `klammertext.serverPath` | full path to `klammertext_ls.py` (auto-located) | | `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.

View File

@@ -167,6 +167,11 @@ function activate(context) {
} }
const python = vscode.workspace.getConfiguration('klammertext').get('pythonPath') || 'python3'; const python = vscode.workspace.getConfiguration('klammertext').get('pythonPath') || 'python3';
client = new LspClient(python, [serverPath], log); 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'); const diagnostics = vscode.languages.createDiagnosticCollection('klammertext');
context.subscriptions.push(diagnostics, output); 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 -- // -- commands --
context.subscriptions.push( context.subscriptions.push(
vscode.commands.registerCommand('klammertext.jumpToMatch', () => { vscode.commands.registerCommand('klammertext.jumpToMatch', () => {

View File

@@ -1,11 +1,18 @@
{ {
"comments": { "comments": {
"lineComment": "#", "lineComment": "#",
"blockComment": ["#[", "]#"] "blockComment": [
"#[",
"]#"
]
}, },
"brackets": [ "brackets": [
["#[", "]#"] [
"#[",
"]#"
]
], ],
"autoClosingPairs": [], "autoClosingPairs": [],
"surroundingPairs": [] "surroundingPairs": [],
"wordPattern": "@{1,3}[A-Za-z0-9_]+|[A-Za-z0-9_]+@{1,3}|@{1,3}|[A-Za-z0-9_]+"
} }

View File

@@ -2,7 +2,7 @@
"name": "klammertext", "name": "klammertext",
"displayName": "Klammertext", "displayName": "Klammertext",
"description": "Klammertext language support: syntax highlighting, delimiter matching, structural reindentation, table alignment, and delimiter diagnostics.", "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", "publisher": "klammertext",
"license": "SEE LICENSE IN THE KLAMMERTEXT DISTRIBUTION", "license": "SEE LICENSE IN THE KLAMMERTEXT DISTRIBUTION",
"engines": { "engines": {
@@ -53,12 +53,24 @@
} }
], ],
"keybindings": [ "keybindings": [
{
"command": "klammertext.jumpToMatch",
"key": "ctrl+k j",
"mac": "cmd+k j",
"when": "editorTextFocus && editorLangId == klammertext"
},
{ {
"command": "klammertext.jumpToMatch", "command": "klammertext.jumpToMatch",
"key": "ctrl+alt+j", "key": "ctrl+alt+j",
"mac": "cmd+alt+j", "mac": "cmd+alt+j",
"when": "editorTextFocus && editorLangId == klammertext" "when": "editorTextFocus && editorLangId == klammertext"
}, },
{
"command": "klammertext.alignTable",
"key": "ctrl+k a",
"mac": "cmd+k a",
"when": "editorTextFocus && editorLangId == klammertext"
},
{ {
"command": "klammertext.alignTable", "command": "klammertext.alignTable",
"key": "ctrl+alt+a", "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/." "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"
}
]
} }
} }
} }

View File

@@ -114,12 +114,13 @@ primitive klammers (`@read`, `@eval`, `@cond`), use `-k none`.
## Editor support (Emacs, Sublime Text) ## Editor support (Emacs, Sublime Text)
Editing Klammertext is nicer with editor support: syntax highlighting, 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. inside the container image — it belongs on your machine, next to your editor.
Download it from either place: Download it from either place:
- <https://andykopra.com/Klammertext_editing.zip> — unpacks to `emacs/` and - <https://andykopra.com/Klammertext_editing.zip> — unpacks to `emacs/`,
`sublime/` folders `sublime/`, `vim/`, and `vscode/` folders, each self-contained
- the Klammertext source repository, - the Klammertext source repository,
<https://git.andykopra.com/ack/klammertext>, directory `doc/edit/` <https://git.andykopra.com/ack/klammertext>, directory `doc/edit/`

View File

@@ -148,12 +148,13 @@ That's it — you're running Klammertext.
## Editor support (Emacs, Sublime Text) ## Editor support (Emacs, Sublime Text)
Editing Klammertext is nicer with editor support: syntax highlighting, 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. inside the container image — it belongs on your Mac, next to your editor.
Download it from either place: Download it from either place:
- <https://andykopra.com/Klammertext_editing.zip> — unpacks to `emacs/` and - <https://andykopra.com/Klammertext_editing.zip> — unpacks to `emacs/`,
`sublime/` folders `sublime/`, `vim/`, and `vscode/` folders, each self-contained
- the Klammertext source repository, - the Klammertext source repository,
<https://git.andykopra.com/ack/klammertext>, directory `doc/edit/` <https://git.andykopra.com/ack/klammertext>, directory `doc/edit/`

View File

@@ -211,6 +211,33 @@ def main():
'position': {'line': 0, 'character': 4}}) 'position': {'line': 0, 'character': 4}})
check('documentHighlight off-delimiter is null', off is None, repr(off)) 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 -- # -- alignTable via executeCommand -> applyEdit, on every align fixture --
align_fixtures = ['align_mixed', 'align_empty_cells', 'align_boundary', align_fixtures = ['align_mixed', 'align_empty_cells', 'align_boundary',
'align_colspan', 'align_escapes'] 'align_colspan', 'align_escapes']

View File

@@ -59,7 +59,8 @@ class WorkspaceEdit {
replace(uri, range, newText) { this.edits.push({ uri, range, newText }); } 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 providers = {};
const commands = {}; const commands = {};
const collections = {}; const collections = {};
@@ -87,10 +88,20 @@ const vscodeStub = {
onDidCloseTextDocument: (fn) => { listeners.close.push(fn); return { dispose() {} }; }, onDidCloseTextDocument: (fn) => { listeners.close.push(fn); return { dispose() {} }; },
applyEdit: (we) => { appliedEdits.push(we); return Promise.resolve(true); }, applyEdit: (we) => { appliedEdits.push(we); return Promise.resolve(true); },
}, },
ThemeColor: class ThemeColor {
constructor(id) { this.id = id; }
},
window: { window: {
createOutputChannel: () => ({ append() {}, dispose() {} }), createOutputChannel: () => ({ append() {}, dispose() {} }),
showWarningMessage: (m) => { statusMessages.push(m); }, showWarningMessage: (m) => { statusMessages.push(m); },
setStatusBarMessage: (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, activeTextEditor: null,
}, },
languages: { languages: {
@@ -199,6 +210,27 @@ async function main() {
check('jumpToMatch cursor at close', sel.active.character === 7, check('jumpToMatch cursor at close', sel.active.character === 7,
JSON.stringify(sel)); 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. // alignTable round-trips through workspace/applyEdit.
const src = fs.readFileSync(path.join(fixDir, 'align_mixed.kt'), 'utf8'); const src = fs.readFileSync(path.join(fixDir, 'align_mixed.kt'), 'utf8');
const exp = fs.readFileSync(path.join(fixDir, 'align_mixed_expected.kt'), 'utf8'); const exp = fs.readFileSync(path.join(fixDir, 'align_mixed_expected.kt'), 'utf8');