#!/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_klammertext_matchInfo(self, msg_id, params): """Custom request: the full matching story for a cursor position — token, matching token, and whether the pair mismatches. The VS Code extension draws its live match/mismatch decorations from this (documentHighlight is word-gated in VS Code, so a bare @ close would never trigger it; and it cannot carry the mismatch flag).""" uri = params['textDocument']['uri'] text = self.docs.get(uri, '') m = KE.match_at(text, pos_to_offset(text, params['position'])) if m is None: self.reply(msg_id, None) return self.reply(msg_id, { 'token': offsets_to_range(text, *m['token']), 'matchToken': (offsets_to_range(text, *m['match_token']) if m['match_token'] is not None else None), 'mismatch': m['mismatch'], 'message': m['message'], }) def on_textDocument_definition(self, msg_id, params): uri = params['textDocument']['uri'] text = self.docs.get(uri, '') 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()