Editor support generalized: shared core, language server, Vim and VS Code (from dev eb5baf9cbe59)
doc/edit/ now holds a shared Python implementation of the language's structural layer (klammertext_edit.py) and a dependency-free language server (klammertext_ls.py), with integrations for Emacs, Sublime Text, Vim, and Visual Studio Code. The editor test suite in tst/ covers the core's API and CLI, the language server protocol, the VS Code extension, headless Vim, and Emacs byte-equality. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
85
doc/edit/README.md
Normal file
85
doc/edit/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Klammertext editor support
|
||||
|
||||
Editing support for Klammertext files (`.kt` documents and `.k` klammer
|
||||
definitions) in four editors, built around one shared implementation.
|
||||
|
||||
| Directory | Contents |
|
||||
|---|---|
|
||||
| `shared/` | **The shared editor core** (`klammertext_edit.py`) and the **Klammertext language server** (`klammertext_ls.py`). Everything structure-aware — indentation, table alignment, delimiter matching, delimiter diagnostics — implemented once, in dependency-free Python. |
|
||||
| `emacs/` | Emacs major mode. An independent elisp implementation of the same algorithms (Emacs cannot call Python per keystroke), held byte-equal to the shared core by the test suite. |
|
||||
| `sublime/` | Sublime Text package. Syntax file plus thin plugin wrappers that import the shared core directly (Sublime's plugin host is Python). |
|
||||
| `vim/` | Vim plugin. Syntax/ftplugin files plus commands that run the shared core's CLI; with `+python3`, the core also runs in-process (`=` operator, live match highlighting). |
|
||||
| `vscode/` | Visual Studio Code extension. TextMate grammar plus a dependency-free extension that spawns the language server and speaks LSP to it. |
|
||||
|
||||
## The architecture
|
||||
|
||||
Klammertext's *structural layer* — @-run tiers, bar-run dimension,
|
||||
`^`-escapes, `#` removal, literal spans, nesting depth — is independent of
|
||||
both the Klammermachine and any klammer set, and every editor needs the
|
||||
same operations on it. Those operations live in
|
||||
**`shared/klammertext_edit.py`**:
|
||||
|
||||
- **Indentation** — 2 spaces per nesting level; bar runs and closing
|
||||
delimiters sit at their opener's column; `@document` is transparent;
|
||||
verbatim/`@eval`/removed content is never touched. Explicit-only in
|
||||
every editor: whitespace is content in Klammertext.
|
||||
- **Table alignment** — pad a `@table`'s rows so the depth-0 `|`
|
||||
separators line up (rows end with `||`; cells over 30 characters or
|
||||
spanning lines opt their row out; over 100 columns the command declines;
|
||||
never a space inside a bar run).
|
||||
- **Delimiter matching** — open ↔ close for klammer applications, literal
|
||||
klammers matched by name with opaque content, everything else by depth.
|
||||
- **Diagnostics** — whole-buffer balance check over all three `@`-tiers:
|
||||
unclosed openings, extra closes, name and tier mismatches, unclosed
|
||||
literal spans and `#[` blocks.
|
||||
- **A CLI** (`indent | align | match | check` over stdin/stdout) for
|
||||
editors that shell out (Vim), and for scripts.
|
||||
|
||||
**`shared/klammertext_ls.py`** puts a language server in front of the same
|
||||
core: JSON-RPC over stdio, no dependencies. It serves publishDiagnostics,
|
||||
document/range formatting (reindentation), documentHighlight and
|
||||
definition (the matcher), and a `klammertext.alignTable` command. The
|
||||
VS Code extension is its first client; any LSP client works — Neovim's
|
||||
built-in LSP, Emacs eglot, Sublime's LSP package — with a one-line
|
||||
configuration pointing at `python3 klammertext_ls.py`.
|
||||
|
||||
Syntax highlighting and cursor-latency features stay native in each editor
|
||||
(a tokenizer or a per-keystroke matcher cannot round-trip to Python), so
|
||||
each editor directory carries its own syntax artifact and, where relevant,
|
||||
its own thin glue.
|
||||
|
||||
## Locating the shared core
|
||||
|
||||
The Sublime, Vim, and VS Code integrations find `klammertext_edit.py` (and
|
||||
VS Code additionally `klammertext_ls.py`) in this order: a copy at the
|
||||
integration's own root (the **editing zip** vendors one there, so each
|
||||
unpacked folder is self-contained), `../shared/` relative to the
|
||||
integration (this repository's layout — using an editor directory straight
|
||||
from a checkout just works), then `$KLAMMERTEXT_HOME/doc/edit/shared/`.
|
||||
|
||||
## Keeping things in sync
|
||||
|
||||
`klammertext_edit.py` is the source of truth for the policy lists
|
||||
(`LITERAL_KLAMMERS`, `TRANSPARENT_KLAMMERS`, `CODE_KLAMMERS`,
|
||||
`ALIGN_KLAMMERS`) and limits (`INDENT_OFFSET`, `CELL_MAX`, `ROW_MAX`).
|
||||
Two kinds of artifact cannot read it and restate parts of it by hand:
|
||||
|
||||
1. **The Emacs mode** — a full independent implementation with its own
|
||||
defcustoms, checked byte-for-byte against the shared core by
|
||||
`tst/editor_test.sh`.
|
||||
2. **The static syntax files** — the literal-klammer set (`@code`) appears
|
||||
in the Sublime `.sublime-syntax`, the Vim `syntax/klammertext.vim`, and
|
||||
the VS Code `tmLanguage.json` grammar (and the Emacs defcustom). When
|
||||
you add a literal klammer, change them together; each file's header
|
||||
carries the same SYNC note.
|
||||
|
||||
## Testing
|
||||
|
||||
`tst/editor_test.sh` (run by `dbg/rebuild.sh` and `make -C tst test`)
|
||||
drives the fixture pairs in `tst/editor/` through the shared core's API
|
||||
and CLI, checks idempotence, checks the Emacs implementation for
|
||||
byte-equality, runs the language server through a scripted LSP client
|
||||
(`ls_test.py`), exercises the VS Code extension against the real server
|
||||
under a stubbed VS Code API (`vscode_ext_test.js`), and runs the Vim
|
||||
plugin's commands headlessly. Emacs, Vim, and Node/VS Code halves skip
|
||||
gracefully where not installed.
|
||||
@@ -14,7 +14,7 @@
|
||||
;; (require 'klammertext-align)
|
||||
;;
|
||||
;; Comment that line out to disable alignment entirely. The Sublime Text
|
||||
;; port doc/sublime/Klammertext_align.py implements the same algorithm —
|
||||
;; port doc/edit/sublime/Klammertext_align.py implements the same algorithm —
|
||||
;; keep the two in step.
|
||||
;;
|
||||
;; Alignment is for SMALL data items (2026-07-27):
|
||||
@@ -42,8 +42,11 @@
|
||||
;; aligned row; run TAB / `indent-region' first if the rows disagree.
|
||||
;;
|
||||
;; SYNC: `klammertext-align-klammers' / `-cell-max' / `-row-max' are
|
||||
;; mirrored as ALIGN_KLAMMERS / CELL_MAX / ROW_MAX in Klammertext_align.py
|
||||
;; (a Sublime plugin cannot read these defcustoms).
|
||||
;; mirrored as ALIGN_KLAMMERS / CELL_MAX / ROW_MAX in the shared Python
|
||||
;; core doc/edit/shared/klammertext_edit.py (the single implementation
|
||||
;; behind the Sublime, Vim, and VS Code integrations and the language
|
||||
;; server; this elisp unit stays independent, held equal by
|
||||
;; tst/editor_test.sh's byte-equality checks).
|
||||
|
||||
;;; Code:
|
||||
|
||||
|
||||
@@ -48,11 +48,14 @@
|
||||
;; Known limitation: a raw @ inside a ^'...'^ literal region would confuse
|
||||
;; the depth scan (the same limitation as the font-lock scanner).
|
||||
;;
|
||||
;; SYNC: the Sublime Text port doc/sublime/Klammertext_indent.py duplicates
|
||||
;; this file's policy (a Sublime plugin cannot read these defcustoms). When
|
||||
;; you change `klammertext-indent-offset', `klammertext-transparent-klammers'
|
||||
;; or `klammertext-code-klammers', mirror the change in that file's
|
||||
;; INDENT_OFFSET / TRANSPARENT_KLAMMERS / CODE_KLAMMERS.
|
||||
;; SYNC: the shared Python core doc/edit/shared/klammertext_edit.py — the
|
||||
;; single implementation behind the Sublime, Vim, and VS Code integrations
|
||||
;; and the language server — carries this file's policy as INDENT_OFFSET /
|
||||
;; TRANSPARENT_KLAMMERS / CODE_KLAMMERS (an elisp defcustom cannot be read
|
||||
;; from Python, so this unit remains an independent implementation, held
|
||||
;; equal by tst/editor_test.sh's byte-equality checks). When you change
|
||||
;; `klammertext-indent-offset', `klammertext-transparent-klammers' or
|
||||
;; `klammertext-code-klammers', mirror the change there.
|
||||
|
||||
;;; Code:
|
||||
|
||||
|
||||
@@ -121,14 +121,17 @@ Register one with `klammertext-add-literal-klammer', e.g. in your init file:
|
||||
:type '(repeat string)
|
||||
:group 'klammertext)
|
||||
|
||||
;; SYNC: the Sublime Text port in doc/sublime/ duplicates this list statically
|
||||
;; (a Sublime syntax/plugin cannot read this Emacs defcustom). When you add or
|
||||
;; remove a literal klammer, mirror it in ALL of:
|
||||
;; * LITERAL_KLAMMERS in doc/sublime/Klammertext.py
|
||||
;; * LITERAL_KLAMMERS in doc/sublime/Klammertext_indent.py
|
||||
;; SYNC: the shared Python core doc/edit/shared/klammertext_edit.py (used by
|
||||
;; the Sublime, Vim, and VS Code integrations and the language server) holds
|
||||
;; this list as LITERAL_KLAMMERS, and the static per-editor syntax files
|
||||
;; restate it (a tokenizer cannot read a defcustom or a Python module). When
|
||||
;; you add or remove a literal klammer, mirror it in ALL of:
|
||||
;; * LITERAL_KLAMMERS in doc/edit/shared/klammertext_edit.py
|
||||
;; * the @NAME literal rule + literal_NAME context in
|
||||
;; doc/sublime/Klammertext.sublime-syntax
|
||||
;; All four are currently seeded with just "code".
|
||||
;; doc/edit/sublime/Klammertext.sublime-syntax
|
||||
;; * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim
|
||||
;; * the @NAME rule in doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
|
||||
;; All are currently seeded with just "code".
|
||||
|
||||
(defun klammertext-add-literal-klammer (name)
|
||||
"Register NAME as a klammer whose literal content must not be interpreted.
|
||||
|
||||
1083
doc/edit/shared/klammertext_edit.py
Normal file
1083
doc/edit/shared/klammertext_edit.py
Normal file
File diff suppressed because it is too large
Load Diff
356
doc/edit/shared/klammertext_ls.py
Normal file
356
doc/edit/shared/klammertext_ls.py
Normal file
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
# klammertext_ls.py
|
||||
#
|
||||
# The Klammertext language server: a small, dependency-free implementation of
|
||||
# the Language Server Protocol (JSON-RPC over stdio) on top of the shared
|
||||
# editor core, doc/edit/shared/klammertext_edit.py. One server, any LSP
|
||||
# client — the VS Code extension (doc/edit/vscode/) spawns it, and Neovim's
|
||||
# built-in LSP, Emacs eglot, or Sublime's LSP package can attach to it with a
|
||||
# one-line configuration (see doc/edit/README.md).
|
||||
#
|
||||
# What it serves:
|
||||
#
|
||||
# textDocument/publishDiagnostics unclosed / mismatched delimiters, on
|
||||
# every open and change
|
||||
# textDocument/formatting reindent the whole document
|
||||
# textDocument/rangeFormatting reindent the selected lines
|
||||
# textDocument/documentHighlight the matching application delimiter for
|
||||
# the cursor position (live match
|
||||
# highlighting in clients that request it
|
||||
# on cursor movement)
|
||||
# textDocument/definition jump-to-match: on an @ delimiter,
|
||||
# "go to definition" goes to its match
|
||||
# workspace/executeCommand klammertext.alignTable — align the
|
||||
# @table enclosing the given position
|
||||
# (applied via workspace/applyEdit)
|
||||
#
|
||||
# Formatting is EXPLICIT-ONLY by design: the server does not implement
|
||||
# on-type formatting, because whitespace is content in Klammertext; nothing
|
||||
# reformats as a side effect of typing.
|
||||
#
|
||||
# Protocol notes: full-text document sync (documents are small); positions in
|
||||
# UTF-16 code units per the LSP default. No external libraries — the framing
|
||||
# and dispatch below are the whole protocol layer.
|
||||
#
|
||||
# Usage: python3 klammertext_ls.py (talks LSP on stdin/stdout)
|
||||
#
|
||||
# The shared core is located next to this file (the repository layout and the
|
||||
# vendored layouts both put the two files side by side), or under
|
||||
# $KLAMMERTEXT_HOME/doc/edit/shared.
|
||||
#
|
||||
# Python floor: 3.8.
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def _import_shared():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates = [here]
|
||||
kh = os.environ.get('KLAMMERTEXT_HOME')
|
||||
if kh:
|
||||
candidates.append(os.path.join(kh, 'doc', 'edit', 'shared'))
|
||||
for d in candidates:
|
||||
if os.path.isfile(os.path.join(d, 'klammertext_edit.py')):
|
||||
if d not in sys.path:
|
||||
sys.path.insert(0, d)
|
||||
break
|
||||
import klammertext_edit
|
||||
return klammertext_edit
|
||||
|
||||
|
||||
KE = _import_shared()
|
||||
|
||||
|
||||
# --- positions: LSP {line, character} (UTF-16) <-> python string offsets ---
|
||||
|
||||
def _utf16_len(s):
|
||||
n = 0
|
||||
for ch in s:
|
||||
n += 2 if ord(ch) > 0xFFFF else 1
|
||||
return n
|
||||
|
||||
|
||||
def pos_to_offset(text, pos):
|
||||
"""Offset in TEXT of LSP position POS ({'line', 'character'}, UTF-16)."""
|
||||
line = pos.get('line', 0)
|
||||
character = pos.get('character', 0)
|
||||
off = 0
|
||||
for _ in range(line):
|
||||
nl = text.find('\n', off)
|
||||
if nl == -1:
|
||||
return len(text)
|
||||
off = nl + 1
|
||||
units = 0
|
||||
n = len(text)
|
||||
while off < n and text[off] != '\n' and units < character:
|
||||
units += 2 if ord(text[off]) > 0xFFFF else 1
|
||||
off += 1
|
||||
return off
|
||||
|
||||
|
||||
def offset_to_pos(text, offset):
|
||||
"""LSP position of character OFFSET in TEXT."""
|
||||
offset = max(0, min(offset, len(text)))
|
||||
line = text.count('\n', 0, offset)
|
||||
bol = text.rfind('\n', 0, offset) + 1
|
||||
return {'line': line, 'character': _utf16_len(text[bol:offset])}
|
||||
|
||||
|
||||
def offsets_to_range(text, start, end):
|
||||
return {'start': offset_to_pos(text, start),
|
||||
'end': offset_to_pos(text, end)}
|
||||
|
||||
|
||||
# --- the server ------------------------------------------------------------
|
||||
|
||||
class Server:
|
||||
|
||||
def __init__(self, instream, outstream):
|
||||
self.instream = instream
|
||||
self.outstream = outstream
|
||||
self.docs = {} # uri -> text
|
||||
self.shutdown_received = False
|
||||
self.running = True
|
||||
self.next_server_id = 1 # ids for server->client requests
|
||||
|
||||
# -- transport --
|
||||
|
||||
def read_message(self):
|
||||
length = None
|
||||
while True:
|
||||
line = self.instream.readline()
|
||||
if not line:
|
||||
return None # EOF
|
||||
line = line.strip()
|
||||
if not line:
|
||||
break # end of headers
|
||||
if line.lower().startswith(b'content-length:'):
|
||||
length = int(line.split(b':', 1)[1])
|
||||
if length is None:
|
||||
return None
|
||||
body = self.instream.read(length)
|
||||
if len(body) < length:
|
||||
return None
|
||||
return json.loads(body.decode('utf-8'))
|
||||
|
||||
def send(self, message):
|
||||
body = json.dumps(message, ensure_ascii=False).encode('utf-8')
|
||||
self.outstream.write(b'Content-Length: ' + str(len(body)).encode()
|
||||
+ b'\r\n\r\n' + body)
|
||||
self.outstream.flush()
|
||||
|
||||
def reply(self, msg_id, result):
|
||||
self.send({'jsonrpc': '2.0', 'id': msg_id, 'result': result})
|
||||
|
||||
def reply_error(self, msg_id, code, message):
|
||||
self.send({'jsonrpc': '2.0', 'id': msg_id,
|
||||
'error': {'code': code, 'message': message}})
|
||||
|
||||
def notify(self, method, params):
|
||||
self.send({'jsonrpc': '2.0', 'method': method, 'params': params})
|
||||
|
||||
def request(self, method, params):
|
||||
"""Server->client request (fire and forget: the client's response is
|
||||
consumed, and ignored, by the main loop)."""
|
||||
self.send({'jsonrpc': '2.0', 'id': 'server-%d' % self.next_server_id,
|
||||
'method': method, 'params': params})
|
||||
self.next_server_id += 1
|
||||
|
||||
# -- diagnostics --
|
||||
|
||||
def publish_diagnostics(self, uri):
|
||||
text = self.docs.get(uri, '')
|
||||
diags = []
|
||||
for p in KE.diagnostics(text):
|
||||
diags.append({
|
||||
'range': offsets_to_range(text, p['start'], p['end']),
|
||||
'severity': 1 if p['severity'] == 'error' else 2,
|
||||
'source': 'klammertext',
|
||||
'message': p['message'],
|
||||
})
|
||||
self.notify('textDocument/publishDiagnostics',
|
||||
{'uri': uri, 'diagnostics': diags})
|
||||
|
||||
# -- dispatch --
|
||||
|
||||
def handle(self, msg):
|
||||
method = msg.get('method')
|
||||
msg_id = msg.get('id')
|
||||
params = msg.get('params') or {}
|
||||
|
||||
if method is None:
|
||||
return # a response to a server->client request
|
||||
|
||||
handler = getattr(self, 'on_' + method.replace('/', '_')
|
||||
.replace('$', 'dollar'), None)
|
||||
if handler is not None:
|
||||
handler(msg_id, params)
|
||||
elif msg_id is not None: # unknown request: MethodNotFound
|
||||
self.reply_error(msg_id, -32601, 'method not found: ' + method)
|
||||
# unknown notification: ignored
|
||||
|
||||
# -- lifecycle --
|
||||
|
||||
def on_initialize(self, msg_id, params):
|
||||
self.reply(msg_id, {
|
||||
'capabilities': {
|
||||
'textDocumentSync': 1, # full
|
||||
'documentFormattingProvider': True,
|
||||
'documentRangeFormattingProvider': True,
|
||||
'documentHighlightProvider': True,
|
||||
'definitionProvider': True,
|
||||
'executeCommandProvider': {
|
||||
'commands': ['klammertext.alignTable'],
|
||||
},
|
||||
},
|
||||
'serverInfo': {'name': 'klammertext-ls'},
|
||||
})
|
||||
|
||||
def on_initialized(self, msg_id, params):
|
||||
pass
|
||||
|
||||
def on_shutdown(self, msg_id, params):
|
||||
self.shutdown_received = True
|
||||
self.reply(msg_id, None)
|
||||
|
||||
def on_exit(self, msg_id, params):
|
||||
self.running = False
|
||||
|
||||
def on_dollar_cancelRequest(self, msg_id, params):
|
||||
pass # requests here are all fast
|
||||
|
||||
# -- document sync (full text) --
|
||||
|
||||
def on_textDocument_didOpen(self, msg_id, params):
|
||||
doc = params['textDocument']
|
||||
self.docs[doc['uri']] = doc.get('text', '')
|
||||
self.publish_diagnostics(doc['uri'])
|
||||
|
||||
def on_textDocument_didChange(self, msg_id, params):
|
||||
uri = params['textDocument']['uri']
|
||||
changes = params.get('contentChanges') or []
|
||||
if changes:
|
||||
self.docs[uri] = changes[-1].get('text', '')
|
||||
self.publish_diagnostics(uri)
|
||||
|
||||
def on_textDocument_didClose(self, msg_id, params):
|
||||
uri = params['textDocument']['uri']
|
||||
self.docs.pop(uri, None)
|
||||
self.notify('textDocument/publishDiagnostics',
|
||||
{'uri': uri, 'diagnostics': []})
|
||||
|
||||
def on_textDocument_didSave(self, msg_id, params):
|
||||
pass
|
||||
|
||||
# -- formatting (reindentation) --
|
||||
|
||||
def _format_edits(self, text, bols):
|
||||
edits = []
|
||||
for a, b, new in KE.reindent_lines(text, bols):
|
||||
edits.append({'range': offsets_to_range(text, a, b),
|
||||
'newText': new})
|
||||
return edits
|
||||
|
||||
def on_textDocument_formatting(self, msg_id, params):
|
||||
text = self.docs.get(params['textDocument']['uri'], '')
|
||||
self.reply(msg_id, self._format_edits(text, KE.line_beginnings(text)))
|
||||
|
||||
def on_textDocument_rangeFormatting(self, msg_id, params):
|
||||
text = self.docs.get(params['textDocument']['uri'], '')
|
||||
rng = params['range']
|
||||
start = pos_to_offset(text, rng['start'])
|
||||
end = pos_to_offset(text, rng['end'])
|
||||
bols = [b for b in KE.line_beginnings(text)
|
||||
if b <= end and (text.find('\n', b) == -1
|
||||
or text.find('\n', b) >= start)]
|
||||
self.reply(msg_id, self._format_edits(text, bols))
|
||||
|
||||
# -- matching (highlight + jump) --
|
||||
|
||||
def on_textDocument_documentHighlight(self, msg_id, params):
|
||||
text = self.docs.get(params['textDocument']['uri'], '')
|
||||
m = KE.match_at(text, pos_to_offset(text, params['position']))
|
||||
if m is None:
|
||||
self.reply(msg_id, None)
|
||||
return
|
||||
highlights = [{'range': offsets_to_range(text, *m['token']),
|
||||
'kind': 1}]
|
||||
if m['match_token'] is not None:
|
||||
highlights.append({'range': offsets_to_range(text,
|
||||
*m['match_token']),
|
||||
'kind': 1})
|
||||
self.reply(msg_id, highlights)
|
||||
|
||||
def on_textDocument_definition(self, msg_id, params):
|
||||
uri = params['textDocument']['uri']
|
||||
text = self.docs.get(uri, '')
|
||||
m = KE.match_at(text, pos_to_offset(text, params['position']))
|
||||
if m is None or m['match_token'] is None:
|
||||
self.reply(msg_id, None)
|
||||
return
|
||||
self.reply(msg_id, {'uri': uri,
|
||||
'range': offsets_to_range(text,
|
||||
*m['match_token'])})
|
||||
|
||||
# -- commands --
|
||||
|
||||
def on_workspace_executeCommand(self, msg_id, params):
|
||||
command = params.get('command')
|
||||
args = params.get('arguments') or []
|
||||
if command != 'klammertext.alignTable' or not args:
|
||||
self.reply_error(msg_id, -32602,
|
||||
'unknown command or missing arguments')
|
||||
return
|
||||
arg = args[0]
|
||||
uri = arg['uri']
|
||||
text = self.docs.get(uri, '')
|
||||
pos = pos_to_offset(text, arg['position'])
|
||||
span = KE.enclosing_span(text, pos, KE.ALIGN_KLAMMERS)
|
||||
if span is None:
|
||||
self.reply(msg_id, None)
|
||||
self.notify('window/showMessage',
|
||||
{'type': 3, 'message':
|
||||
'Klammertext: the cursor is not inside a table '
|
||||
'klammer (%s)' % ', '.join(
|
||||
'@' + n for n in sorted(KE.ALIGN_KLAMMERS))})
|
||||
return
|
||||
_name, cs, ce = span
|
||||
edits, message = KE.compute_edits(text[cs:ce])
|
||||
self.reply(msg_id, None)
|
||||
if edits:
|
||||
lsp_edits = [{'range': offsets_to_range(text, cs + a, cs + b),
|
||||
'newText': new}
|
||||
for a, b, new in edits]
|
||||
self.request('workspace/applyEdit',
|
||||
{'label': 'Klammertext: align table',
|
||||
'edit': {'changes': {uri: lsp_edits}}})
|
||||
self.notify('window/showMessage',
|
||||
{'type': 3, 'message': 'Klammertext: ' + message})
|
||||
|
||||
# -- main loop --
|
||||
|
||||
def run(self):
|
||||
while self.running:
|
||||
msg = self.read_message()
|
||||
if msg is None:
|
||||
break # EOF or malformed stream
|
||||
try:
|
||||
self.handle(msg)
|
||||
except Exception as e: # a bug must not kill the server
|
||||
if msg.get('id') is not None and 'method' in msg:
|
||||
self.reply_error(msg['id'], -32603,
|
||||
'internal error: %s' % e)
|
||||
sys.stderr.write('klammertext_ls: %s\n' % e)
|
||||
sys.stderr.flush()
|
||||
return 0 if self.shutdown_received else 1
|
||||
|
||||
|
||||
def main():
|
||||
server = Server(sys.stdin.buffer, sys.stdout.buffer)
|
||||
sys.exit(server.run())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,7 +1,8 @@
|
||||
# Klammertext.py
|
||||
#
|
||||
# Sublime Text plugin for klammer APPLICATION (@) delimiters. Two features,
|
||||
# both ports of doc/emacs/klammertext-mode.el, both reusing one matcher:
|
||||
# both counterparts of doc/edit/emacs/klammertext-mode.el, both reusing the
|
||||
# one context-sensitive matcher in the shared core:
|
||||
#
|
||||
# 1. Jump between an opening and its close — the Sublime equivalent of the
|
||||
# Emacs mode's `klammertext-jump-to-match' (bound C-c C-j). Command name
|
||||
@@ -9,465 +10,172 @@
|
||||
#
|
||||
# 2. Live highlighting of the matching delimiter as the caret sits on one —
|
||||
# the equivalent of the Emacs mode's show-paren support. Implemented as a
|
||||
# ViewEventListener (see KlammertextMatchHighlighter at the bottom); no
|
||||
# language server is involved. A mismatched named close or an unbalanced
|
||||
# delimiter is highlighted in red with a status-bar message, mirroring the
|
||||
# Emacs mode's klammertext-mismatch-face + minibuffer report.
|
||||
# ViewEventListener (see KlammertextMatchHighlighter at the bottom). A
|
||||
# mismatched named close or an unbalanced delimiter is highlighted in red
|
||||
# with a status-bar message, mirroring the Emacs mode's
|
||||
# klammertext-mismatch-face + minibuffer report.
|
||||
#
|
||||
# This is the companion to Klammertext.sublime-syntax. The syntax file only
|
||||
# colors tokens; a tokenizer cannot match context-dependent delimiters, so the
|
||||
# jump is implemented here as a TextCommand. The keybinding lives in the
|
||||
# companion Default.sublime-keymap.
|
||||
#
|
||||
# Command name (for keymaps / the command palette): klammertext_jump_to_match
|
||||
# The matcher itself — on-or-just-after caret rule, literal klammers matched
|
||||
# BY NAME with opaque content (@code <-> code@), everything else by depth,
|
||||
# @@/@@@ runs and removed text stepped over — lives in the shared core,
|
||||
# doc/edit/shared/klammertext_edit.py, together with the LITERAL_KLAMMERS
|
||||
# policy list. This file is only the Sublime wrapper.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# What it does (a direct port of the elisp matcher):
|
||||
# * On an opening @name, move to its closing @ or name@.
|
||||
# * On a close (bare @ or name@), move to the opening @name.
|
||||
# * Triggers when the caret is ON the @ or immediately AFTER it (the same
|
||||
# on-or-just-after rule the Emacs command uses).
|
||||
# * Only single-@ APPLICATION delimiters match. @@/@@@ runs, removed text
|
||||
# (#, ##, #[...]#), escaped ^@, and literal-klammer spans (@code ... code@)
|
||||
# are stepped over, exactly as in the Emacs mode. The abbreviated
|
||||
# @name-arg form opens no span.
|
||||
# * Works at every caret when there are multiple selections.
|
||||
# The shared core is located next to this file (a vendored copy — the
|
||||
# installed-package layout produced by doc/make_editing_zip.sh), or in
|
||||
# ../shared (the repository layout), or under $KLAMMERTEXT_HOME.
|
||||
#
|
||||
# Literal klammers (identical to C-c C-j): a @code ... code@ span is opaque.
|
||||
# The general depth scan still steps over such a span WHOLESALE when matching
|
||||
# some OTHER klammer, so verbatim @ inside it never miscount. A literal
|
||||
# klammer's OWN delimiters are matched BY NAME rather than by depth (see
|
||||
# app_match): @code jumps to the next code@, and code@ to the nearest preceding
|
||||
# @code — correct even when the content holds unbalanced @, e.g. @code x @ y
|
||||
# code@. LITERAL_KLAMMERS lists these names; keep it in sync with the '@code'
|
||||
# handling in Klammertext.sublime-syntax.
|
||||
#
|
||||
# LITERAL_KLAMMERS must stay in sync with the literal klammers recognized in
|
||||
# Klammertext.sublime-syntax (seeded there as @code). The Emacs mode keeps this
|
||||
# list in the `klammertext-literal-klammers' defcustom; a plugin has no access
|
||||
# to it, so it is duplicated here.
|
||||
# SYNC: the literal-klammer set in the shared core must agree with the @code
|
||||
# rule + literal_code context in Klammertext.sublime-syntax (a static syntax
|
||||
# file cannot read Python; both are seeded with just 'code').
|
||||
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Klammer names whose content is a literal argument (verbatim interior).
|
||||
#
|
||||
# SYNC: this list is one of four copies that must agree. When you add or
|
||||
# remove a literal klammer, mirror it in all four:
|
||||
# * klammertext-literal-klammers in doc/emacs/klammertext-mode.el (the source
|
||||
# of truth; a Sublime syntax/plugin cannot read that Emacs defcustom)
|
||||
# * LITERAL_KLAMMERS here
|
||||
# * LITERAL_KLAMMERS in Klammertext_indent.py (a deletable unit, so it does
|
||||
# not import from this file)
|
||||
# * the @NAME literal rule + literal_NAME context in Klammertext.sublime-syntax
|
||||
# All four are currently seeded with just "code".
|
||||
LITERAL_KLAMMERS = set(["code"])
|
||||
try:
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
_IN_SUBLIME = True
|
||||
except ImportError: # standalone import outside Sublime Text
|
||||
_IN_SUBLIME = False
|
||||
|
||||
|
||||
# --- pure helpers (operate on the whole buffer as a string) ----------------
|
||||
|
||||
def name_char_p(ch):
|
||||
"""True if CH can be part of a klammer name (letter, digit or _).
|
||||
A hyphen is NOT a name char: @name-arg1 ends the name at the first hyphen."""
|
||||
if ch is None:
|
||||
return False
|
||||
return (('a' <= ch <= 'z') or ('A' <= ch <= 'Z')
|
||||
or ('0' <= ch <= '9') or ch == '_')
|
||||
def _import_shared():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates = [here, os.path.join(os.path.dirname(here), 'shared')]
|
||||
kh = os.environ.get('KLAMMERTEXT_HOME')
|
||||
if kh:
|
||||
candidates.append(os.path.join(kh, 'doc', 'edit', 'shared'))
|
||||
for d in candidates:
|
||||
if os.path.isfile(os.path.join(d, 'klammertext_edit.py')):
|
||||
if d not in sys.path:
|
||||
sys.path.insert(0, d)
|
||||
break
|
||||
import klammertext_edit
|
||||
return klammertext_edit
|
||||
|
||||
|
||||
def escaped_p(s, pos):
|
||||
"""True if the char at POS is escaped by an odd run of ^ before it.
|
||||
In Klammertext ^# and ^@ are literal, so such a char is not a delimiter."""
|
||||
n = 0
|
||||
i = pos - 1
|
||||
while i >= 0 and s[i] == '^':
|
||||
n += 1
|
||||
i -= 1
|
||||
return (n % 2) == 1
|
||||
KE = _import_shared()
|
||||
|
||||
|
||||
def block_end(s, frm):
|
||||
"""Index just after the ]# that closes a #[ block opened at FROM (the index
|
||||
just after the opening #[). Counts nested #[ ... ]#; len(s) if unclosed."""
|
||||
depth = 1
|
||||
i = frm
|
||||
n = len(s)
|
||||
while depth > 0:
|
||||
a = s.find('#[', i)
|
||||
b = s.find(']#', i)
|
||||
if a == -1 and b == -1:
|
||||
return n
|
||||
if b == -1 or (a != -1 and a < b):
|
||||
depth += 1
|
||||
i = a + 2
|
||||
else:
|
||||
depth -= 1
|
||||
i = b + 2
|
||||
return i
|
||||
if _IN_SUBLIME:
|
||||
|
||||
class KlammertextJumpToMatchCommand(sublime_plugin.TextCommand):
|
||||
"""Jump between a klammer application's opening and closing delimiter.
|
||||
Sublime equivalent of the Emacs mode's C-c C-j."""
|
||||
|
||||
def at_run_end(s, pos):
|
||||
"""Index just after the run of @ that begins at POS."""
|
||||
p = pos
|
||||
n = len(s)
|
||||
while p < n and s[p] == '@':
|
||||
p += 1
|
||||
return p
|
||||
def run(self, edit):
|
||||
view = self.view
|
||||
s = view.substr(sublime.Region(0, view.size()))
|
||||
new_regions = []
|
||||
moved = False
|
||||
message = None
|
||||
|
||||
for region in view.sel():
|
||||
m = KE.match_at(s, region.b)
|
||||
if m is None:
|
||||
new_regions.append(region)
|
||||
message = ("point is not on a klammer application "
|
||||
"delimiter (@)")
|
||||
continue
|
||||
if m['match'] is None:
|
||||
new_regions.append(region)
|
||||
message = ("no matching delimiter for this %s klammer"
|
||||
% ("opening" if m['kind'] == 'open'
|
||||
else "closing"))
|
||||
continue
|
||||
new_regions.append(sublime.Region(m['match'], m['match']))
|
||||
moved = True
|
||||
|
||||
def next_app_delim(s, i, limit):
|
||||
"""From index I, find the next single-@ application delimiter before LIMIT.
|
||||
Step over @@/@@@ runs, removed text, literal spans, escaped ^@, and the
|
||||
abbreviated @name-arg form. Return (pos, kind, next_i) with kind 'open' or
|
||||
'close' and next_i the index to resume from, or None when none is found."""
|
||||
n = len(s)
|
||||
if limit is None:
|
||||
limit = n
|
||||
while i < limit:
|
||||
# find next @ or # at or after i (emacs re-search-forward "[@#]")
|
||||
j = i
|
||||
while j < limit and s[j] != '@' and s[j] != '#':
|
||||
j += 1
|
||||
if j >= limit:
|
||||
return None
|
||||
hit = j
|
||||
i = hit + 1 # default: advance past the hit
|
||||
if escaped_p(s, hit): # ^@ / ^# : keep going
|
||||
continue
|
||||
nxt = s[hit + 1] if hit + 1 < n else None
|
||||
if s[hit] == '#': # removal: step over it
|
||||
if nxt == '#':
|
||||
i = n
|
||||
elif nxt == '[':
|
||||
i = block_end(s, hit + 2)
|
||||
elif nxt in ('+', '/', '-'):
|
||||
i = hit + 1
|
||||
else: # to end of line
|
||||
eol = s.find('\n', hit)
|
||||
i = n if eol == -1 else eol
|
||||
continue
|
||||
# s[hit] == '@'
|
||||
if nxt == '@': # @@ / @@@ : step over the run
|
||||
i = at_run_end(s, hit)
|
||||
continue
|
||||
if name_char_p(nxt): # @name : opening?
|
||||
k = hit + 1
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
name = s[hit + 1:k]
|
||||
after = s[k] if k < n else None
|
||||
if name in LITERAL_KLAMMERS: # literal span: skip to its close
|
||||
close = name + '@'
|
||||
idx = s.find(close, k)
|
||||
i = n if idx == -1 else idx + len(close)
|
||||
continue
|
||||
elif after == '-': # @name-arg : opens no span
|
||||
i = k
|
||||
continue
|
||||
view.sel().clear()
|
||||
for r in new_regions:
|
||||
view.sel().add(r)
|
||||
|
||||
if moved:
|
||||
view.show(view.sel()[0].b)
|
||||
elif message:
|
||||
sublime.status_message("Klammertext: " + message)
|
||||
|
||||
def is_enabled(self):
|
||||
# Only meaningful in Klammertext buffers.
|
||||
return self.view.match_selector(0, "text.klammertext")
|
||||
|
||||
# --- live matched-delimiter highlighting (show-paren equivalent) -------
|
||||
|
||||
class KlammertextMatchHighlighter(sublime_plugin.ViewEventListener):
|
||||
"""Highlight the matching klammer application delimiter as the caret
|
||||
sits on one. The Sublime equivalent of the Emacs mode's show-paren
|
||||
support — driven by cursor movement, reusing the shared matcher.
|
||||
|
||||
A matched pair is boxed (region.bluish); a mismatch or unbalanced
|
||||
delimiter is boxed in red (region.redish) with a status-bar message.
|
||||
Both the token under the caret and its match are boxed; the Emacs mode
|
||||
highlights only the single @ character, but boxing the whole
|
||||
@name / name@ reads better here. To highlight only the far delimiter,
|
||||
drop the first region in _update()."""
|
||||
|
||||
MATCH_KEY = 'klammertext_paren_match'
|
||||
MISMATCH_KEY = 'klammertext_paren_mismatch'
|
||||
|
||||
@classmethod
|
||||
def is_applicable(cls, settings):
|
||||
return str(settings.get('syntax', '')).endswith(
|
||||
'Klammertext.sublime-syntax')
|
||||
|
||||
def __init__(self, view):
|
||||
super().__init__(view)
|
||||
self._change_count = -1
|
||||
self._text = ''
|
||||
|
||||
def _buffer(self):
|
||||
# Re-read the buffer only when it has actually changed, so plain
|
||||
# cursor movement over a large file does not re-copy the document.
|
||||
cc = self.view.change_count()
|
||||
if cc != self._change_count:
|
||||
self._text = self.view.substr(
|
||||
sublime.Region(0, self.view.size()))
|
||||
self._change_count = cc
|
||||
return self._text
|
||||
|
||||
def on_selection_modified_async(self):
|
||||
self._update()
|
||||
|
||||
def on_activated_async(self):
|
||||
self._update()
|
||||
|
||||
def _clear(self):
|
||||
self.view.erase_regions(self.MATCH_KEY)
|
||||
self.view.erase_regions(self.MISMATCH_KEY)
|
||||
|
||||
def _update(self):
|
||||
view = self.view
|
||||
sel = view.sel()
|
||||
if len(sel) == 0:
|
||||
self._clear()
|
||||
return
|
||||
s = self._buffer()
|
||||
m = KE.match_at(s, sel[0].b)
|
||||
if m is None:
|
||||
self._clear()
|
||||
return
|
||||
|
||||
regions = [sublime.Region(*m['token'])]
|
||||
if m['match_token'] is not None:
|
||||
regions.append(sublime.Region(*m['match_token']))
|
||||
|
||||
flags = sublime.DRAW_NO_FILL
|
||||
if m['mismatch']:
|
||||
view.erase_regions(self.MATCH_KEY)
|
||||
view.add_regions(self.MISMATCH_KEY, regions,
|
||||
'region.redish', '', flags)
|
||||
if m['message']:
|
||||
sublime.status_message("Klammertext: " + m['message'])
|
||||
else:
|
||||
return (hit, 'open', k)
|
||||
else: # name@ / bare @ : closing
|
||||
return (hit, 'close', hit + 1)
|
||||
return None
|
||||
|
||||
|
||||
def match_forward(s, open_pos):
|
||||
"""OPEN_POS is the @ of an opening application. Return the matching close @
|
||||
index, or None if unbalanced."""
|
||||
n = len(s)
|
||||
i = open_pos + 1
|
||||
while i < n and name_char_p(s[i]): # past the opening name
|
||||
i += 1
|
||||
depth = 1
|
||||
while depth > 0:
|
||||
d = next_app_delim(s, i, None)
|
||||
if d is None:
|
||||
return None
|
||||
pos, kind, nxt = d
|
||||
i = nxt
|
||||
if kind == 'open':
|
||||
depth += 1
|
||||
else:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return pos
|
||||
return None
|
||||
|
||||
|
||||
def match_backward(s, close_pos):
|
||||
"""CLOSE_POS is the @ of a closing application. Return the matching open @
|
||||
index, or None if unbalanced. Scans forward from 0 with a stack."""
|
||||
stack = []
|
||||
i = 0
|
||||
limit = close_pos + 1
|
||||
while True:
|
||||
d = next_app_delim(s, i, limit)
|
||||
if d is None:
|
||||
return None
|
||||
pos, kind, nxt = d
|
||||
i = nxt
|
||||
if kind == 'open':
|
||||
stack.append(pos)
|
||||
else:
|
||||
open_pos = stack.pop() if stack else None
|
||||
if pos == close_pos:
|
||||
return open_pos
|
||||
|
||||
|
||||
def app_delim_info(s, pos):
|
||||
"""If the char at POS is a single-@ application delimiter, return
|
||||
(pos, kind) with kind 'open' or 'close'; else None. The abbreviated
|
||||
@name-arg form (which opens no span) returns None."""
|
||||
n = len(s)
|
||||
if not (0 <= pos < n):
|
||||
return None
|
||||
if s[pos] != '@':
|
||||
return None
|
||||
if pos > 0 and s[pos - 1] == '@':
|
||||
return None
|
||||
if pos + 1 < n and s[pos + 1] == '@':
|
||||
return None
|
||||
if escaped_p(s, pos):
|
||||
return None
|
||||
nxt = s[pos + 1] if pos + 1 < n else None
|
||||
if name_char_p(nxt):
|
||||
k = pos + 1
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
after = s[k] if k < n else None
|
||||
if after == '-':
|
||||
return None
|
||||
return (pos, 'open')
|
||||
return (pos, 'close')
|
||||
|
||||
|
||||
# --- name / mismatch helpers (for the live highlighter) --------------------
|
||||
|
||||
def _name_forward(s, pos):
|
||||
"""Index just past the run of name chars starting at POS."""
|
||||
n = len(s)
|
||||
k = pos
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
return k
|
||||
|
||||
|
||||
def open_name(s, open_pos):
|
||||
"""Name of the opening @name whose @ is at OPEN_POS."""
|
||||
return s[open_pos + 1:_name_forward(s, open_pos + 1)]
|
||||
|
||||
|
||||
def close_name(s, close_pos):
|
||||
"""Name of a named close NAME@ whose @ is at CLOSE_POS, or None for a bare @
|
||||
(including the compact @name@ form, whose name belongs to the opening)."""
|
||||
ns = close_pos
|
||||
while ns > 0 and name_char_p(s[ns - 1]):
|
||||
ns -= 1
|
||||
if ns < close_pos and (ns == 0 or s[ns - 1] != '@'):
|
||||
return s[ns:close_pos]
|
||||
return None
|
||||
|
||||
|
||||
def paren_mismatch(s, open_pos, close_pos):
|
||||
"""True if the pair is unbalanced (either side None) or the named close
|
||||
disagrees with the opening name."""
|
||||
if open_pos is None or close_pos is None:
|
||||
return True
|
||||
cname = close_name(s, close_pos)
|
||||
return cname is not None and cname != open_name(s, open_pos)
|
||||
|
||||
|
||||
def token_region(s, pos, kind):
|
||||
"""(start, end) of the whole delimiter token whose @ is at POS.
|
||||
Opening: @ plus its name. Named close: the name plus @. Bare @: just @."""
|
||||
if kind == 'open':
|
||||
return (pos, _name_forward(s, pos + 1))
|
||||
ns = pos
|
||||
while ns > 0 and name_char_p(s[ns - 1]):
|
||||
ns -= 1
|
||||
if ns < pos and (ns == 0 or s[ns - 1] != '@'):
|
||||
return (ns, pos + 1) # named close NAME@
|
||||
return (pos, pos + 1) # bare @ (or @name@)
|
||||
|
||||
|
||||
# --- matching dispatch: literal klammers by name, others by depth ----------
|
||||
|
||||
def literal_delim_name(s, pos, kind):
|
||||
"""If the application delimiter at POS (kind 'open'/'close') belongs to a
|
||||
literal klammer (name in LITERAL_KLAMMERS), return its name; else None.
|
||||
A literal klammer's @NAME open and NAME@ close are matched by name, not by
|
||||
depth counting, because its content is verbatim."""
|
||||
name = open_name(s, pos) if kind == 'open' else close_name(s, pos)
|
||||
if name and name in LITERAL_KLAMMERS:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def literal_match_forward(s, open_pos, name):
|
||||
"""Index of the @ of the NAME@ that closes the literal @NAME at OPEN_POS, or
|
||||
None. The content is opaque, so search for the literal close string."""
|
||||
start = open_pos + 1 + len(name)
|
||||
idx = s.find(name + '@', start)
|
||||
return idx + len(name) if idx != -1 else None
|
||||
|
||||
|
||||
def literal_match_backward(s, close_pos, name):
|
||||
"""Index of the @ of the @NAME that opens the literal NAME@ whose @ is at
|
||||
CLOSE_POS, or None. Literal spans do not nest, so the nearest preceding
|
||||
real @NAME is the opener (not @@NAME, and not escaped)."""
|
||||
open_str = '@' + name
|
||||
end = close_pos
|
||||
while True:
|
||||
idx = s.rfind(open_str, 0, end)
|
||||
if idx == -1:
|
||||
return None
|
||||
before = s[idx - 1] if idx > 0 else None
|
||||
if before != '@' and not escaped_p(s, idx):
|
||||
return idx
|
||||
end = idx
|
||||
|
||||
|
||||
def app_match(s, pos, kind):
|
||||
"""Matching application delimiter for the delimiter at POS of KIND
|
||||
('open'/'close'), or None. A literal klammer matches by name (@NAME <->
|
||||
NAME@) with content opaque; other klammers match by depth."""
|
||||
lit = literal_delim_name(s, pos, kind)
|
||||
if lit is not None:
|
||||
return (literal_match_forward(s, pos, lit) if kind == 'open'
|
||||
else literal_match_backward(s, pos, lit))
|
||||
return match_forward(s, pos) if kind == 'open' else match_backward(s, pos)
|
||||
|
||||
|
||||
# --- the command -----------------------------------------------------------
|
||||
|
||||
class KlammertextJumpToMatchCommand(sublime_plugin.TextCommand):
|
||||
"""Jump between a klammer application's opening and closing delimiter.
|
||||
Sublime equivalent of the Emacs mode's C-c C-j."""
|
||||
|
||||
def run(self, edit):
|
||||
view = self.view
|
||||
s = view.substr(sublime.Region(0, view.size()))
|
||||
new_regions = []
|
||||
moved = False
|
||||
message = None
|
||||
|
||||
for region in view.sel():
|
||||
p = region.b
|
||||
info = app_delim_info(s, p)
|
||||
if info is None and p > 0:
|
||||
info = app_delim_info(s, p - 1)
|
||||
if info is None:
|
||||
new_regions.append(region)
|
||||
message = "point is not on a klammer application delimiter (@)"
|
||||
continue
|
||||
dpos, kind = info
|
||||
match = app_match(s, dpos, kind)
|
||||
if match is None:
|
||||
new_regions.append(region)
|
||||
message = ("no matching delimiter for this %s klammer"
|
||||
% ("opening" if kind == 'open' else "closing"))
|
||||
continue
|
||||
new_regions.append(sublime.Region(match, match))
|
||||
moved = True
|
||||
|
||||
view.sel().clear()
|
||||
for r in new_regions:
|
||||
view.sel().add(r)
|
||||
|
||||
if moved:
|
||||
view.show(view.sel()[0].b)
|
||||
elif message:
|
||||
sublime.status_message("Klammertext: " + message)
|
||||
|
||||
def is_enabled(self):
|
||||
# Only meaningful in Klammertext buffers.
|
||||
return self.view.match_selector(0, "text.klammertext")
|
||||
|
||||
|
||||
# --- live matched-delimiter highlighting (show-paren equivalent) -----------
|
||||
|
||||
class KlammertextMatchHighlighter(sublime_plugin.ViewEventListener):
|
||||
"""Highlight the matching klammer application delimiter as the caret sits
|
||||
on one. The Sublime equivalent of the Emacs mode's show-paren support —
|
||||
driven by cursor movement, reusing the same context-sensitive matcher.
|
||||
|
||||
A matched pair is boxed (region.bluish); a mismatch or unbalanced delimiter
|
||||
is boxed in red (region.redish) with a status-bar message. Both the token
|
||||
under the caret and its match are boxed; the Emacs mode highlights only the
|
||||
single @ character, but boxing the whole @name / name@ reads better here.
|
||||
To highlight only the far delimiter, drop the first region in _update()."""
|
||||
|
||||
MATCH_KEY = 'klammertext_paren_match'
|
||||
MISMATCH_KEY = 'klammertext_paren_mismatch'
|
||||
|
||||
@classmethod
|
||||
def is_applicable(cls, settings):
|
||||
return str(settings.get('syntax', '')).endswith('Klammertext.sublime-syntax')
|
||||
|
||||
def __init__(self, view):
|
||||
super().__init__(view)
|
||||
self._change_count = -1
|
||||
self._text = ''
|
||||
|
||||
def _buffer(self):
|
||||
# Re-read the buffer only when it has actually changed, so plain cursor
|
||||
# movement over a large file does not re-copy the whole document.
|
||||
cc = self.view.change_count()
|
||||
if cc != self._change_count:
|
||||
self._text = self.view.substr(sublime.Region(0, self.view.size()))
|
||||
self._change_count = cc
|
||||
return self._text
|
||||
|
||||
def on_selection_modified_async(self):
|
||||
self._update()
|
||||
|
||||
def on_activated_async(self):
|
||||
self._update()
|
||||
|
||||
def _clear(self):
|
||||
self.view.erase_regions(self.MATCH_KEY)
|
||||
self.view.erase_regions(self.MISMATCH_KEY)
|
||||
|
||||
def _update(self):
|
||||
view = self.view
|
||||
sel = view.sel()
|
||||
if len(sel) == 0:
|
||||
self._clear()
|
||||
return
|
||||
p = sel[0].b
|
||||
s = self._buffer()
|
||||
|
||||
info = app_delim_info(s, p)
|
||||
if info is None and p > 0:
|
||||
info = app_delim_info(s, p - 1)
|
||||
if info is None:
|
||||
self._clear()
|
||||
return
|
||||
|
||||
dpos, kind = info
|
||||
match = app_match(s, dpos, kind)
|
||||
open_pos = dpos if kind == 'open' else match
|
||||
close_pos = match if kind == 'open' else dpos
|
||||
mism = paren_mismatch(s, open_pos, close_pos)
|
||||
|
||||
regions = [sublime.Region(*token_region(s, dpos, kind))]
|
||||
if match is not None:
|
||||
other_kind = 'close' if kind == 'open' else 'open'
|
||||
regions.append(sublime.Region(*token_region(s, match, other_kind)))
|
||||
|
||||
flags = sublime.DRAW_NO_FILL
|
||||
if mism:
|
||||
view.erase_regions(self.MATCH_KEY)
|
||||
view.add_regions(self.MISMATCH_KEY, regions, 'region.redish', '', flags)
|
||||
if match is None:
|
||||
if kind == 'open':
|
||||
msg = "opening @%s has no matching close" % open_name(s, open_pos)
|
||||
else:
|
||||
msg = "closing delimiter has no matching open"
|
||||
else:
|
||||
msg = ("closing %s@ does not match opening @%s"
|
||||
% (close_name(s, close_pos) or '?', open_name(s, open_pos)))
|
||||
sublime.status_message("Klammertext: " + msg)
|
||||
else:
|
||||
view.erase_regions(self.MISMATCH_KEY)
|
||||
view.add_regions(self.MATCH_KEY, regions, 'region.bluish', '', flags)
|
||||
view.erase_regions(self.MISMATCH_KEY)
|
||||
view.add_regions(self.MATCH_KEY, regions,
|
||||
'region.bluish', '', flags)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Klammertext.sublime-syntax
|
||||
#
|
||||
# Sublime Text syntax highlighting for Klammertext (.kt and .k files).
|
||||
# A port of the Emacs major mode doc/emacs/klammertext-mode.el.
|
||||
# A port of the Emacs major mode doc/edit/emacs/klammertext-mode.el.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# What it highlights (mirrors the Emacs mode's eight token classes):
|
||||
@@ -32,14 +32,18 @@
|
||||
# '@code' rule and the 'literal_code' context below, replacing
|
||||
# code -> foo.
|
||||
#
|
||||
# SYNC: the literal-klammer set is duplicated in three places that
|
||||
# must agree (a .sublime-syntax file is static and cannot read the
|
||||
# Emacs defcustom). When you add or remove one, mirror it in all:
|
||||
# SYNC: the literal-klammer set's source of truth is
|
||||
# LITERAL_KLAMMERS in doc/edit/shared/klammertext_edit.py (the
|
||||
# shared core all the Python-side integrations import). A static
|
||||
# syntax file cannot read it, so when you add or remove one,
|
||||
# mirror it in the per-editor artifacts:
|
||||
# * klammertext-literal-klammers in
|
||||
# doc/emacs/klammertext-mode.el (the source of truth)
|
||||
# * LITERAL_KLAMMERS in Klammertext.py
|
||||
# doc/edit/emacs/klammertext-mode.el
|
||||
# * the @NAME rule + literal_NAME context here
|
||||
# All three are currently seeded with just 'code'.
|
||||
# * the @NAME verbatim region in doc/edit/vim/syntax/klammertext.vim
|
||||
# * the @NAME rule in
|
||||
# doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
|
||||
# All are currently seeded with just 'code'.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# How open vs. close is decided (the same rule the Emacs scanner uses):
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
# Klammertext_align.py
|
||||
#
|
||||
# EXPERIMENTAL. Table alignment for Klammertext files — pads the cells of a
|
||||
# klammer's rows so the | separators line up vertically. Companion to
|
||||
# doc/emacs/klammertext-align.el (the same algorithm; keep the two in step).
|
||||
# This file is a separate unit: delete it (or move it out of the package
|
||||
# folder) to disable alignment entirely.
|
||||
# klammer's rows so the | separators line up vertically. Counterpart of
|
||||
# doc/edit/emacs/klammertext-align.el. This file is a separate unit: delete
|
||||
# it (or move it out of the package folder) to disable alignment entirely.
|
||||
#
|
||||
# Command name (for keymaps / the command palette): klammertext_align_table
|
||||
# Keybinding: Ctrl+Alt+A (in Default.sublime-keymap), scoped to Klammertext
|
||||
@@ -16,389 +15,45 @@
|
||||
# Row 2 | Text | Not as long ||
|
||||
# @
|
||||
#
|
||||
# Alignment is for SMALL data items (2026-07-27):
|
||||
# The algorithm, its rules (rows end with ||; a row with a cell over
|
||||
# CELL_MAX or spanning lines is untouched; beyond ROW_MAX columns nothing
|
||||
# changes; only depth-0 bars are separators; no whitespace ever inside a bar
|
||||
# run), and the policy lists (ALIGN_KLAMMERS, CELL_MAX, ROW_MAX) live in the
|
||||
# shared core, doc/edit/shared/klammertext_edit.py. This file is only the
|
||||
# Sublime command wrapper.
|
||||
#
|
||||
# * A row is one line ending with the row delimiter || (the customary
|
||||
# trailing delimiter; the parser strips one trailing top-level delimiter,
|
||||
# and it keeps every row uniform). The last row may omit the ||.
|
||||
# * A row is LEFT UNTOUCHED when any of its cells is longer than CELL_MAX
|
||||
# (30) characters, or when the row spans lines (a cell with a newline).
|
||||
# Untouched rows do not contribute to the column widths.
|
||||
# * If the aligned rows would exceed ROW_MAX (100) columns, nothing is
|
||||
# changed and the status bar says so — the general case of long rows has
|
||||
# no good answer, so the command declines rather than guessing.
|
||||
#
|
||||
# Cell padding is semantically free: the SKS strips cell content, and no
|
||||
# whitespace is ever inserted inside a bar run (that would turn a || row
|
||||
# separator into an empty | | cell — the load-bearing-whitespace trap).
|
||||
# Bars inside a nested klammer (e.g. @frac 1 | 2 @ in a cell) belong to that
|
||||
# klammer, not the table: only bars at nesting depth 0 within the table span
|
||||
# count, the same depth rule the Klammermachine itself applies to @cond.
|
||||
# Aligned rows adopt the leading whitespace of the first aligned row; run
|
||||
# the reindent command (Ctrl+Alt+I) first if the rows disagree.
|
||||
#
|
||||
# SYNC: ALIGN_KLAMMERS / CELL_MAX / ROW_MAX mirror the Emacs defcustoms
|
||||
# klammertext-align-klammers / -cell-max / -row-max in klammertext-align.el.
|
||||
# LITERAL_KLAMMERS is the same four-way synced list as everywhere else. The
|
||||
# scanning helpers are duplicated from Klammertext.py so this file stays a
|
||||
# deletable unit with no import coupling.
|
||||
# The shared core is located next to this file (a vendored copy — the
|
||||
# installed-package layout produced by doc/make_editing_zip.sh), or in
|
||||
# ../shared (the repository layout), or under $KLAMMERTEXT_HOME.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
_IN_SUBLIME = True
|
||||
except ImportError: # standalone testing outside Sublime Text
|
||||
except ImportError: # standalone import outside Sublime Text
|
||||
_IN_SUBLIME = False
|
||||
|
||||
import bisect
|
||||
|
||||
ALIGN_KLAMMERS = set(["table"])
|
||||
CELL_MAX = 30
|
||||
ROW_MAX = 100
|
||||
LITERAL_KLAMMERS = set(["code"])
|
||||
|
||||
|
||||
# --- pure helpers (duplicated from Klammertext.py; see SYNC note above) -----
|
||||
|
||||
def name_char_p(ch):
|
||||
"""True if CH can be part of a klammer name (letter, digit or _)."""
|
||||
if ch is None:
|
||||
return False
|
||||
return (('a' <= ch <= 'z') or ('A' <= ch <= 'Z')
|
||||
or ('0' <= ch <= '9') or ch == '_')
|
||||
|
||||
|
||||
def escaped_p(s, pos):
|
||||
"""True if the char at POS is escaped by an odd run of ^ before it."""
|
||||
n = 0
|
||||
i = pos - 1
|
||||
while i >= 0 and s[i] == '^':
|
||||
n += 1
|
||||
i -= 1
|
||||
return (n % 2) == 1
|
||||
|
||||
|
||||
def block_end(s, frm):
|
||||
"""Index just after the ]# that closes a #[ block opened at FROM (the index
|
||||
just after the opening #[). Counts nested #[ ... ]#; len(s) if unclosed."""
|
||||
depth = 1
|
||||
i = frm
|
||||
n = len(s)
|
||||
while depth > 0:
|
||||
a = s.find('#[', i)
|
||||
b = s.find(']#', i)
|
||||
if a == -1 and b == -1:
|
||||
return n
|
||||
if b == -1 or (a != -1 and a < b):
|
||||
depth += 1
|
||||
i = a + 2
|
||||
else:
|
||||
depth -= 1
|
||||
i = b + 2
|
||||
return i
|
||||
|
||||
|
||||
def at_run_end(s, pos):
|
||||
"""Index just after the run of @ that begins at POS."""
|
||||
p = pos
|
||||
n = len(s)
|
||||
while p < n and s[p] == '@':
|
||||
p += 1
|
||||
return p
|
||||
|
||||
|
||||
# --- finding the enclosing table span ---------------------------------------
|
||||
|
||||
def enclosing_span(s, pos, names):
|
||||
"""Innermost span of a klammer named in NAMES that contains POS.
|
||||
Return (name, content_start, content_end) with content_start just after
|
||||
the opening @name token and content_end at the start of the closing
|
||||
delimiter token, or None. Scans S from the start with a position stack,
|
||||
stepping over removed text, literal spans, escaped characters, and the
|
||||
abbreviated @name-arg form."""
|
||||
stack = [] # (name, open_token_start, content_start)
|
||||
n = len(s)
|
||||
i = 0
|
||||
while i < n:
|
||||
j = i
|
||||
while j < n and s[j] != '@' and s[j] != '#':
|
||||
j += 1
|
||||
if j >= n:
|
||||
def _import_shared():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates = [here, os.path.join(os.path.dirname(here), 'shared')]
|
||||
kh = os.environ.get('KLAMMERTEXT_HOME')
|
||||
if kh:
|
||||
candidates.append(os.path.join(kh, 'doc', 'edit', 'shared'))
|
||||
for d in candidates:
|
||||
if os.path.isfile(os.path.join(d, 'klammertext_edit.py')):
|
||||
if d not in sys.path:
|
||||
sys.path.insert(0, d)
|
||||
break
|
||||
hit = j
|
||||
i = hit + 1
|
||||
if escaped_p(s, hit):
|
||||
continue
|
||||
nxt = s[hit + 1] if hit + 1 < n else None
|
||||
if s[hit] == '#':
|
||||
if nxt == '#':
|
||||
break # ## removes the rest of the buffer
|
||||
elif nxt == '[':
|
||||
i = block_end(s, hit + 2)
|
||||
elif nxt in ('+', '/', '-'):
|
||||
pass
|
||||
else:
|
||||
eol = s.find('\n', hit)
|
||||
i = n if eol == -1 else eol
|
||||
continue
|
||||
run_end = at_run_end(s, hit)
|
||||
run_len = run_end - hit
|
||||
after = s[run_end] if run_end < n else None
|
||||
if name_char_p(after):
|
||||
k = run_end
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
name = s[run_end:k]
|
||||
i = k
|
||||
if run_len == 1 and name in LITERAL_KLAMMERS:
|
||||
idx = s.find(name + '@', k)
|
||||
if idx == -1:
|
||||
break
|
||||
i = idx + len(name) + 1
|
||||
elif run_len == 1 and k < n and s[k] == '-':
|
||||
pass # @name-arg : opens no span
|
||||
else:
|
||||
stack.append((name, hit, k))
|
||||
else:
|
||||
# a close: the token starts at the preceding name run, if any
|
||||
ns = hit
|
||||
while ns > 0 and name_char_p(s[ns - 1]):
|
||||
ns -= 1
|
||||
tok_start = ns if (ns < hit and (ns == 0 or s[ns - 1] != '@')) else hit
|
||||
if stack:
|
||||
name, open_start, content_start = stack.pop()
|
||||
if name in names and open_start <= pos <= run_end:
|
||||
return (name, content_start, tok_start)
|
||||
i = run_end
|
||||
return None
|
||||
import klammertext_edit
|
||||
return klammertext_edit
|
||||
|
||||
|
||||
# --- scanning the span content, line by line --------------------------------
|
||||
KE = _import_shared()
|
||||
|
||||
def scan_lines(content):
|
||||
"""Scan CONTENT (the text of a table span). Return a list of line
|
||||
records, one per line: dicts with start, end (offsets into CONTENT, end
|
||||
excludes the newline), start_depth, end_depth (klammer nesting relative
|
||||
to the span), bars (list of (pos, runlen) for unescaped depth-0 bar
|
||||
runs), blocked (inside removed/verbatim content), comment (a # removes
|
||||
the rest of the line)."""
|
||||
n = len(content)
|
||||
line_starts = [0]
|
||||
for idx, ch in enumerate(content):
|
||||
if ch == '\n':
|
||||
line_starts.append(idx + 1)
|
||||
nlines = len(line_starts)
|
||||
|
||||
def line_index(p):
|
||||
return bisect.bisect_right(line_starts, p) - 1
|
||||
|
||||
lines = [{'start': line_starts[k],
|
||||
'end': (line_starts[k + 1] - 1 if k + 1 < nlines else n),
|
||||
'bars': [], 'blocked': False, 'comment': False,
|
||||
'start_depth': None, 'end_depth': None}
|
||||
for k in range(nlines)]
|
||||
lines[0]['start_depth'] = 0
|
||||
|
||||
def block_range(a, b):
|
||||
"""Mark every line touched by [a, b) as blocked."""
|
||||
last = max(a, b - 1)
|
||||
for k in range(line_index(a), line_index(min(last, n - 1)) + 1):
|
||||
lines[k]['blocked'] = True
|
||||
|
||||
depth = 0
|
||||
i = 0
|
||||
while i < n:
|
||||
j = i
|
||||
while j < n and content[j] not in '@#|\n':
|
||||
j += 1
|
||||
if j >= n:
|
||||
break
|
||||
hit = j
|
||||
i = hit + 1
|
||||
c = content[hit]
|
||||
if c == '\n':
|
||||
k = line_index(hit)
|
||||
lines[k]['end_depth'] = depth
|
||||
if k + 1 < nlines:
|
||||
lines[k + 1]['start_depth'] = depth
|
||||
continue
|
||||
if escaped_p(content, hit):
|
||||
continue
|
||||
nxt = content[hit + 1] if hit + 1 < n else None
|
||||
if c == '#':
|
||||
if nxt == '#':
|
||||
block_range(hit, n)
|
||||
break
|
||||
elif nxt == '[':
|
||||
e = block_end(content, hit + 2)
|
||||
if line_index(max(hit, e - 1)) != line_index(hit):
|
||||
block_range(hit, e)
|
||||
i = e
|
||||
elif nxt in ('+', '/', '-'):
|
||||
pass
|
||||
else: # # to end of line
|
||||
lines[line_index(hit)]['comment'] = True
|
||||
eol = content.find('\n', hit)
|
||||
i = n if eol == -1 else eol
|
||||
continue
|
||||
if c == '|':
|
||||
if hit > 0 and content[hit - 1] == '|':
|
||||
continue # mid-run (after an escaped ^|)
|
||||
k = hit
|
||||
while k < n and content[k] == '|':
|
||||
k += 1
|
||||
if depth == 0:
|
||||
lines[line_index(hit)]['bars'].append((hit, k - hit))
|
||||
i = k
|
||||
continue
|
||||
# '@'
|
||||
run_end = at_run_end(content, hit)
|
||||
run_len = run_end - hit
|
||||
after = content[run_end] if run_end < n else None
|
||||
if name_char_p(after):
|
||||
k = run_end
|
||||
while k < n and name_char_p(content[k]):
|
||||
k += 1
|
||||
name = content[run_end:k]
|
||||
i = k
|
||||
if run_len == 1 and name in LITERAL_KLAMMERS:
|
||||
idx = content.find(name + '@', k)
|
||||
e = n if idx == -1 else idx + len(name) + 1
|
||||
if line_index(max(hit, e - 1)) != line_index(hit):
|
||||
block_range(hit, e)
|
||||
i = e
|
||||
elif run_len == 1 and k < n and content[k] == '-':
|
||||
pass
|
||||
else:
|
||||
depth += 1
|
||||
else:
|
||||
depth = max(0, depth - 1)
|
||||
i = run_end
|
||||
|
||||
for ln in lines:
|
||||
if ln['start_depth'] is None:
|
||||
ln['blocked'] = True
|
||||
if ln['end_depth'] is None:
|
||||
ln['end_depth'] = depth
|
||||
return lines
|
||||
|
||||
|
||||
# --- the alignment ----------------------------------------------------------
|
||||
|
||||
def compute_edits(content):
|
||||
"""Compute the alignment edits for CONTENT (a table span's text).
|
||||
Return (edits, message): edits is a list of (start, end, new_text)
|
||||
triples relative to CONTENT, ascending; message is a status string (a
|
||||
reason when edits is empty)."""
|
||||
lines = scan_lines(content)
|
||||
n = len(content)
|
||||
|
||||
# The last line holding actual content (a row there may omit its ||).
|
||||
last_content = None
|
||||
for k in range(len(lines) - 1, 0, -1):
|
||||
if content[lines[k]['start']:lines[k]['end']].strip():
|
||||
last_content = k
|
||||
break
|
||||
|
||||
rows = [] # (line record, cells, trailing_p)
|
||||
chain_ok = True # a row must START a row: the previous
|
||||
for k in range(1, len(lines)): # content line ended with || (or was the
|
||||
ln = lines[k] # opener / an option line / blank)
|
||||
text = content[ln['start']:ln['end']]
|
||||
stripped = text.strip()
|
||||
if not stripped:
|
||||
continue # blank line: chain unchanged
|
||||
if stripped.startswith(':') and not ln['bars']:
|
||||
continue # option line: chain unchanged
|
||||
row = _parse_row(content, ln, text, chain_ok, k == last_content)
|
||||
trailing = _trailing_rowsep(content, ln)
|
||||
chain_ok = trailing
|
||||
if row is not None:
|
||||
rows.append(row)
|
||||
|
||||
if not rows:
|
||||
return ([], "no alignable rows found")
|
||||
|
||||
widths = []
|
||||
for _ln, cells, _tr in rows:
|
||||
for c_idx, cell in enumerate(cells):
|
||||
if c_idx >= len(widths):
|
||||
widths.append(0)
|
||||
widths[c_idx] = max(widths[c_idx], len(cell))
|
||||
|
||||
indent = ' ' * _indent_width(content, rows[0][0])
|
||||
if ROW_MAX is not None:
|
||||
longest = 0
|
||||
for _ln, cells, trailing in rows:
|
||||
m = len(cells)
|
||||
w = (len(indent) + sum(widths[:m]) + 3 * (m - 1)
|
||||
+ (3 if trailing else 0))
|
||||
longest = max(longest, w)
|
||||
if longest > ROW_MAX:
|
||||
return ([], "aligned rows would be %d characters (limit %d); "
|
||||
"not aligning" % (longest, ROW_MAX))
|
||||
|
||||
edits = []
|
||||
for ln, cells, trailing in rows:
|
||||
parts = [cells[c].ljust(widths[c]) for c in range(len(cells) - 1)]
|
||||
last = cells[-1]
|
||||
if trailing:
|
||||
last = last.ljust(widths[len(cells) - 1])
|
||||
parts.append(last)
|
||||
new = indent + ' | '.join(parts) + (' ||' if trailing else '')
|
||||
if new != content[ln['start']:ln['end']]:
|
||||
edits.append((ln['start'], ln['end'], new))
|
||||
msg = ("aligned %d rows" % len(rows)) if edits else "already aligned"
|
||||
return (edits, msg)
|
||||
|
||||
|
||||
def _indent_width(content, ln):
|
||||
i = ln['start']
|
||||
while i < ln['end'] and content[i] in ' \t':
|
||||
i += 1
|
||||
return i - ln['start']
|
||||
|
||||
|
||||
def _trailing_rowsep(content, ln):
|
||||
"""True when the line's LAST depth-0 bar run is a || sitting at the end of
|
||||
the line (only whitespace after it)."""
|
||||
if not ln['bars']:
|
||||
return False
|
||||
pos, runlen = ln['bars'][-1]
|
||||
return (runlen == 2
|
||||
and content[pos + 2:ln['end']].strip() == '')
|
||||
|
||||
|
||||
def _parse_row(content, ln, text, chain_ok, is_last_content):
|
||||
"""If the line is an alignable row, return (ln, cells, trailing_p);
|
||||
else None. See the file header for the rules."""
|
||||
if ln['blocked'] or ln['comment'] or not chain_ok:
|
||||
return None
|
||||
if ln['start_depth'] != 0 or ln['end_depth'] != 0:
|
||||
return None
|
||||
if not ln['bars']:
|
||||
return None
|
||||
trailing = _trailing_rowsep(content, ln)
|
||||
singles = ln['bars'][:-1] if trailing else ln['bars']
|
||||
for _pos, runlen in singles:
|
||||
if runlen != 1:
|
||||
return None # a mid-line || (or |||): not one row
|
||||
if not trailing and not is_last_content:
|
||||
return None # row continues onto the next line
|
||||
cell_start = ln['start'] + _indent_width(content, ln)
|
||||
cell_end = ln['bars'][-1][0] if trailing else ln['end']
|
||||
bounds = [cell_start] + [p for p, _r in singles] + [cell_end]
|
||||
cells = []
|
||||
for b_idx in range(len(bounds) - 1):
|
||||
a = bounds[b_idx] + (1 if b_idx > 0 else 0) # skip the | itself
|
||||
cell = content[a:bounds[b_idx + 1]].strip()
|
||||
if len(cell) > CELL_MAX:
|
||||
return None
|
||||
cells.append(cell)
|
||||
return (ln, cells, trailing)
|
||||
|
||||
|
||||
# --- the command ------------------------------------------------------------
|
||||
|
||||
if _IN_SUBLIME:
|
||||
|
||||
@@ -410,14 +65,15 @@ if _IN_SUBLIME:
|
||||
view = self.view
|
||||
s = view.substr(sublime.Region(0, view.size()))
|
||||
pos = view.sel()[0].b if len(view.sel()) else 0
|
||||
span = enclosing_span(s, pos, ALIGN_KLAMMERS)
|
||||
span = KE.enclosing_span(s, pos, KE.ALIGN_KLAMMERS)
|
||||
if span is None:
|
||||
sublime.status_message(
|
||||
"Klammertext: the caret is not inside a table klammer (%s)"
|
||||
% ", ".join("@" + name for name in sorted(ALIGN_KLAMMERS)))
|
||||
% ", ".join("@" + name
|
||||
for name in sorted(KE.ALIGN_KLAMMERS)))
|
||||
return
|
||||
_name, cs, ce = span
|
||||
edits, msg = compute_edits(s[cs:ce])
|
||||
edits, msg = KE.compute_edits(s[cs:ce])
|
||||
for a, b, new in sorted(edits, reverse=True):
|
||||
view.replace(edit, sublime.Region(cs + a, cs + b), new)
|
||||
sublime.status_message("Klammertext: " + msg)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# Klammertext_indent.py
|
||||
#
|
||||
# EXPERIMENTAL. Reindentation for Klammertext files — the Sublime Text port
|
||||
# of doc/emacs/klammertext-indent.el. This file is a separate unit: delete it
|
||||
# (or move it out of the package folder) to disable indentation entirely; the
|
||||
# rest of the Klammertext package is unaffected.
|
||||
# EXPERIMENTAL. Reindentation for Klammertext files — the Sublime Text
|
||||
# counterpart of doc/edit/emacs/klammertext-indent.el. This file is a
|
||||
# separate unit: delete it (or move it out of the package folder) to disable
|
||||
# indentation entirely; the rest of the Klammertext package is unaffected.
|
||||
#
|
||||
# Command name (for keymaps / the command palette): klammertext_reindent
|
||||
# Keybinding: Ctrl+Alt+I (in Default.sublime-keymap), scoped to Klammertext
|
||||
@@ -11,245 +11,45 @@
|
||||
# line when there is just a caret. Nothing reformats automatically (no
|
||||
# on-Enter auto-indent), because whitespace is content in Klammertext.
|
||||
#
|
||||
# The convention (2026-07-27):
|
||||
# The algorithm, the indentation convention, and the policy lists
|
||||
# (TRANSPARENT_KLAMMERS, CODE_KLAMMERS, INDENT_OFFSET, ...) live in the
|
||||
# shared core, doc/edit/shared/klammertext_edit.py — the single Python
|
||||
# implementation used by the Sublime, Vim, and VS Code integrations and by
|
||||
# the language server. This file is only the Sublime command wrapper.
|
||||
#
|
||||
# @ol <- opener at its context's content column
|
||||
# Item one <- content: opener column + 2
|
||||
# | Item two <- bar run at the OPENER's column ("| " is two
|
||||
# @ol characters, so item text aligns with "Item one")
|
||||
# Embedded item one
|
||||
# | Embedded item two
|
||||
# @ <- close at its opener's column
|
||||
# | Item four
|
||||
# @
|
||||
#
|
||||
# Formal rule: a line indents to offset x (effective depth); a line that
|
||||
# BEGINS with a bar run (|, ||, ...) or a closing delimiter (a bare @-run or
|
||||
# a named close) indents one level less, i.e. to its owner's opening column.
|
||||
# The bar-run rule is dimension-independent: | (list items), || (table rows)
|
||||
# and any longer run all drop to the opener's column. Effective depth counts
|
||||
# every enclosing span uniformly -- applications (@), definitions (@@), and
|
||||
# system commands (@@@) -- with these exceptions:
|
||||
#
|
||||
# * Klammers in TRANSPARENT_KLAMMERS (seeded with "document") contribute no
|
||||
# level, so ordinary paragraphs of a document sit at the left margin.
|
||||
# * Lines inside a literal klammer's verbatim content (@code ... code@) and
|
||||
# inside the argument span of a klammer in CODE_KLAMMERS (seeded with
|
||||
# "eval" -- inline Python is indentation-sensitive!) are NEVER touched.
|
||||
# Removed regions (#[ ... ]#, everything after ##) are likewise left
|
||||
# alone.
|
||||
#
|
||||
# Known limitation (shared with the Emacs scanner): a raw @ inside a ^'...'^
|
||||
# literal region would confuse the depth scan.
|
||||
#
|
||||
# SYNC: the policy lists below must agree with the Emacs side:
|
||||
# * LITERAL_KLAMMERS with klammertext-literal-klammers (also duplicated in
|
||||
# Klammertext.py and Klammertext.sublime-syntax; all seeded "code")
|
||||
# * TRANSPARENT_KLAMMERS with klammertext-transparent-klammers ("document")
|
||||
# * CODE_KLAMMERS with klammertext-code-klammers ("eval")
|
||||
# * INDENT_OFFSET with klammertext-indent-offset (2)
|
||||
# The scanning helpers (name_char_p, escaped_p, block_end, at_run_end) are
|
||||
# duplicated from Klammertext.py so this file stays a deletable unit with no
|
||||
# import coupling.
|
||||
# The shared core is located next to this file (a vendored copy — the
|
||||
# installed-package layout produced by doc/make_editing_zip.sh), or in
|
||||
# ../shared (the repository layout), or under $KLAMMERTEXT_HOME.
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import sublime
|
||||
import sublime_plugin
|
||||
_IN_SUBLIME = True
|
||||
except ImportError: # standalone testing outside Sublime Text
|
||||
except ImportError: # standalone import outside Sublime Text
|
||||
_IN_SUBLIME = False
|
||||
|
||||
INDENT_OFFSET = 2
|
||||
LITERAL_KLAMMERS = set(["code"])
|
||||
TRANSPARENT_KLAMMERS = set(["document"])
|
||||
CODE_KLAMMERS = set(["eval"])
|
||||
|
||||
|
||||
# --- pure helpers (duplicated from Klammertext.py; see SYNC note above) -----
|
||||
|
||||
def name_char_p(ch):
|
||||
"""True if CH can be part of a klammer name (letter, digit or _)."""
|
||||
if ch is None:
|
||||
return False
|
||||
return (('a' <= ch <= 'z') or ('A' <= ch <= 'Z')
|
||||
or ('0' <= ch <= '9') or ch == '_')
|
||||
|
||||
|
||||
def escaped_p(s, pos):
|
||||
"""True if the char at POS is escaped by an odd run of ^ before it."""
|
||||
n = 0
|
||||
i = pos - 1
|
||||
while i >= 0 and s[i] == '^':
|
||||
n += 1
|
||||
i -= 1
|
||||
return (n % 2) == 1
|
||||
|
||||
|
||||
def block_end(s, frm):
|
||||
"""Index just after the ]# that closes a #[ block opened at FROM (the index
|
||||
just after the opening #[). Counts nested #[ ... ]#; len(s) if unclosed."""
|
||||
depth = 1
|
||||
i = frm
|
||||
n = len(s)
|
||||
while depth > 0:
|
||||
a = s.find('#[', i)
|
||||
b = s.find(']#', i)
|
||||
if a == -1 and b == -1:
|
||||
return n
|
||||
if b == -1 or (a != -1 and a < b):
|
||||
depth += 1
|
||||
i = a + 2
|
||||
else:
|
||||
depth -= 1
|
||||
i = b + 2
|
||||
return i
|
||||
|
||||
|
||||
def at_run_end(s, pos):
|
||||
"""Index just after the run of @ that begins at POS."""
|
||||
p = pos
|
||||
n = len(s)
|
||||
while p < n and s[p] == '@':
|
||||
p += 1
|
||||
return p
|
||||
|
||||
|
||||
# --- the depth scanner (port of klammertext-indent--state-at) ---------------
|
||||
|
||||
def state_at(s, pos):
|
||||
"""Scan s[0:POS] (POS a line beginning). Return (stack, opaque): STACK is
|
||||
the list of names of the klammer applications, @@ definitions and @@@
|
||||
commands open at POS, outermost first; OPAQUE is True when POS lies inside
|
||||
content that indentation must not touch (removed text, a literal klammer's
|
||||
verbatim span, or a code klammer's argument span)."""
|
||||
stack = []
|
||||
n = len(s)
|
||||
i = 0
|
||||
while i < pos:
|
||||
j = i
|
||||
while j < pos and s[j] != '@' and s[j] != '#':
|
||||
j += 1
|
||||
if j >= pos:
|
||||
def _import_shared():
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
candidates = [here, os.path.join(os.path.dirname(here), 'shared')]
|
||||
kh = os.environ.get('KLAMMERTEXT_HOME')
|
||||
if kh:
|
||||
candidates.append(os.path.join(kh, 'doc', 'edit', 'shared'))
|
||||
for d in candidates:
|
||||
if os.path.isfile(os.path.join(d, 'klammertext_edit.py')):
|
||||
if d not in sys.path:
|
||||
sys.path.insert(0, d)
|
||||
break
|
||||
hit = j
|
||||
i = hit + 1
|
||||
if escaped_p(s, hit): # ^@ / ^# : plain text
|
||||
continue
|
||||
nxt = s[hit + 1] if hit + 1 < n else None
|
||||
if s[hit] == '#':
|
||||
if nxt == '#': # ## removes to end of buffer
|
||||
return (stack, True)
|
||||
elif nxt == '[': # #[ ... ]# (nestable)
|
||||
end = block_end(s, hit + 2)
|
||||
if pos < end:
|
||||
return (stack, True)
|
||||
i = end
|
||||
elif nxt in ('+', '/', '-'): # whitespace operators
|
||||
pass
|
||||
else: # # to end of line
|
||||
eol = s.find('\n', hit)
|
||||
i = n if eol == -1 else eol
|
||||
continue
|
||||
# an @-run
|
||||
run_end = at_run_end(s, hit)
|
||||
run_len = run_end - hit
|
||||
after = s[run_end] if run_end < n else None
|
||||
if name_char_p(after):
|
||||
# @name / @@name / @@@name : an opener (or, for a literal
|
||||
# klammer, a verbatim span to step over).
|
||||
k = run_end
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
name = s[run_end:k]
|
||||
i = k
|
||||
if run_len == 1 and name in LITERAL_KLAMMERS:
|
||||
# Verbatim interior: find the closing NAME@ by name.
|
||||
idx = s.find(name + '@', k)
|
||||
if idx == -1: # never closed
|
||||
return (stack, True)
|
||||
close_end = idx + len(name) + 1
|
||||
if pos < close_end:
|
||||
return (stack, True)
|
||||
i = close_end
|
||||
elif run_len == 1 and k < n and s[k] == '-':
|
||||
pass # @name-arg : opens no span
|
||||
else:
|
||||
stack.append(name)
|
||||
else:
|
||||
# a bare @-run, or the run of a named close: a close.
|
||||
if stack:
|
||||
stack.pop()
|
||||
i = run_end
|
||||
opaque = any(name in CODE_KLAMMERS for name in stack)
|
||||
return (stack, opaque)
|
||||
import klammertext_edit
|
||||
return klammertext_edit
|
||||
|
||||
|
||||
def depth(stack):
|
||||
"""Number of indentation levels STACK contributes.
|
||||
Transparent klammers contribute none."""
|
||||
return sum(1 for name in stack if name not in TRANSPARENT_KLAMMERS)
|
||||
KE = _import_shared()
|
||||
|
||||
|
||||
def dedent_line_p(s, bol):
|
||||
"""True when the line starting at BOL begins with a token that sits at its
|
||||
owner's opening column: a bar run (|, ||, ...), a bare close run (@, @@,
|
||||
@@@), or a named close (name@, name@@, name@@@). A line beginning with an
|
||||
opener (@name, @@name, @@@name) is content-level."""
|
||||
n = len(s)
|
||||
i = bol
|
||||
while i < n and s[i] in ' \t':
|
||||
i += 1
|
||||
if i >= n:
|
||||
return False
|
||||
c = s[i]
|
||||
if c == '|':
|
||||
return True
|
||||
if c == '@':
|
||||
run_end = at_run_end(s, i)
|
||||
return not name_char_p(s[run_end] if run_end < n else None)
|
||||
if name_char_p(c):
|
||||
# A named close: name chars followed by an @-run (an unescaped @ can
|
||||
# only be a delimiter).
|
||||
k = i
|
||||
while k < n and name_char_p(s[k]):
|
||||
k += 1
|
||||
return k < n and s[k] == '@'
|
||||
return False
|
||||
|
||||
|
||||
def target_column(s, bol):
|
||||
"""Column for the line starting at BOL, or None for lines that must not
|
||||
be touched (verbatim, code, or removed content)."""
|
||||
stack, opaque = state_at(s, bol)
|
||||
if opaque:
|
||||
return None
|
||||
if dedent_line_p(s, bol) and stack:
|
||||
stack = stack[:-1]
|
||||
return INDENT_OFFSET * depth(stack)
|
||||
|
||||
|
||||
def reindent_lines(s, bols):
|
||||
"""Compute the edits that reindent the lines whose beginnings are BOLS.
|
||||
Return a list of (start, end, replacement) triples over S, in ascending
|
||||
order, replacing each line's leading whitespace; untouchable lines and
|
||||
already-correct lines produce no edit. Pure function -- also used by the
|
||||
standalone tests."""
|
||||
n = len(s)
|
||||
edits = []
|
||||
for bol in bols:
|
||||
tgt = target_column(s, bol)
|
||||
if tgt is None:
|
||||
continue
|
||||
i = bol
|
||||
while i < n and s[i] in ' \t':
|
||||
i += 1
|
||||
if s[bol:i] != ' ' * tgt:
|
||||
edits.append((bol, i, ' ' * tgt))
|
||||
return edits
|
||||
|
||||
|
||||
# --- the command ------------------------------------------------------------
|
||||
|
||||
if _IN_SUBLIME:
|
||||
|
||||
class KlammertextReindentCommand(sublime_plugin.TextCommand):
|
||||
@@ -269,7 +69,7 @@ if _IN_SUBLIME:
|
||||
bols.append(line.a)
|
||||
# Compute all edits from the original text, then apply from the
|
||||
# bottom up so earlier offsets stay valid.
|
||||
for a, b, new in sorted(reindent_lines(s, bols), reverse=True):
|
||||
for a, b, new in sorted(KE.reindent_lines(s, bols), reverse=True):
|
||||
view.replace(edit, sublime.Region(a, b), new)
|
||||
|
||||
def is_enabled(self):
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
# Klammertext for Sublime Text
|
||||
|
||||
A Sublime Text port of the Emacs major mode for Klammertext
|
||||
(`doc/emacs/klammertext-mode.el`). It brings syntax highlighting, delimiter
|
||||
matching, and comment toggling to `.kt` and `.k` files. Behavior mirrors the
|
||||
Emacs mode closely; where the two intentionally differ, the file headers say so.
|
||||
A Sublime Text package for Klammertext: syntax highlighting, delimiter
|
||||
matching, comment toggling, reindentation, and table alignment for `.kt` and
|
||||
`.k` files. The structural features run on the **shared editor core**
|
||||
(`klammertext_edit.py`) — the single Python implementation used by the Vim
|
||||
and VS Code integrations and the Klammertext language server — so all the
|
||||
editors behave identically; the plugin files here are Sublime command
|
||||
wrappers. Behavior mirrors the Emacs mode closely (an independent elisp
|
||||
implementation, held equal by the Klammertext test suite); where the two
|
||||
intentionally differ, the file headers say so.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Klammertext.sublime-syntax` | Syntax highlighting. Colors the text-removal constructs (`#`, `##`, `#[...]#`) and the three `@`-tiers — application `@`, definition `@@`, system `@@@` — each as an opening vs. a close, plus `^`-escapes and verbatim `@code ... code@` spans. |
|
||||
| `Klammertext.py` | Plugin with two features that share one context-sensitive matcher: jump between an opening and its close, and live highlighting of the matching delimiter as the caret moves (mismatched or unbalanced delimiters flag in red). |
|
||||
| `Klammertext.py` | Jump between an opening and its close, and live highlighting of the matching delimiter as the caret moves (mismatched or unbalanced delimiters flag in red). Both reuse the shared core's context-sensitive matcher. |
|
||||
| `Klammertext_indent.py` | **Experimental.** Reindentation per the Klammertext convention (see below). A separate unit: delete this one file to disable indentation; nothing else is affected. |
|
||||
| `Klammertext_align.py` | **Experimental.** Table alignment (see below). Also a separate, deletable unit. |
|
||||
| `klammertext_edit.py` | The shared editor core the three plugins import. Ships in the editing zip; when you install from the Klammertext repository instead, copy it in from `doc/edit/shared/` (or leave the package inside the repository tree, where the plugins find `../shared/` themselves). |
|
||||
| `Default.sublime-keymap` | Binds jump-to-match to **Ctrl+M**, reindent to **Ctrl+Alt+I**, and table alignment to **Ctrl+Alt+A**, scoped to Klammertext files. |
|
||||
| `Comments.tmPreferences` | Comment toggling: **Ctrl+/** inserts `# ` (line removal), **Ctrl+Shift+/** wraps in `#[ ... ]#` (block removal). |
|
||||
| `Breakers` / `Celeste` / `Mariana` / `Monokai` / `Sixteen` `.sublime-color-scheme` | Color overrides for Sublime's five built-in schemes — one hue system, full intensity on the dark schemes, scaled down on the light ones. Additive: they recolor only the Klammertext delimiters and leave the rest of each scheme unchanged. |
|
||||
@@ -32,7 +38,10 @@ directory:
|
||||
The quickest way to find it: **Preferences → Browse Packages…** opens the
|
||||
`Packages` directory. Create the `Klammertext` folder there and copy the files
|
||||
in. Sublime loads them live — no restart — and applies the syntax to `.kt` and
|
||||
`.k` files automatically.
|
||||
`.k` files automatically. The package must include `klammertext_edit.py`
|
||||
(see the file table above): the editing zip ships it in place; from a
|
||||
repository checkout, copy `doc/edit/shared/klammertext_edit.py` into the
|
||||
folder alongside the plugin files.
|
||||
|
||||
Use a dedicated folder (not `Packages/User/`) so the bundled keymap does not
|
||||
merge into your personal one. If you want highlighting only, the
|
||||
@@ -60,7 +69,7 @@ prefer `super+m` can change it in `Default.sublime-keymap`.
|
||||
## Indentation (experimental)
|
||||
|
||||
`Klammertext_indent.py` ports the Emacs mode's indentation
|
||||
(`doc/emacs/klammertext-indent.el`): **Ctrl+Alt+I** reindents the line(s)
|
||||
(`doc/edit/emacs/klammertext-indent.el`): **Ctrl+Alt+I** reindents the line(s)
|
||||
touched by the selection to reflect the klammer nesting, two spaces per level:
|
||||
|
||||
```
|
||||
@@ -82,8 +91,9 @@ belong to. All three `@`-tiers indent uniformly. Exceptions: `@document`
|
||||
contributes no level (a document's paragraphs stay at the left margin); lines
|
||||
inside verbatim `@code` content, inside `@eval` argument spans (inline Python
|
||||
is indentation-sensitive), and inside removed regions are never touched. The
|
||||
policy lists (`TRANSPARENT_KLAMMERS`, `CODE_KLAMMERS`, `INDENT_OFFSET`) are at
|
||||
the top of `Klammertext_indent.py`, kept in sync with the Emacs defcustoms.
|
||||
policy lists (`TRANSPARENT_KLAMMERS`, `CODE_KLAMMERS`, `INDENT_OFFSET`) are
|
||||
in the shared core (`klammertext_edit.py`), kept in sync with the Emacs
|
||||
defcustoms.
|
||||
|
||||
Sublime's own Reindent (Edit → Line → Reindent) is driven by single-line
|
||||
regex patterns that cannot express Klammertext nesting, so this is a plugin
|
||||
@@ -96,7 +106,7 @@ keep the command available from plugins).
|
||||
## Table alignment (experimental)
|
||||
|
||||
`Klammertext_align.py` ports the Emacs mode's table alignment
|
||||
(`doc/emacs/klammertext-align.el`): **Ctrl+Alt+A** with the caret anywhere
|
||||
(`doc/edit/emacs/klammertext-align.el`): **Ctrl+Alt+A** with the caret anywhere
|
||||
inside a `@table` span pads the cells of its rows so the `|` separators line
|
||||
up:
|
||||
|
||||
@@ -118,8 +128,8 @@ bar run (which would turn a `||` row separator into an empty `| |` cell),
|
||||
and bars inside a nested klammer in a cell (`@frac 1 | 2 @`) belong to that
|
||||
klammer, not the table. Aligned rows adopt the leading whitespace of the
|
||||
first aligned row — run Ctrl+Alt+I first if the rows disagree. The limits
|
||||
sit at the top of `Klammertext_align.py`, mirrored from the Emacs
|
||||
defcustoms.
|
||||
(`ALIGN_KLAMMERS`, `CELL_MAX`, `ROW_MAX`) are in the shared core
|
||||
(`klammertext_edit.py`), mirrored from the Emacs defcustoms.
|
||||
|
||||
## Colors
|
||||
|
||||
@@ -143,25 +153,30 @@ exact values are in each file's header comment.
|
||||
|
||||
## Keeping literal klammers in sync
|
||||
|
||||
Klammers whose content is verbatim (`@code ... code@`) are listed in four
|
||||
places that must agree — a Sublime syntax/plugin cannot read the Emacs
|
||||
defcustom, so the list is duplicated:
|
||||
Klammers whose content is verbatim (`@code ... code@`) are listed in the
|
||||
shared core — `LITERAL_KLAMMERS` in `klammertext_edit.py`, the source of
|
||||
truth — and restated in the static per-editor artifacts, which cannot read
|
||||
Python:
|
||||
|
||||
- `klammertext-literal-klammers` in `doc/emacs/klammertext-mode.el` (the source of truth)
|
||||
- `LITERAL_KLAMMERS` in `Klammertext.py`
|
||||
- `LITERAL_KLAMMERS` in `Klammertext_indent.py`
|
||||
- the `@code` rule and `literal_code` context in `Klammertext.sublime-syntax`
|
||||
- `klammertext-literal-klammers` in `doc/edit/emacs/klammertext-mode.el`
|
||||
(the independent elisp implementation)
|
||||
- the `@code` region in `doc/edit/vim/syntax/klammertext.vim` and the rule
|
||||
in `doc/edit/vscode/syntaxes/klammertext.tmLanguage.json`
|
||||
|
||||
All four are seeded with just `code`. When you add or remove a literal
|
||||
klammer, change all four.
|
||||
All are seeded with just `code`. When you add or remove a literal klammer,
|
||||
change them together.
|
||||
|
||||
## Not included
|
||||
|
||||
Whole-file semantic validation — persistent error underlines when the cursor is
|
||||
elsewhere, klammer-name completion, go-to-definition — is not part of this
|
||||
package. That would need a language server (used through the Sublime LSP
|
||||
package), a separate program, and is unrelated to the highlighting and matching
|
||||
provided here.
|
||||
Whole-file diagnostics (persistent error underlines when the cursor is
|
||||
elsewhere) are not part of this package, but they exist: the **Klammertext
|
||||
language server** (`doc/edit/shared/klammertext_ls.py`, the same program the
|
||||
VS Code extension uses) serves them to Sublime through the community LSP
|
||||
package. Install "LSP" from Package Control and add a client with
|
||||
`command: ["python3", "/path/to/klammertext_ls.py"]` for the
|
||||
`text.klammertext` selector. Everything in this package works the same
|
||||
with or without it.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
117
doc/edit/vim/README.md
Normal file
117
doc/edit/vim/README.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Klammertext support for Vim
|
||||
|
||||
Vim support for editing Klammertext files (`.kt` documents and `.k` klammer
|
||||
definitions): syntax highlighting, delimiter matching and jumping, structural
|
||||
reindentation, table alignment, and a delimiter checker.
|
||||
|
||||
The structure-aware features are thin wrappers around the **shared editor
|
||||
core** (`klammertext_edit.py`) — the single Python implementation of
|
||||
Klammertext's structural layer used by the Sublime Text and VS Code
|
||||
integrations and by the Klammertext language server. The Vim plugin runs it
|
||||
through `python3`; there is no Vim-specific reimplementation to drift out of
|
||||
sync.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Vim 8+ (or Neovim) with `+eval` — any normal Vim; only `vim-tiny` lacks it.
|
||||
- `python3` on `PATH` (Klammertext itself already requires Python).
|
||||
- The shared core, found automatically in this order:
|
||||
1. `g:klammertext_edit_py`, if you set it (a full path to
|
||||
`klammertext_edit.py`);
|
||||
2. a `klammertext_edit.py` vendored at this plugin's root (the layout the
|
||||
Klammertext editing zip ships);
|
||||
3. `../shared/klammertext_edit.py` relative to the plugin directory (the
|
||||
layout of the Klammertext repository — using the plugin straight from a
|
||||
checkout just works);
|
||||
4. `$KLAMMERTEXT_HOME/doc/edit/shared/klammertext_edit.py`.
|
||||
|
||||
## Install
|
||||
|
||||
Copy (or symlink) this `vim/` directory into a Vim native package path,
|
||||
renaming it as you like:
|
||||
|
||||
mkdir -p ~/.vim/pack/klammertext/start
|
||||
cp -R vim ~/.vim/pack/klammertext/start/klammertext
|
||||
|
||||
(Neovim: `~/.local/share/nvim/site/pack/klammertext/start/klammertext`.)
|
||||
If you copy the directory out of the Klammertext tree, also copy
|
||||
`shared/klammertext_edit.py` into the copied folder's root — or set
|
||||
`g:klammertext_edit_py`. The editing zip from the Klammertext website ships
|
||||
the vendored copy already in place.
|
||||
|
||||
`.kt` and `.k` files then get the `klammertext` filetype. **Note:** `.kt`
|
||||
is also Kotlin's extension, and stock Vim maps it to Kotlin; this plugin's
|
||||
`ftdetect` overrides that unconditionally. If you edit both languages, see
|
||||
the comment in `ftdetect/klammertext.vim`.
|
||||
|
||||
## What you get
|
||||
|
||||
**Syntax highlighting** — the same token classes and palette as the Emacs
|
||||
and Sublime Text support: text removal (`#`, `##`, nestable `#[ ... ]#`),
|
||||
the three `@`-tiers — application (`@`, blue), definition (`@@`, green),
|
||||
system (`@@@`, orange) — each as an opening (`@name`) or a close (`name@`,
|
||||
bare `@`), opens bright and closes the same hue darker; `^`-escapes;
|
||||
verbatim `@code ... code@` interiors. All groups are `hi def` — override
|
||||
them with `:highlight` in your vimrc.
|
||||
|
||||
**Commands** (buffer-local; default mappings below):
|
||||
|
||||
| Command | Does |
|
||||
|---|---|
|
||||
| `:[range]KlammertextReindent` | reindent the range (default: whole buffer) |
|
||||
| `:KlammertextAlign` | align the `@table` enclosing the cursor |
|
||||
| `:KlammertextJumpToMatch` | jump between a klammer application's opening and closing delimiter |
|
||||
| `:KlammertextCheck` | list unclosed/mismatched delimiters in the location list |
|
||||
|
||||
Default mappings (buffer-local; suppress them all with
|
||||
`let g:klammertext_no_mappings = 1`): `<LocalLeader>i` reindents the current
|
||||
line (in visual mode, the selection), `<LocalLeader>a` aligns the enclosing
|
||||
table, `<LocalLeader>j` jumps to the matching delimiter. `LocalLeader`
|
||||
defaults to backslash; set `maplocalleader` to taste.
|
||||
|
||||
Reindentation is **explicit-only**: whitespace is content in Klammertext,
|
||||
so nothing reformats as a side effect of typing (`indentkeys` is emptied).
|
||||
Alignment follows 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.
|
||||
|
||||
**With `+python3`** (check `:echo has('python3')`) the shared core also runs
|
||||
in-process: the `=` operator reindents through `'indentexpr'` (`==`, `gg=G`),
|
||||
and the matching delimiter is highlighted live as the cursor sits on one —
|
||||
the show-paren equivalent, with a mismatched or unbalanced delimiter shown
|
||||
in red plus a message. Without `+python3` the commands above still work;
|
||||
they shell out to `python3`.
|
||||
|
||||
**Comment toggling** — `'commentstring'` is set to `# %s`, so Vim 9.1's
|
||||
built-in commenting and Neovim's `gcc`/`gc` (or the commentary plugin)
|
||||
toggle `#` line removal.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Meaning (default) |
|
||||
|---|---|
|
||||
| `g:klammertext_edit_py` | full path to `klammertext_edit.py` (auto-detected) |
|
||||
| `g:klammertext_python` | Python interpreter (`python3`) |
|
||||
| `g:klammertext_no_mappings` | set to define no default mappings |
|
||||
|
||||
## Neovim and the language server
|
||||
|
||||
Neovim users can additionally attach Neovim's built-in LSP client to the
|
||||
Klammertext language server (`klammertext_ls.py`, next to the shared core)
|
||||
for diagnostics as you type, `gq`/format-document reindentation, and
|
||||
cursor-hold match highlighting:
|
||||
|
||||
```lua
|
||||
vim.api.nvim_create_autocmd('FileType', {
|
||||
pattern = 'klammertext',
|
||||
callback = function()
|
||||
vim.lsp.start {
|
||||
name = 'klammertext-ls',
|
||||
cmd = { 'python3', '/path/to/doc/edit/shared/klammertext_ls.py' },
|
||||
}
|
||||
end,
|
||||
})
|
||||
```
|
||||
|
||||
The plugin's own commands work the same with or without it.
|
||||
299
doc/edit/vim/autoload/klammertext.vim
Normal file
299
doc/edit/vim/autoload/klammertext.vim
Normal file
@@ -0,0 +1,299 @@
|
||||
" autoload/klammertext.vim — implementation of the Klammertext commands.
|
||||
"
|
||||
" All the structure-aware work (indentation, table alignment, delimiter
|
||||
" matching, diagnostics) is done by the shared Python core,
|
||||
" klammertext_edit.py — the single implementation used by the Sublime,
|
||||
" Vim, and VS Code integrations and by the language server. This file is
|
||||
" glue: it locates the core, runs it (as a stdin/stdout filter, or
|
||||
" in-process via +python3 where that is faster), and applies the results
|
||||
" to the buffer.
|
||||
"
|
||||
" Configuration:
|
||||
" g:klammertext_edit_py full path to klammertext_edit.py (overrides the
|
||||
" search described below)
|
||||
" g:klammertext_python the Python interpreter (default "python3")
|
||||
|
||||
" --- locating the shared core ---------------------------------------------
|
||||
" Search order: an explicit g:klammertext_edit_py; a vendored copy at this
|
||||
" plugin's root (the layout the editing zip installs); ../shared relative
|
||||
" to the plugin root (the repository layout); $KLAMMERTEXT_HOME.
|
||||
|
||||
let s:plugin_root = expand('<sfile>:p:h:h')
|
||||
|
||||
function! s:EditScript() abort
|
||||
if exists('g:klammertext_edit_py')
|
||||
return g:klammertext_edit_py
|
||||
endif
|
||||
for candidate in [
|
||||
\ s:plugin_root . '/klammertext_edit.py',
|
||||
\ fnamemodify(s:plugin_root, ':h') . '/shared/klammertext_edit.py',
|
||||
\ (empty($KLAMMERTEXT_HOME) ? '' :
|
||||
\ $KLAMMERTEXT_HOME . '/doc/edit/shared/klammertext_edit.py')]
|
||||
if !empty(candidate) && filereadable(candidate)
|
||||
return candidate
|
||||
endif
|
||||
endfor
|
||||
return ''
|
||||
endfunction
|
||||
|
||||
function! s:Python() abort
|
||||
return exists('g:klammertext_python') ? g:klammertext_python : 'python3'
|
||||
endfunction
|
||||
|
||||
" Run the core CLI over the whole buffer. ARGS is the argument string
|
||||
" (e.g. "indent 3-7"). Returns a dict {ok, lines, msg}: LINES is the
|
||||
" transformed buffer (for the filter modes), MSG the stderr message.
|
||||
function! s:RunCore(args) abort
|
||||
let script = s:EditScript()
|
||||
if empty(script)
|
||||
return {'ok': 0, 'lines': [], 'msg':
|
||||
\ 'Klammertext: cannot locate klammertext_edit.py'
|
||||
\ . ' (set g:klammertext_edit_py)'}
|
||||
endif
|
||||
let errfile = tempname()
|
||||
let cmd = s:Python() . ' ' . shellescape(script) . ' ' . a:args
|
||||
\ . ' 2>' . shellescape(errfile)
|
||||
let out = systemlist(cmd, getline(1, '$'))
|
||||
let msg = filereadable(errfile) ? join(readfile(errfile), ' ') : ''
|
||||
call delete(errfile)
|
||||
if v:shell_error
|
||||
return {'ok': 0, 'lines': [], 'msg':
|
||||
\ empty(msg) ? 'Klammertext: the shared core failed' : msg}
|
||||
endif
|
||||
return {'ok': 1, 'lines': out, 'msg': msg}
|
||||
endfunction
|
||||
|
||||
" Replace the buffer with LINES, touching only the lines that changed (so
|
||||
" undo stays small and the cursor does not move). The transformations
|
||||
" never add or remove lines; refuse if the count disagrees.
|
||||
function! s:ApplyLines(lines) abort
|
||||
if len(a:lines) != line('$')
|
||||
echohl ErrorMsg
|
||||
echo 'Klammertext: unexpected line count from the shared core'
|
||||
echohl None
|
||||
return 0
|
||||
endif
|
||||
let changed = 0
|
||||
for i in range(1, line('$'))
|
||||
if getline(i) !=# a:lines[i - 1]
|
||||
call setline(i, a:lines[i - 1])
|
||||
let changed += 1
|
||||
endif
|
||||
endfor
|
||||
return changed
|
||||
endfunction
|
||||
|
||||
function! s:CursorArgs() abort
|
||||
let col = exists('*charcol') ? charcol('.') : col('.')
|
||||
return line('.') . ' ' . col
|
||||
endfunction
|
||||
|
||||
" --- the commands ----------------------------------------------------------
|
||||
|
||||
function! klammertext#Reindent(first, last) abort
|
||||
let r = s:RunCore('indent ' . a:first . '-' . a:last)
|
||||
if !r.ok
|
||||
echohl ErrorMsg | echo r.msg | echohl None
|
||||
return
|
||||
endif
|
||||
call s:ApplyLines(r.lines)
|
||||
endfunction
|
||||
|
||||
function! klammertext#AlignTable() abort
|
||||
let r = s:RunCore('align ' . s:CursorArgs())
|
||||
if !r.ok
|
||||
echohl ErrorMsg | echo r.msg | echohl None
|
||||
return
|
||||
endif
|
||||
call s:ApplyLines(r.lines)
|
||||
if !empty(r.msg)
|
||||
echo 'Klammertext: ' . r.msg
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! klammertext#JumpToMatch() abort
|
||||
let script = s:EditScript()
|
||||
if empty(script)
|
||||
echohl ErrorMsg
|
||||
echo 'Klammertext: cannot locate klammertext_edit.py'
|
||||
echohl None
|
||||
return
|
||||
endif
|
||||
let out = systemlist(s:Python() . ' ' . shellescape(script)
|
||||
\ . ' match ' . s:CursorArgs(), getline(1, '$'))
|
||||
if v:shell_error || empty(out)
|
||||
echohl ErrorMsg | echo 'Klammertext: the shared core failed' | echohl None
|
||||
return
|
||||
endif
|
||||
let parts = split(out[0], ' ')
|
||||
if parts[0] ==# 'none'
|
||||
echo 'Klammertext: ' . join(parts[1:], ' ')
|
||||
return
|
||||
endif
|
||||
" "match L C" or "mismatch L C message..."
|
||||
let lnum = str2nr(parts[1])
|
||||
let ccol = str2nr(parts[2])
|
||||
if exists('*setcursorcharpos')
|
||||
call setcursorcharpos(lnum, ccol)
|
||||
else
|
||||
call cursor(lnum, ccol)
|
||||
endif
|
||||
if parts[0] ==# 'mismatch'
|
||||
echohl WarningMsg
|
||||
echo 'Klammertext: ' . join(parts[3:], ' ')
|
||||
echohl None
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! klammertext#Check() abort
|
||||
let script = s:EditScript()
|
||||
if empty(script)
|
||||
echohl ErrorMsg
|
||||
echo 'Klammertext: cannot locate klammertext_edit.py'
|
||||
echohl None
|
||||
return
|
||||
endif
|
||||
let out = systemlist(s:Python() . ' ' . shellescape(script) . ' check',
|
||||
\ getline(1, '$'))
|
||||
if v:shell_error
|
||||
echohl ErrorMsg | echo 'Klammertext: the shared core failed' | echohl None
|
||||
return
|
||||
endif
|
||||
let items = []
|
||||
for line in out
|
||||
let m = matchlist(line, '^\(\d\+\):\(\d\+\): \(.*\)$')
|
||||
if !empty(m)
|
||||
call add(items, {'bufnr': bufnr('%'), 'lnum': str2nr(m[1]),
|
||||
\ 'col': str2nr(m[2]), 'text': m[3], 'type': 'E'})
|
||||
endif
|
||||
endfor
|
||||
call setloclist(0, items, ' ')
|
||||
call setloclist(0, [], 'a', {'title': 'Klammertext delimiter check'})
|
||||
if empty(items)
|
||||
lclose
|
||||
echo 'Klammertext: no delimiter problems found'
|
||||
else
|
||||
lopen
|
||||
endif
|
||||
endfunction
|
||||
|
||||
" --- +python3: in-process indentexpr and live match highlighting ----------
|
||||
" These load the shared core into Vim's embedded Python once, so the =
|
||||
" operator and the per-cursor-move matcher run without spawning processes.
|
||||
|
||||
let s:py_ready = 0
|
||||
|
||||
function! s:PySetup() abort
|
||||
if s:py_ready
|
||||
return 1
|
||||
endif
|
||||
let script = s:EditScript()
|
||||
if empty(script) || !has('python3')
|
||||
return 0
|
||||
endif
|
||||
let g:klammertext_py_dir = fnamemodify(script, ':h')
|
||||
py3 << EOF
|
||||
import sys
|
||||
import vim
|
||||
_kt_dir = vim.eval('g:klammertext_py_dir')
|
||||
if _kt_dir not in sys.path:
|
||||
sys.path.insert(0, _kt_dir)
|
||||
import klammertext_edit as _kt
|
||||
|
||||
def _kt_buffer_and_offset():
|
||||
buf = vim.current.buffer
|
||||
lines = buf[:]
|
||||
s = '\n'.join(lines)
|
||||
row, bytecol = vim.current.window.cursor # bytecol is 0-based bytes
|
||||
line = lines[row - 1] if row <= len(lines) else ''
|
||||
charcol = len(line.encode('utf-8')[:bytecol].decode('utf-8', 'replace'))
|
||||
offset = sum(len(l) + 1 for l in lines[:row - 1]) + charcol
|
||||
return s, lines, offset
|
||||
|
||||
def _kt_indent(lnum):
|
||||
buf = vim.current.buffer
|
||||
lines = buf[:]
|
||||
s = '\n'.join(lines)
|
||||
bol = sum(len(l) + 1 for l in lines[:lnum - 1])
|
||||
col = _kt.target_column(s, bol)
|
||||
return -1 if col is None else col
|
||||
|
||||
def _kt_pos(lines, offset):
|
||||
"""(1-based line, 0-based char col, the line's text) for char OFFSET."""
|
||||
line = 0
|
||||
while line < len(lines) and offset > len(lines[line]):
|
||||
offset -= len(lines[line]) + 1
|
||||
line += 1
|
||||
text = lines[line] if line < len(lines) else ''
|
||||
return (line + 1, offset, text)
|
||||
|
||||
def _kt_match():
|
||||
"""[] when not on a delimiter; else [mismatch, message,
|
||||
l1, c1, len1, (l2, c2, len2)?] with byte columns for matchaddpos()."""
|
||||
s, lines, offset = _kt_buffer_and_offset()
|
||||
m = _kt.match_at(s, offset)
|
||||
if m is None:
|
||||
return []
|
||||
out = [1 if m['mismatch'] else 0, m['message'] or '']
|
||||
for tok in [m['token'], m['match_token']]:
|
||||
if tok is None:
|
||||
continue
|
||||
line, ccol, text = _kt_pos(lines, tok[0])
|
||||
bcol = len(text[:ccol].encode('utf-8')) + 1
|
||||
blen = len(text[ccol:ccol + (tok[1] - tok[0])].encode('utf-8'))
|
||||
out.extend([line, bcol, blen])
|
||||
return out
|
||||
EOF
|
||||
let s:py_ready = 1
|
||||
return 1
|
||||
endfunction
|
||||
|
||||
function! klammertext#IndentExpr() abort
|
||||
if !s:PySetup()
|
||||
return -1
|
||||
endif
|
||||
return py3eval('_kt_indent(' . v:lnum . ')')
|
||||
endfunction
|
||||
|
||||
function! s:ClearMatchHighlight() abort
|
||||
if exists('w:klammertext_match_ids')
|
||||
for id in w:klammertext_match_ids
|
||||
silent! call matchdelete(id)
|
||||
endfor
|
||||
endif
|
||||
let w:klammertext_match_ids = []
|
||||
endfunction
|
||||
|
||||
function! s:UpdateMatchHighlight() abort
|
||||
call s:ClearMatchHighlight()
|
||||
let r = py3eval('_kt_match()')
|
||||
if empty(r)
|
||||
return
|
||||
endif
|
||||
let group = r[0] ? 'KlammertextMismatch' : 'KlammertextMatch'
|
||||
let pos = []
|
||||
let i = 2
|
||||
while i + 3 <= len(r)
|
||||
call add(pos, [r[i], r[i + 1], r[i + 2]])
|
||||
let i += 3
|
||||
endwhile
|
||||
if !empty(pos)
|
||||
call add(w:klammertext_match_ids, matchaddpos(group, pos))
|
||||
endif
|
||||
if r[0] && !empty(r[1])
|
||||
echo 'Klammertext: ' . r[1]
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! klammertext#SetupMatchHighlight() abort
|
||||
if !s:PySetup()
|
||||
return
|
||||
endif
|
||||
hi def link KlammertextMatch MatchParen
|
||||
hi def KlammertextMismatch guifg=#ff5555 gui=bold ctermfg=203 cterm=bold
|
||||
augroup klammertextMatch
|
||||
autocmd! * <buffer>
|
||||
autocmd CursorMoved,CursorMovedI <buffer> call s:UpdateMatchHighlight()
|
||||
autocmd BufLeave,WinLeave <buffer> call s:ClearMatchHighlight()
|
||||
augroup END
|
||||
endfunction
|
||||
10
doc/edit/vim/ftdetect/klammertext.vim
Normal file
10
doc/edit/vim/ftdetect/klammertext.vim
Normal file
@@ -0,0 +1,10 @@
|
||||
" Klammertext filetype detection (.kt source files, .k klammer definitions).
|
||||
"
|
||||
" NOTE: .kt is also Kotlin's extension, and recent Vim/Neovim runtimes map
|
||||
" *.kt to the kotlin filetype. This file overrides that unconditionally
|
||||
" (`set filetype=`, not `setfiletype`, so it wins over the runtime's
|
||||
" earlier detection). If you edit both Kotlin and Klammertext, replace the
|
||||
" *.kt line with a content heuristic of your choice, or drop it and set the
|
||||
" filetype per file with a modeline (# vim: ft=klammertext) or :set.
|
||||
au BufRead,BufNewFile *.kt set filetype=klammertext
|
||||
au BufRead,BufNewFile *.k set filetype=klammertext
|
||||
63
doc/edit/vim/ftplugin/klammertext.vim
Normal file
63
doc/edit/vim/ftplugin/klammertext.vim
Normal file
@@ -0,0 +1,63 @@
|
||||
" Klammertext filetype plugin: comment format, the structural commands, and
|
||||
" (when Vim has +python3) fast in-process indentation and live delimiter
|
||||
" match highlighting. The implementations are in autoload/klammertext.vim;
|
||||
" the algorithms themselves are the shared Python core
|
||||
" (klammertext_edit.py — see doc/edit/README.md for how it is located).
|
||||
"
|
||||
" Commands (buffer-local):
|
||||
" :[range]KlammertextReindent reindent the range (default: whole buffer)
|
||||
" :KlammertextAlign align the @table enclosing the cursor
|
||||
" :KlammertextJumpToMatch jump between a klammer application's
|
||||
" opening and closing delimiter
|
||||
" :KlammertextCheck unbalanced/mismatched delimiters -> the
|
||||
" location list
|
||||
"
|
||||
" Default mappings (set g:klammertext_no_mappings to define none):
|
||||
" <LocalLeader>i reindent the current line (visual: the selection)
|
||||
" <LocalLeader>a align the enclosing table
|
||||
" <LocalLeader>j jump to the matching delimiter
|
||||
"
|
||||
" Reindentation is EXPLICIT-ONLY: whitespace is content in Klammertext, so
|
||||
" indentkeys is emptied and nothing reformats as a side effect of typing.
|
||||
" With +python3 the ftplugin also sets 'indentexpr', so the = operator
|
||||
" (e.g. ==, gg=G) reindents through the same shared implementation.
|
||||
|
||||
if exists("b:did_ftplugin")
|
||||
finish
|
||||
endif
|
||||
let b:did_ftplugin = 1
|
||||
|
||||
setlocal commentstring=#\ %s
|
||||
setlocal comments=b:#
|
||||
setlocal indentkeys=
|
||||
|
||||
command! -buffer -range=% KlammertextReindent
|
||||
\ call klammertext#Reindent(<line1>, <line2>)
|
||||
command! -buffer KlammertextAlign call klammertext#AlignTable()
|
||||
command! -buffer KlammertextJumpToMatch call klammertext#JumpToMatch()
|
||||
command! -buffer KlammertextCheck call klammertext#Check()
|
||||
|
||||
if !exists("g:klammertext_no_mappings")
|
||||
nnoremap <buffer> <silent> <LocalLeader>i :.KlammertextReindent<CR>
|
||||
xnoremap <buffer> <silent> <LocalLeader>i :KlammertextReindent<CR>
|
||||
nnoremap <buffer> <silent> <LocalLeader>a :KlammertextAlign<CR>
|
||||
nnoremap <buffer> <silent> <LocalLeader>j :KlammertextJumpToMatch<CR>
|
||||
endif
|
||||
|
||||
let b:undo_ftplugin = "setlocal commentstring< comments< indentkeys<"
|
||||
\ . " | delcommand KlammertextReindent"
|
||||
\ . " | delcommand KlammertextAlign"
|
||||
\ . " | delcommand KlammertextJumpToMatch"
|
||||
\ . " | delcommand KlammertextCheck"
|
||||
|
||||
" With +python3 the shared core runs in-process: 'indentexpr' makes the =
|
||||
" operator work, and the matching delimiter is highlighted live as the
|
||||
" cursor sits on one (the show-paren equivalent; a mismatch shows in red
|
||||
" with a message). Without +python3 the commands above still work — they
|
||||
" shell out to python3 — and Neovim users can get live matching from the
|
||||
" language server instead (see doc/edit/README.md).
|
||||
if has('python3')
|
||||
setlocal indentexpr=klammertext#IndentExpr()
|
||||
let b:undo_ftplugin .= " | setlocal indentexpr<"
|
||||
call klammertext#SetupMatchHighlight()
|
||||
endif
|
||||
113
doc/edit/vim/syntax/klammertext.vim
Normal file
113
doc/edit/vim/syntax/klammertext.vim
Normal file
@@ -0,0 +1,113 @@
|
||||
" Vim syntax highlighting for Klammertext (.kt and .k files).
|
||||
" The Vim counterpart of doc/edit/emacs/klammertext-mode.el's highlighting
|
||||
" and doc/edit/sublime/Klammertext.sublime-syntax.
|
||||
"
|
||||
" What it highlights (the same token classes as the other editors):
|
||||
"
|
||||
" Text removal (#):
|
||||
" # ... remove to end of line (marker + removed text)
|
||||
" ## ... remove to end of file (marker + removed text)
|
||||
" #[ ... ]# remove enclosed text, nestable (markers + removed)
|
||||
" #- #+ #/ whitespace operators: NOT removals, left unhighlighted
|
||||
"
|
||||
" Klammer applications (@), definitions (@@), system commands (@@@):
|
||||
" @name @@name @@@name opening (@ and name are one unit)
|
||||
" name@ name@@ name@@@ named closing
|
||||
" @ @@ @@@ bare closing
|
||||
"
|
||||
" Escapes: ^@ ^# ^| ^^ — the caret makes the next character literal; the
|
||||
" two characters are consumed as one (unhighlighted) unit, so the escaped
|
||||
" character is never read as a delimiter. A run of carets pairs
|
||||
" left-to-right, reproducing the language's parity rule.
|
||||
"
|
||||
" Literal klammers: @code ... code@ — the interior is verbatim (no # or @
|
||||
" interpreted). SYNC: the literal-klammer set's source of truth is
|
||||
" LITERAL_KLAMMERS in doc/edit/shared/klammertext_edit.py; a static
|
||||
" syntax file cannot read it, so when you add a literal klammer 'foo',
|
||||
" copy the klammertextVerbatim region below with code -> foo (and mirror
|
||||
" it in the Emacs, Sublime, and VS Code artifacts; all are seeded with
|
||||
" just 'code').
|
||||
"
|
||||
" How open vs. close is decided (the same rule as every other integration):
|
||||
" a delimiter whose NAME follows the @-run (@name) is an OPENING; a bare
|
||||
" @-run, or one whose NAME precedes it (name@), is a CLOSING. The
|
||||
" look-ahead \%(\w\|@\)\@! on every closing keeps 'foo@bar' correct: that @
|
||||
" is followed by a name, so it opens @bar and 'foo' stays plain text. The
|
||||
" abbreviated @name-arg form colors only @name (the name ends at the first
|
||||
" hyphen), exactly like the other editors.
|
||||
"
|
||||
" Colors come from the shared Klammertext palette
|
||||
" (notes/klammertext_palette.md in the development tree): application blue,
|
||||
" definition green, system orange; each opening bright and its close the
|
||||
" same hue darker; full intensity on dark backgrounds, deepened (0.60x) on
|
||||
" light. All groups are `hi def`, so :highlight in your vimrc overrides.
|
||||
" Delimiter matching (jump + live highlight) is not a tokenizer concern —
|
||||
" it lives in the ftplugin/autoload files.
|
||||
|
||||
if exists("b:current_syntax")
|
||||
finish
|
||||
endif
|
||||
|
||||
" --- escapes: ^X makes X literal; consumed so # / @ are not delimiters ----
|
||||
" (Defined first; it wins by the earlier-start rule, since the ^ precedes.)
|
||||
syn match klammertextEscape /\^./
|
||||
|
||||
" --- text removal (#) -----------------------------------------------------
|
||||
" Order matters: at the same start position, the LAST defined item wins.
|
||||
syn match klammertextRemovedLine /#.*$/ contains=klammertextMarkerLine
|
||||
syn match klammertextMarkerLine /#/ contained
|
||||
" whitespace operators #- #+N #/N : not removals, left unhighlighted
|
||||
syn match klammertextWhitespaceOp "#[-+/]\d*"
|
||||
syn region klammertextRemovedBlock matchgroup=klammertextMarker start=/#\[/ end=/\]#/ contains=klammertextRemovedBlock
|
||||
syn region klammertextRemovedFile matchgroup=klammertextMarker start=/##/ end=/\%$/
|
||||
|
||||
" --- system / target commands @@@ ----------------------------------------
|
||||
syn match klammertextSysOpen /@\@1<!@@@\w\+/
|
||||
syn match klammertextSysClose /@\@1<!@@@\%(\w\|@\)\@!/
|
||||
syn match klammertextSysClose /@\@1<!\w\+@@@\%(\w\|@\)\@!/
|
||||
|
||||
" --- klammer definitions @@ ----------------------------------------------
|
||||
syn match klammertextDefOpen /@\@1<!@@\w\+/
|
||||
syn match klammertextDefClose /@\@1<!@@\%(\w\|@\)\@!/
|
||||
syn match klammertextDefClose /@\@1<!\w\+@@\%(\w\|@\)\@!/
|
||||
|
||||
" --- klammer applications @ ----------------------------------------------
|
||||
syn match klammertextAppOpen /@\@1<!@\w\+/
|
||||
syn match klammertextAppClose /@\@1<!@\%(\w\|@\)\@!/
|
||||
syn match klammertextAppClose /@\@1<!\w\+@\%(\w\|@\)\@!/
|
||||
|
||||
" --- literal klammer: interior verbatim (seeded default: @code) -----------
|
||||
" Defined AFTER the @-tier matches: in Vim, when several items match at the
|
||||
" same position the LAST defined wins, and this region must beat the plain
|
||||
" klammertextAppOpen match at '@code'. (Sublime's tokenizer picks the FIRST
|
||||
" listed rule — the opposite convention; don't copy that ordering here.)
|
||||
syn region klammertextVerbatim matchgroup=klammertextAppOpen start=/@\@1<!@code\%(\w\)\@!/ matchgroup=klammertextAppClose end=/code@/
|
||||
|
||||
" --- colors ---------------------------------------------------------------
|
||||
" The shared palette, dark and light values (see the header). cterm values
|
||||
" are the nearest xterm-256 approximations.
|
||||
if &background ==# 'light'
|
||||
hi def klammertextAppOpen guifg=#528599 ctermfg=66
|
||||
hi def klammertextAppClose guifg=#426a7a ctermfg=60
|
||||
hi def klammertextDefOpen guifg=#758b55 ctermfg=101
|
||||
hi def klammertextDefClose guifg=#5e7044 ctermfg=101
|
||||
hi def klammertextSysOpen guifg=#996743 ctermfg=94
|
||||
hi def klammertextSysClose guifg=#7a5236 ctermfg=94
|
||||
hi def klammertextMarker guifg=#994040 ctermfg=131
|
||||
hi def klammertextRemoved guifg=#9a9a9a ctermfg=247
|
||||
else
|
||||
hi def klammertextAppOpen guifg=#89ddff ctermfg=117
|
||||
hi def klammertextAppClose guifg=#6eb1cc ctermfg=74
|
||||
hi def klammertextDefOpen guifg=#c3e88d ctermfg=150
|
||||
hi def klammertextDefClose guifg=#9cba71 ctermfg=107
|
||||
hi def klammertextSysOpen guifg=#ffab70 ctermfg=216
|
||||
hi def klammertextSysClose guifg=#cc895a ctermfg=173
|
||||
hi def klammertextMarker guifg=#ff6b6b ctermfg=210
|
||||
hi def klammertextRemoved guifg=#8a8272 ctermfg=101
|
||||
endif
|
||||
hi def link klammertextMarkerLine klammertextMarker
|
||||
hi def link klammertextRemovedLine klammertextRemoved
|
||||
hi def link klammertextRemovedBlock klammertextRemoved
|
||||
hi def link klammertextRemovedFile klammertextRemoved
|
||||
|
||||
let b:current_syntax = "klammertext"
|
||||
100
doc/edit/vscode/README.md
Normal file
100
doc/edit/vscode/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Klammertext support for Visual Studio Code
|
||||
|
||||
VS Code support for editing Klammertext files (`.kt` documents and `.k`
|
||||
klammer definitions): syntax highlighting, delimiter matching and jumping,
|
||||
structural reindentation, table alignment, and delimiter diagnostics in the
|
||||
Problems panel.
|
||||
|
||||
The extension has **no npm dependencies and no build step**. Highlighting
|
||||
is a TextMate grammar (converted from the Sublime Text syntax); everything
|
||||
structural comes from the **Klammertext language server**
|
||||
(`klammertext_ls.py`), a dependency-free Python process the extension
|
||||
spawns, which itself runs the shared editor core (`klammertext_edit.py`)
|
||||
used by the Sublime Text and Vim integrations. One implementation of the
|
||||
language's structure, everywhere.
|
||||
|
||||
## Requirements
|
||||
|
||||
- VS Code 1.75 or later — a minimum, not a target: VS Code's monthly
|
||||
releases count 1.75, 1.76, … (1.75 is from January 2023), so any
|
||||
version from the last few years qualifies.
|
||||
- `python3` on `PATH` (or set `klammertext.pythonPath`); Klammertext itself
|
||||
already requires Python.
|
||||
- The language server, found automatically in this order:
|
||||
1. the `klammertext.serverPath` setting, if set;
|
||||
2. `klammertext_ls.py` vendored next to `extension.js` (the layout the
|
||||
Klammertext editing zip ships);
|
||||
3. `../shared/klammertext_ls.py` relative to the extension directory (the
|
||||
layout of the Klammertext repository — using the extension straight
|
||||
from a checkout just works);
|
||||
4. `$KLAMMERTEXT_HOME/doc/edit/shared/klammertext_ls.py`.
|
||||
|
||||
## Install
|
||||
|
||||
Copy this `vscode/` directory into your VS Code extensions folder:
|
||||
|
||||
cp -R vscode ~/.vscode/extensions/klammertext
|
||||
|
||||
then restart VS Code (or run the **Developer: Reload Window** command). If
|
||||
you copy the directory out of the Klammertext tree, also copy
|
||||
`shared/klammertext_ls.py` and `shared/klammertext_edit.py` into the copied
|
||||
folder — or set `klammertext.serverPath`. The editing zip from the
|
||||
Klammertext website ships the vendored copies already in place.
|
||||
|
||||
**Note:** `.kt` is also Kotlin's extension. Stock VS Code has no Kotlin
|
||||
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
|
||||
|
||||
**Syntax highlighting** — the same token classes as the Emacs, Sublime
|
||||
Text, and Vim support: text removal (`#`, `##`, nestable `#[ ... ]#`), the
|
||||
three `@`-tiers — application (`@`), definition (`@@`), system (`@@@`) —
|
||||
each as an opening (`@name`, one unit) or a close (`name@`, bare `@`),
|
||||
`^`-escapes, and verbatim `@code ... code@` interiors. Colors come from
|
||||
your theme (applications as functions, definitions as types, system
|
||||
commands as keywords, removed text as comments). To adopt the full
|
||||
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
|
||||
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.
|
||||
|
||||
**Commands and keybindings** (when editing Klammertext):
|
||||
|
||||
| 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 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 toggling** — `Ctrl+/` toggles `#` line removal and
|
||||
`Shift+Alt+A` wraps the selection in `#[ ... ]#`, via the standard VS Code
|
||||
comment commands.
|
||||
|
||||
## Settings
|
||||
|
||||
| Setting | Meaning (default) |
|
||||
|---|---|
|
||||
| `klammertext.pythonPath` | Python interpreter for the server (`python3`) |
|
||||
| `klammertext.serverPath` | full path to `klammertext_ls.py` (auto-located) |
|
||||
309
doc/edit/vscode/extension.js
Normal file
309
doc/edit/vscode/extension.js
Normal file
@@ -0,0 +1,309 @@
|
||||
// extension.js — the Klammertext VS Code extension.
|
||||
//
|
||||
// Everything structural (diagnostics, reindentation, table alignment,
|
||||
// delimiter matching) comes from the Klammertext language server
|
||||
// (klammertext_ls.py), which itself runs the shared editor core used by the
|
||||
// Sublime Text and Vim integrations. This file is glue: it spawns the
|
||||
// server and speaks the Language Server Protocol to it directly — the
|
||||
// framing and dispatch below are small, so the extension has NO npm
|
||||
// dependencies and no build step (deliberately, matching Klammertext's
|
||||
// no-third-party-libraries ethos).
|
||||
//
|
||||
// What the extension wires up:
|
||||
// * document sync (full text) for klammertext documents
|
||||
// * publishDiagnostics -> the Problems panel
|
||||
// * Format Document / Format Selection -> textDocument/(range)formatting
|
||||
// (structural reindentation; explicit-only — no format-on-type)
|
||||
// * occurrences highlighting -> textDocument/documentHighlight (the
|
||||
// matching delimiter lights up as the cursor sits on one)
|
||||
// * Go to Definition on a delimiter -> its matching delimiter
|
||||
// * klammertext.jumpToMatch (Ctrl+Alt+J) -> move the cursor to the match
|
||||
// * klammertext.alignTable (Ctrl+Alt+A) -> workspace/executeCommand; the
|
||||
// server answers with workspace/applyEdit
|
||||
//
|
||||
// The server is located via the klammertext.serverPath setting, a vendored
|
||||
// copy next to this file (the editing-zip layout), ../shared/ relative to
|
||||
// it (the Klammertext repository layout), or $KLAMMERTEXT_HOME.
|
||||
|
||||
'use strict';
|
||||
|
||||
const vscode = require('vscode');
|
||||
const cp = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// --- a minimal LSP client over a child process -----------------------------
|
||||
|
||||
class LspClient {
|
||||
constructor(command, args, log) {
|
||||
this.log = log;
|
||||
this.nextId = 1;
|
||||
this.pending = new Map(); // id -> {resolve, reject}
|
||||
this.handlers = new Map(); // method -> fn(params, id)
|
||||
this.buffer = Buffer.alloc(0);
|
||||
this.dead = false;
|
||||
this.proc = cp.spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
this.proc.stdout.on('data', (chunk) => this._onData(chunk));
|
||||
this.proc.stderr.on('data', (chunk) => log(chunk.toString()));
|
||||
this.proc.on('error', (err) => { this.dead = true; log('spawn error: ' + err.message); });
|
||||
this.proc.on('exit', (code) => { this.dead = true; log('server exited: ' + code); });
|
||||
}
|
||||
|
||||
_onData(chunk) {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
for (;;) {
|
||||
const sep = this.buffer.indexOf('\r\n\r\n');
|
||||
if (sep === -1) return;
|
||||
const header = this.buffer.slice(0, sep).toString();
|
||||
const m = /content-length:\s*(\d+)/i.exec(header);
|
||||
if (!m) { this.buffer = this.buffer.slice(sep + 4); continue; }
|
||||
const length = parseInt(m[1], 10);
|
||||
if (this.buffer.length < sep + 4 + length) return;
|
||||
const body = this.buffer.slice(sep + 4, sep + 4 + length).toString();
|
||||
this.buffer = this.buffer.slice(sep + 4 + length);
|
||||
let msg;
|
||||
try { msg = JSON.parse(body); } catch (e) { continue; }
|
||||
this._dispatch(msg);
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(msg) {
|
||||
if (msg.method !== undefined) {
|
||||
const handler = this.handlers.get(msg.method);
|
||||
if (handler) {
|
||||
Promise.resolve(handler(msg.params, msg.id)).then((result) => {
|
||||
if (msg.id !== undefined && msg.id !== null) {
|
||||
this._send({ jsonrpc: '2.0', id: msg.id, result: result === undefined ? null : result });
|
||||
}
|
||||
});
|
||||
} else if (msg.id !== undefined && msg.id !== null) {
|
||||
this._send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'method not found' } });
|
||||
}
|
||||
} else if (this.pending.has(msg.id)) {
|
||||
const p = this.pending.get(msg.id);
|
||||
this.pending.delete(msg.id);
|
||||
if (msg.error) p.reject(new Error(msg.error.message));
|
||||
else p.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
|
||||
_send(msg) {
|
||||
if (this.dead) return;
|
||||
const body = Buffer.from(JSON.stringify(msg), 'utf8');
|
||||
this.proc.stdin.write('Content-Length: ' + body.length + '\r\n\r\n');
|
||||
this.proc.stdin.write(body);
|
||||
}
|
||||
|
||||
request(method, params) {
|
||||
if (this.dead) return Promise.reject(new Error('server not running'));
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject });
|
||||
this._send({ jsonrpc: '2.0', id, method, params });
|
||||
});
|
||||
}
|
||||
|
||||
notify(method, params) {
|
||||
this._send({ jsonrpc: '2.0', method, params });
|
||||
}
|
||||
|
||||
onRequest(method, handler) { this.handlers.set(method, handler); }
|
||||
|
||||
stop() {
|
||||
if (this.dead) return;
|
||||
this.request('shutdown', null).then(
|
||||
() => { this.notify('exit', null); },
|
||||
() => { try { this.proc.kill(); } catch (e) { /* gone */ } });
|
||||
}
|
||||
}
|
||||
|
||||
// --- LSP <-> VS Code conversions -------------------------------------------
|
||||
|
||||
function toVsRange(r) {
|
||||
return new vscode.Range(r.start.line, r.start.character, r.end.line, r.end.character);
|
||||
}
|
||||
|
||||
function toVsEdits(edits) {
|
||||
return (edits || []).map((e) => new vscode.TextEdit(toVsRange(e.range), e.newText));
|
||||
}
|
||||
|
||||
function fromVsPosition(p) {
|
||||
return { line: p.line, character: p.character };
|
||||
}
|
||||
|
||||
function docParams(document) {
|
||||
return { textDocument: { uri: document.uri.toString() } };
|
||||
}
|
||||
|
||||
// --- locating the server ---------------------------------------------------
|
||||
|
||||
function findServer(context) {
|
||||
const configured = vscode.workspace.getConfiguration('klammertext').get('serverPath');
|
||||
const candidates = [];
|
||||
if (configured) candidates.push(configured);
|
||||
candidates.push(path.join(context.extensionPath, 'klammertext_ls.py'));
|
||||
candidates.push(path.join(context.extensionPath, '..', 'shared', 'klammertext_ls.py'));
|
||||
if (process.env.KLAMMERTEXT_HOME) {
|
||||
candidates.push(path.join(process.env.KLAMMERTEXT_HOME, 'doc', 'edit', 'shared', 'klammertext_ls.py'));
|
||||
}
|
||||
return candidates.find((c) => { try { return fs.statSync(c).isFile(); } catch (e) { return false; } });
|
||||
}
|
||||
|
||||
// --- activation ------------------------------------------------------------
|
||||
|
||||
let client = null;
|
||||
|
||||
function activate(context) {
|
||||
const output = vscode.window.createOutputChannel('Klammertext');
|
||||
const log = (s) => output.append(s.endsWith('\n') ? s : s + '\n');
|
||||
|
||||
const serverPath = findServer(context);
|
||||
if (!serverPath) {
|
||||
vscode.window.showWarningMessage(
|
||||
'Klammertext: cannot locate klammertext_ls.py — set the ' +
|
||||
'klammertext.serverPath setting. Highlighting works; ' +
|
||||
'diagnostics, formatting, matching and alignment need the server.');
|
||||
return;
|
||||
}
|
||||
const python = vscode.workspace.getConfiguration('klammertext').get('pythonPath') || 'python3';
|
||||
client = new LspClient(python, [serverPath], log);
|
||||
|
||||
const diagnostics = vscode.languages.createDiagnosticCollection('klammertext');
|
||||
context.subscriptions.push(diagnostics, output);
|
||||
|
||||
client.onRequest('textDocument/publishDiagnostics', (params) => {
|
||||
diagnostics.set(vscode.Uri.parse(params.uri), (params.diagnostics || []).map((d) => {
|
||||
const diag = new vscode.Diagnostic(
|
||||
toVsRange(d.range), d.message,
|
||||
d.severity === 1 ? vscode.DiagnosticSeverity.Error
|
||||
: vscode.DiagnosticSeverity.Warning);
|
||||
diag.source = d.source;
|
||||
return diag;
|
||||
}));
|
||||
});
|
||||
|
||||
client.onRequest('workspace/applyEdit', (params) => {
|
||||
const we = new vscode.WorkspaceEdit();
|
||||
const changes = (params.edit && params.edit.changes) || {};
|
||||
for (const uri of Object.keys(changes)) {
|
||||
for (const e of changes[uri]) {
|
||||
we.replace(vscode.Uri.parse(uri), toVsRange(e.range), e.newText);
|
||||
}
|
||||
}
|
||||
return vscode.workspace.applyEdit(we).then((applied) => ({ applied }));
|
||||
});
|
||||
|
||||
client.onRequest('window/showMessage', (params) => {
|
||||
vscode.window.setStatusBarMessage(params.message, 5000);
|
||||
});
|
||||
|
||||
// -- document sync (full text) --
|
||||
const isKt = (doc) => doc.languageId === 'klammertext';
|
||||
const open = (doc) => {
|
||||
if (!isKt(doc)) return;
|
||||
client.notify('textDocument/didOpen', {
|
||||
textDocument: { uri: doc.uri.toString(), languageId: 'klammertext',
|
||||
version: doc.version, text: doc.getText() },
|
||||
});
|
||||
};
|
||||
|
||||
client.request('initialize', {
|
||||
processId: process.pid,
|
||||
rootUri: null,
|
||||
capabilities: {},
|
||||
}).then(() => {
|
||||
client.notify('initialized', {});
|
||||
vscode.workspace.textDocuments.forEach(open);
|
||||
}, (err) => log('initialize failed: ' + err.message));
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidOpenTextDocument(open),
|
||||
vscode.workspace.onDidChangeTextDocument((event) => {
|
||||
if (!isKt(event.document)) return;
|
||||
client.notify('textDocument/didChange', {
|
||||
textDocument: { uri: event.document.uri.toString(),
|
||||
version: event.document.version },
|
||||
contentChanges: [{ text: event.document.getText() }],
|
||||
});
|
||||
}),
|
||||
vscode.workspace.onDidCloseTextDocument((doc) => {
|
||||
if (!isKt(doc)) return;
|
||||
client.notify('textDocument/didClose', docParams(doc));
|
||||
}));
|
||||
|
||||
// -- providers --
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerDocumentFormattingEditProvider('klammertext', {
|
||||
provideDocumentFormattingEdits(document) {
|
||||
return client.request('textDocument/formatting',
|
||||
Object.assign(docParams(document), { options: {} }))
|
||||
.then(toVsEdits);
|
||||
},
|
||||
}),
|
||||
vscode.languages.registerDocumentRangeFormattingEditProvider('klammertext', {
|
||||
provideDocumentRangeFormattingEdits(document, range) {
|
||||
return client.request('textDocument/rangeFormatting',
|
||||
Object.assign(docParams(document), {
|
||||
range: { start: fromVsPosition(range.start),
|
||||
end: fromVsPosition(range.end) },
|
||||
options: {},
|
||||
})).then(toVsEdits);
|
||||
},
|
||||
}),
|
||||
vscode.languages.registerDocumentHighlightProvider('klammertext', {
|
||||
provideDocumentHighlights(document, position) {
|
||||
return client.request('textDocument/documentHighlight',
|
||||
Object.assign(docParams(document),
|
||||
{ position: fromVsPosition(position) }))
|
||||
.then((result) => (result || []).map((h) =>
|
||||
new vscode.DocumentHighlight(toVsRange(h.range))));
|
||||
},
|
||||
}),
|
||||
vscode.languages.registerDefinitionProvider('klammertext', {
|
||||
provideDefinition(document, position) {
|
||||
return client.request('textDocument/definition',
|
||||
Object.assign(docParams(document),
|
||||
{ position: fromVsPosition(position) }))
|
||||
.then((result) => result
|
||||
? new vscode.Location(vscode.Uri.parse(result.uri),
|
||||
toVsRange(result.range))
|
||||
: null);
|
||||
},
|
||||
}));
|
||||
|
||||
// -- commands --
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('klammertext.jumpToMatch', () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || !isKt(editor.document)) return;
|
||||
return client.request('textDocument/definition',
|
||||
Object.assign(docParams(editor.document),
|
||||
{ position: fromVsPosition(editor.selection.active) }))
|
||||
.then((result) => {
|
||||
if (!result) {
|
||||
vscode.window.setStatusBarMessage(
|
||||
'Klammertext: no matching delimiter here', 5000);
|
||||
return;
|
||||
}
|
||||
const pos = toVsRange(result.range).start;
|
||||
editor.selection = new vscode.Selection(pos, pos);
|
||||
editor.revealRange(new vscode.Range(pos, pos));
|
||||
});
|
||||
}),
|
||||
vscode.commands.registerCommand('klammertext.alignTable', () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || !isKt(editor.document)) return;
|
||||
return client.request('workspace/executeCommand', {
|
||||
command: 'klammertext.alignTable',
|
||||
arguments: [{ uri: editor.document.uri.toString(),
|
||||
position: fromVsPosition(editor.selection.active) }],
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
function deactivate() {
|
||||
if (client) client.stop();
|
||||
client = null;
|
||||
}
|
||||
|
||||
module.exports = { activate, deactivate };
|
||||
11
doc/edit/vscode/language-configuration.json
Normal file
11
doc/edit/vscode/language-configuration.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"comments": {
|
||||
"lineComment": "#",
|
||||
"blockComment": ["#[", "]#"]
|
||||
},
|
||||
"brackets": [
|
||||
["#[", "]#"]
|
||||
],
|
||||
"autoClosingPairs": [],
|
||||
"surroundingPairs": []
|
||||
}
|
||||
85
doc/edit/vscode/package.json
Normal file
85
doc/edit/vscode/package.json
Normal file
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "klammertext",
|
||||
"displayName": "Klammertext",
|
||||
"description": "Klammertext language support: syntax highlighting, delimiter matching, structural reindentation, table alignment, and delimiter diagnostics.",
|
||||
"version": "0.1.0",
|
||||
"publisher": "klammertext",
|
||||
"license": "SEE LICENSE IN THE KLAMMERTEXT DISTRIBUTION",
|
||||
"engines": {
|
||||
"vscode": "^1.75.0"
|
||||
},
|
||||
"categories": [
|
||||
"Programming Languages"
|
||||
],
|
||||
"main": "./extension.js",
|
||||
"activationEvents": [
|
||||
"onLanguage:klammertext"
|
||||
],
|
||||
"capabilities": {
|
||||
"untrustedWorkspaces": {
|
||||
"supported": false,
|
||||
"description": "The extension runs the Klammertext language server (a local Python process)."
|
||||
}
|
||||
},
|
||||
"contributes": {
|
||||
"languages": [
|
||||
{
|
||||
"id": "klammertext",
|
||||
"aliases": [
|
||||
"Klammertext"
|
||||
],
|
||||
"extensions": [
|
||||
".kt",
|
||||
".k"
|
||||
],
|
||||
"configuration": "./language-configuration.json"
|
||||
}
|
||||
],
|
||||
"grammars": [
|
||||
{
|
||||
"language": "klammertext",
|
||||
"scopeName": "text.klammertext",
|
||||
"path": "./syntaxes/klammertext.tmLanguage.json"
|
||||
}
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"command": "klammertext.jumpToMatch",
|
||||
"title": "Klammertext: Jump to Matching Delimiter"
|
||||
},
|
||||
{
|
||||
"command": "klammertext.alignTable",
|
||||
"title": "Klammertext: Align Table"
|
||||
}
|
||||
],
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "klammertext.jumpToMatch",
|
||||
"key": "ctrl+alt+j",
|
||||
"mac": "cmd+alt+j",
|
||||
"when": "editorTextFocus && editorLangId == klammertext"
|
||||
},
|
||||
{
|
||||
"command": "klammertext.alignTable",
|
||||
"key": "ctrl+alt+a",
|
||||
"mac": "cmd+alt+a",
|
||||
"when": "editorTextFocus && editorLangId == klammertext"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Klammertext",
|
||||
"properties": {
|
||||
"klammertext.pythonPath": {
|
||||
"type": "string",
|
||||
"default": "python3",
|
||||
"description": "Python interpreter used to run the Klammertext language server."
|
||||
},
|
||||
"klammertext.serverPath": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"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/."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
132
doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
Normal file
132
doc/edit/vscode/syntaxes/klammertext.tmLanguage.json
Normal file
@@ -0,0 +1,132 @@
|
||||
{
|
||||
"//": [
|
||||
"TextMate grammar for Klammertext — the VS Code port of",
|
||||
"doc/edit/sublime/Klammertext.sublime-syntax (same token classes,",
|
||||
"same scope names; see that file's header for the full rationale).",
|
||||
"",
|
||||
"What it highlights: text removal (#, ##, nestable #[ ... ]#,",
|
||||
"whitespace operators left unscoped), the three @-tiers — application",
|
||||
"(@), definition (@@), system (@@@) — each as an opening (@name, one",
|
||||
"unit) or a close (name@, bare @), ^-escapes (consumed, unscoped),",
|
||||
"and verbatim @code ... code@ interiors.",
|
||||
"",
|
||||
"How open vs. close is decided (the same rule as every integration):",
|
||||
"a delimiter whose NAME follows the @-run is an OPENING; a bare",
|
||||
"@-run, or one whose NAME precedes it, is a CLOSING. The",
|
||||
"(?![A-Za-z0-9_@]) look-ahead on every closing keeps foo@bar correct.",
|
||||
"",
|
||||
"SYNC: the literal-klammer set's source of truth is LITERAL_KLAMMERS",
|
||||
"in doc/edit/shared/klammertext_edit.py. A static grammar cannot",
|
||||
"read it: to add a literal klammer 'foo', copy the @code begin/end",
|
||||
"rule below with code -> foo (and mirror it in the Emacs, Sublime,",
|
||||
"and Vim artifacts; all are seeded with just 'code').",
|
||||
"",
|
||||
"Delimiter matching, indentation, alignment, and diagnostics are not",
|
||||
"tokenizer concerns — they come from the Klammertext language server",
|
||||
"via extension.js.",
|
||||
"",
|
||||
"The ## rule's end pattern never matches, so the region runs to the",
|
||||
"end of the file (## removes the rest of the file by definition)."
|
||||
],
|
||||
"name": "Klammertext",
|
||||
"scopeName": "text.klammertext",
|
||||
"patterns": [
|
||||
{
|
||||
"match": "\\^."
|
||||
},
|
||||
{
|
||||
"begin": "#\\[",
|
||||
"beginCaptures": {
|
||||
"0": { "name": "punctuation.definition.comment.klammertext" }
|
||||
},
|
||||
"end": "\\]#",
|
||||
"endCaptures": {
|
||||
"0": { "name": "punctuation.definition.comment.klammertext" }
|
||||
},
|
||||
"name": "comment.block.klammertext",
|
||||
"patterns": [
|
||||
{ "include": "#removal-block" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"match": "#[-+/]\\d*"
|
||||
},
|
||||
{
|
||||
"begin": "##",
|
||||
"beginCaptures": {
|
||||
"0": { "name": "punctuation.definition.comment.klammertext" }
|
||||
},
|
||||
"end": "$never^",
|
||||
"name": "comment.block.klammertext"
|
||||
},
|
||||
{
|
||||
"begin": "#",
|
||||
"beginCaptures": {
|
||||
"0": { "name": "punctuation.definition.comment.klammertext" }
|
||||
},
|
||||
"end": "$",
|
||||
"name": "comment.line.klammertext"
|
||||
},
|
||||
{
|
||||
"begin": "@code(?![A-Za-z0-9_])",
|
||||
"beginCaptures": {
|
||||
"0": { "name": "entity.name.function.begin.klammertext" }
|
||||
},
|
||||
"end": "code@",
|
||||
"endCaptures": {
|
||||
"0": { "name": "entity.name.function.end.klammertext" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "@@@[A-Za-z0-9_]+",
|
||||
"name": "keyword.control.begin.klammertext"
|
||||
},
|
||||
{
|
||||
"match": "@@@(?![A-Za-z0-9_@])",
|
||||
"name": "keyword.control.end.klammertext"
|
||||
},
|
||||
{
|
||||
"match": "[A-Za-z0-9_]+@@@(?![A-Za-z0-9_@])",
|
||||
"name": "keyword.control.end.klammertext"
|
||||
},
|
||||
{
|
||||
"match": "@@[A-Za-z0-9_]+",
|
||||
"name": "storage.type.begin.klammertext"
|
||||
},
|
||||
{
|
||||
"match": "@@(?![A-Za-z0-9_@])",
|
||||
"name": "storage.type.end.klammertext"
|
||||
},
|
||||
{
|
||||
"match": "[A-Za-z0-9_]+@@(?![A-Za-z0-9_@])",
|
||||
"name": "storage.type.end.klammertext"
|
||||
},
|
||||
{
|
||||
"match": "@[A-Za-z0-9_]+",
|
||||
"name": "entity.name.function.begin.klammertext"
|
||||
},
|
||||
{
|
||||
"match": "@(?![A-Za-z0-9_@])",
|
||||
"name": "entity.name.function.end.klammertext"
|
||||
},
|
||||
{
|
||||
"match": "[A-Za-z0-9_]+@(?![A-Za-z0-9_@])",
|
||||
"name": "entity.name.function.end.klammertext"
|
||||
}
|
||||
],
|
||||
"repository": {
|
||||
"removal-block": {
|
||||
"begin": "#\\[",
|
||||
"beginCaptures": {
|
||||
"0": { "name": "punctuation.definition.comment.klammertext" }
|
||||
},
|
||||
"end": "\\]#",
|
||||
"endCaptures": {
|
||||
"0": { "name": "punctuation.definition.comment.klammertext" }
|
||||
},
|
||||
"patterns": [
|
||||
{ "include": "#removal-block" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user