godcode-engine 4.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
godcode/lsp.py ADDED
@@ -0,0 +1,677 @@
1
+ """Language server for God Code (v3.0, Pillar 4).
2
+
3
+ A minimal Language Server Protocol implementation over stdio, hand-rolled
4
+ on top of the stdlib — no third-party dependencies. JSON-RPC 2.0 messages
5
+ are framed with ``Content-Length`` headers, per the LSP base protocol.
6
+
7
+ Lifecycle::
8
+
9
+ godcode lsp # start serving on stdin/stdout
10
+
11
+ Diagnostics are produced by the real lexer/parser pipeline (the same
12
+ logic ``godcode check`` uses): opening or changing a ``.god`` scroll
13
+ re-parses it and publishes the divine errors as LSP diagnostics.
14
+
15
+ Protocol notes:
16
+ * All logging goes to stderr. stdout is the protocol channel — nothing
17
+ else may ever be written there.
18
+ * Unknown methods answer with JSON-RPC error -32601 ("Method not found").
19
+ * Malformed frames are skipped; the server keeps serving.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ import sys
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Divine documentation table (hover + completion)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ LSP_DOCS: dict[str, str] = {
33
+ # -- block sentinels -------------------------------------------------
34
+ "BEGIN CREATION": (
35
+ "**BEGIN CREATION**\n\n"
36
+ "Speak, and let there be a program. Every scroll opens its works "
37
+ "with `BEGIN CREATION` and closes them with `END CREATION`.\n\n"
38
+ "```godcode\nBEGIN CREATION\n REVEAL(\"Let there be light\")\n"
39
+ " ASCEND\nEND CREATION\n```"
40
+ ),
41
+ "END CREATION": (
42
+ "**END CREATION**\n\n"
43
+ "The seal upon the scroll — it closes what `BEGIN CREATION` opened. "
44
+ "No works may follow it.\n\n"
45
+ "```godcode\nEND CREATION\n```"
46
+ ),
47
+ "DEFINE RITE": (
48
+ "**DEFINE RITE**\n\n"
49
+ "Establish a rite — a named ceremony of statements that may be "
50
+ "invoked again and again. Closed with `END RITE`; a rite may "
51
+ "`RETURN` a value to its caller.\n\n"
52
+ "```godcode\nDEFINE RITE bless(name)\n"
53
+ " REVEAL(\"Blessed be \" + name)\nEND RITE\n```"
54
+ ),
55
+ "END RITE": (
56
+ "**END RITE**\n\n"
57
+ "The closing of a rite begun with `DEFINE RITE`. All ceremonies "
58
+ "must be sealed.\n\n"
59
+ "```godcode\nEND RITE\n```"
60
+ ),
61
+ "BREATHE LIFE INTO": (
62
+ "**BREATHE LIFE INTO**\n\n"
63
+ "Awaken a declared vessel so it may be used. A name must be "
64
+ "`DECLARE`d before the breath is given.\n\n"
65
+ "```godcode\nDECLARE vessel AS 0\nBREATHE LIFE INTO vessel\n```"
66
+ ),
67
+ # -- keywords --------------------------------------------------------
68
+ "BEGIN": (
69
+ "**BEGIN**\n\n"
70
+ "The opening word of creation — always paired with `CREATION`. "
71
+ "See `BEGIN CREATION`.\n\n```godcode\nBEGIN CREATION\n```"
72
+ ),
73
+ "CREATION": (
74
+ "**CREATION**\n\n"
75
+ "The body of all works — paired with `BEGIN` to open a scroll and "
76
+ "with `END` to close it. See `BEGIN CREATION`.\n\n"
77
+ "```godcode\nBEGIN CREATION\n ASCEND\nEND CREATION\n```"
78
+ ),
79
+ "DECLARE": (
80
+ "**DECLARE**\n\n"
81
+ "Bring a name into being and bind it to a value. "
82
+ "Every vessel must be declared before the breath is given.\n\n"
83
+ "```godcode\nDECLARE tribes AS 12\n```"
84
+ ),
85
+ "AS": (
86
+ "**AS**\n\n"
87
+ "The binding word — it joins a declared name to its value, as "
88
+ "in `DECLARE x AS 1`.\n\n```godcode\nDECLARE x AS 1\n```"
89
+ ),
90
+ "IF": (
91
+ "**IF**\n\n"
92
+ "Weigh a condition; if it holds true, the words between `THEN` "
93
+ "and `ENDIF` come to pass.\n\n"
94
+ "```godcode\nIF faith > fear THEN\n REVEAL(\"walk on\")\nENDIF\n```"
95
+ ),
96
+ "THEN": (
97
+ "**THEN**\n\n"
98
+ "Marks the start of the true branch after an `IF` condition. "
99
+ "Closed by `ENDIF` (or `ELSE`).\n\n```godcode\nIF x THEN\n REVEAL(x)\n"
100
+ "ENDIF\n```"
101
+ ),
102
+ "ELSE": (
103
+ "**ELSE**\n\n"
104
+ "The other path — when the `IF` condition proves false, these "
105
+ "words come to pass instead.\n\n"
106
+ "```godcode\nIF x THEN\n REVEAL(\"yes\")\nELSE\n"
107
+ " REVEAL(\"no\")\nENDIF\n```"
108
+ ),
109
+ "ENDIF": (
110
+ "**ENDIF**\n\n"
111
+ "The seal upon an `IF` weighing. Every `IF` must be closed.\n\n"
112
+ "```godcode\nENDIF\n```"
113
+ ),
114
+ "FOR": (
115
+ "**FOR**\n\n"
116
+ "Walk through every member of a list or range, naming each one "
117
+ "in turn, until `ENDFOR`.\n\n"
118
+ "```godcode\nFOR tribe IN RANGE(12)\n REVEAL(tribe)\nENDFOR\n```"
119
+ ),
120
+ "IN": (
121
+ "**IN**\n\n"
122
+ "The walking word — `FOR name IN list` binds each member in turn.\n\n"
123
+ "```godcode\nFOR star IN heavens\n REVEAL(star)\nENDFOR\n```"
124
+ ),
125
+ "ENDFOR": (
126
+ "**ENDFOR**\n\n"
127
+ "The seal upon a `FOR` walk. Every journey must end.\n\n"
128
+ "```godcode\nENDFOR\n```"
129
+ ),
130
+ "WHILE": (
131
+ "**WHILE**\n\n"
132
+ "Repeat the works between `DO` and `ENDWHILE` for as long as the "
133
+ "condition holds true.\n\n"
134
+ "```godcode\nWHILE night < dawn DO\n REVEAL(\"watch\")\nENDWHILE\n```"
135
+ ),
136
+ "DO": (
137
+ "**DO**\n\n"
138
+ "Marks the start of the repeated works in a `WHILE` loop.\n\n"
139
+ "```godcode\nWHILE x DO\n REVEAL(x)\nENDWHILE\n```"
140
+ ),
141
+ "ENDWHILE": (
142
+ "**ENDWHILE**\n\n"
143
+ "The seal upon a `WHILE` vigil.\n\n```godcode\nENDWHILE\n```"
144
+ ),
145
+ "DEFINE": (
146
+ "**DEFINE**\n\n"
147
+ "The first word of a rite's establishment — always paired with "
148
+ "`RITE`. See `DEFINE RITE`.\n\n```godcode\nDEFINE RITE bless()\n"
149
+ "END RITE\n```"
150
+ ),
151
+ "RITE": (
152
+ "**RITE**\n\n"
153
+ "A named ceremony of statements. Paired with `DEFINE` to open "
154
+ "and `END` to close. See `DEFINE RITE`.\n\n"
155
+ "```godcode\nDEFINE RITE bless()\nEND RITE\n```"
156
+ ),
157
+ "INVOKE": (
158
+ "**INVOKE**\n\n"
159
+ "Call a rite by name, offering it arguments in the ancient manner.\n\n"
160
+ "```godcode\nINVOKE bless(\"the meek\")\n```"
161
+ ),
162
+ "RETURN": (
163
+ "**RETURN**\n\n"
164
+ "Offer a value back from a rite to the one who invoked it. "
165
+ "A rite without `RETURN` yields the void.\n\n"
166
+ "```godcode\nRETURN manna * 2\n```"
167
+ ),
168
+ "IMPORT": (
169
+ "**IMPORT**\n\n"
170
+ "Bring another scroll into this creation, that its works may "
171
+ "serve here.\n\n```godcode\nIMPORT \"psalms.god\"\n```"
172
+ ),
173
+ "REVEAL": (
174
+ "**REVEAL**\n\n"
175
+ "Speak a value aloud — the scroll's voice, printing to the world "
176
+ "beyond.\n\n```godcode\nREVEAL(\"The heavens declare\")\n```"
177
+ ),
178
+ "BREATHE": (
179
+ "**BREATHE**\n\n"
180
+ "The first word of awakening — always `BREATHE LIFE INTO name`. "
181
+ "See `BREATHE LIFE INTO`.\n\n"
182
+ "```godcode\nBREATHE LIFE INTO vessel\n```"
183
+ ),
184
+ "LIFE": (
185
+ "**LIFE**\n\n"
186
+ "The middle word of `BREATHE LIFE INTO` — the breath itself.\n\n"
187
+ "```godcode\nBREATHE LIFE INTO vessel\n```"
188
+ ),
189
+ "INTO": (
190
+ "**INTO**\n\n"
191
+ "The directing word of `BREATHE LIFE INTO name`.\n\n"
192
+ "```godcode\nBREATHE LIFE INTO vessel\n```"
193
+ ),
194
+ "PROPHESY": (
195
+ "**PROPHESY**\n\n"
196
+ "Utter a fixed word into the scroll — a literal prophecy of text.\n\n"
197
+ "```godcode\nPROPHESY \"and it was good\"\n```"
198
+ ),
199
+ "ASCEND": (
200
+ "**ASCEND**\n\n"
201
+ "End the run in peace. Nothing after `ASCEND` shall come to pass.\n\n"
202
+ "```godcode\nASCEND\n```"
203
+ ),
204
+ "SEAL": (
205
+ "**SEAL**\n\n"
206
+ "Set a value under seal — it is recorded in the covenant ledger "
207
+ "and may not be altered thereafter.\n\n```godcode\nSEAL covenant\n```"
208
+ ),
209
+ # --- v4.0 ---
210
+ "ANCHOR": (
211
+ "**ANCHOR**\n\n"
212
+ "Anchor a value's hash on a chain: `ANCHOR(expr)` returns a receipt "
213
+ "map `{chain, anchor_hash, height, timestamp, payload_hash}`. "
214
+ "The default chain is `simulated` (a local tamper-evident chain); "
215
+ "a second argument names another registered chain.\n\n"
216
+ "```godcode\nDECLARE seal AS ANCHOR(covenant)\n```"
217
+ ),
218
+ "CONSULT": (
219
+ "**CONSULT**\n\n"
220
+ "Ask the local Spirit oracle a question; it answers with two to "
221
+ "three sentences of counsel. No external calls.\n\n"
222
+ "```godcode\nREVEAL(CONSULT(\"How should I structure this covenant?\"))\n```"
223
+ ),
224
+ "INTENT": (
225
+ "**INTENT**\n\n"
226
+ "The naming word of `DECLARE INTENT \"words...\" ON rite_name` — "
227
+ "it registers a natural-language intent on a rite. When the rite "
228
+ "is invoked, the Spirit discerns whether its words still walk in "
229
+ "the declared intent, and counsels gently on drift.\n\n"
230
+ "```godcode\nDECLARE INTENT \"bring peace\" ON evening_blessing\n```"
231
+ ),
232
+ # --- end v4.0 ---
233
+ "TESTIFY": (
234
+ "**TESTIFY**\n\n"
235
+ "Bear witness to a value — affirm it before the heavens.\n\n"
236
+ "```godcode\nTESTIFY manna > 0\n```"
237
+ ),
238
+ "BLESS": (
239
+ "**BLESS**\n\n"
240
+ "Confer blessing upon a name, marking it favored.\n\n"
241
+ "```godcode\nBLESS the_meek\n```"
242
+ ),
243
+ "ANOINT": (
244
+ "**ANOINT**\n\n"
245
+ "Anoint a name for a holy purpose, setting it apart.\n\n"
246
+ "```godcode\nANOINT the_chosen\n```"
247
+ ),
248
+ "REFLECT": (
249
+ "**REFLECT**\n\n"
250
+ "Pause and contemplate — a still point in the works.\n\n"
251
+ "```godcode\nREFLECT\n```"
252
+ ),
253
+ "AND": (
254
+ "**AND**\n\n"
255
+ "Join two truths; the whole holds only if both hold.\n\n"
256
+ "```godcode\nIF faith AND works THEN\n```"
257
+ ),
258
+ "OR": (
259
+ "**OR**\n\n"
260
+ "Offer two truths; the whole holds if either holds.\n\n"
261
+ "```godcode\nIF mercy OR grace THEN\n```"
262
+ ),
263
+ "NOT": (
264
+ "**NOT**\n\n"
265
+ "Turn a truth upon its head.\n\n```godcode\nIF NOT fear THEN\n```"
266
+ ),
267
+ "TRUE": (
268
+ "**TRUE**\n\n"
269
+ "The eternal yes.\n\n```godcode\nDECLARE amen AS true\n```"
270
+ ),
271
+ "FALSE": (
272
+ "**FALSE**\n\n"
273
+ "The eternal no.\n\n```godcode\nDECLARE doubt AS false\n```"
274
+ ),
275
+ "VOID": (
276
+ "**VOID**\n\n"
277
+ "The absence of all things — what a rite yields when it returns "
278
+ "nothing.\n\n```godcode\nDECLARE emptiness AS void\n```"
279
+ ),
280
+ "IS": (
281
+ "**IS**\n\n"
282
+ "The weighing word — reserved for divine comparisons.\n\n"
283
+ "```godcode\n# reserved\n```"
284
+ ),
285
+ "END": (
286
+ "**END**\n\n"
287
+ "The closing word — paired with `CREATION` or `RITE` to seal a "
288
+ "block.\n\n```godcode\nEND CREATION\n```"
289
+ ),
290
+ # -- built-ins -------------------------------------------------------
291
+ "LEN": (
292
+ "**LEN**(value)\n\n"
293
+ "Measure the length of a string or a list, and the number shall "
294
+ "be revealed.\n\n```godcode\nREVEAL(LEN(\"firmament\"))\n```"
295
+ ),
296
+ "STR": (
297
+ "**STR**(value)\n\n"
298
+ "Turn anything into its spoken form — a string.\n\n"
299
+ "```godcode\nREVEAL(STR(40) + \" days\")\n```"
300
+ ),
301
+ "NUM": (
302
+ "**NUM**(value)\n\n"
303
+ "Turn a string into a number, that it may be weighed and counted.\n\n"
304
+ "```godcode\nDECLARE years AS NUM(\"40\")\n```"
305
+ ),
306
+ "TYPE": (
307
+ "**TYPE**(value)\n\n"
308
+ "Discern the kind of a thing — its type, named aloud.\n\n"
309
+ "```godcode\nREVEAL(TYPE(manna))\n```"
310
+ ),
311
+ "RANDOM": (
312
+ "**RANDOM**(bound)\n\n"
313
+ "Cast lots — draw a whole number from 0 up to (but not including) "
314
+ "`bound`.\n\n```godcode\nDECLARE lot AS RANDOM(12)\n```"
315
+ ),
316
+ "RANGE": (
317
+ "**RANGE**(stop) / **RANGE**(start, stop)\n\n"
318
+ "Number the days — produce the sequence of whole numbers from "
319
+ "0 (or `start`) up to `stop`.\n\n```godcode\n"
320
+ "FOR day IN RANGE(7)\n REVEAL(day)\nENDFOR\n```"
321
+ ),
322
+ "PUSH": (
323
+ "**PUSH**(list, value)\n\n"
324
+ "Add to the multitude — append `value` to the end of `list`.\n\n"
325
+ "```godcode\nPUSH(tribes, \"Benjamin\")\n```"
326
+ ),
327
+ "UPPER": (
328
+ "**UPPER**(text)\n\n"
329
+ "Lift every letter to the heavens — uppercase the string.\n\n"
330
+ "```godcode\nREVEAL(UPPER(\"hosanna\"))\n```"
331
+ ),
332
+ "LOWER": (
333
+ "**LOWER**(text)\n\n"
334
+ "Humble every letter — lowercase the string.\n\n"
335
+ "```godcode\nREVEAL(LOWER(\"HOSANNA\"))\n```"
336
+ ),
337
+ "SPLIT": (
338
+ "**SPLIT**(text, separator)\n\n"
339
+ "Divide the word — split `text` into a list at each `separator`.\n\n"
340
+ "```godcode\nDECLARE words AS SPLIT(\"loaves fishes\", \" \")\n```"
341
+ ),
342
+ "JOIN": (
343
+ "**JOIN**(list, separator)\n\n"
344
+ "Gather the scattered — join a list of strings with `separator`.\n\n"
345
+ "```godcode\nREVEAL(JOIN(words, \" \"))\n```"
346
+ ),
347
+ "ASK": (
348
+ "**ASK**([prompt])\n\n"
349
+ "Seek counsel — read a line from the one who runs the scroll.\n\n"
350
+ "```godcode\nDECLARE name AS ASK(\"What is your name? \")\n```"
351
+ ),
352
+ "BEHOLD": (
353
+ "**BEHOLD**()\n\n"
354
+ "Mark the present hour — the current time, in the heavens' own "
355
+ "notation.\n\n```godcode\nREVEAL(BEHOLD())\n```"
356
+ ),
357
+ "REVERSE": (
358
+ "**REVERSE**(value)\n\n"
359
+ "Turn it back upon itself — reverse a string or a list.\n\n"
360
+ "```godcode\nREVEAL(REVERSE(\"stressed\"))\n```"
361
+ ),
362
+ }
363
+
364
+ # Phrases matched as whole units on hover (case-insensitive).
365
+ _PHRASES = ("BEGIN CREATION", "END CREATION", "DEFINE RITE", "END RITE",
366
+ "BREATHE LIFE INTO")
367
+
368
+ # Snippet-style completions: (label, insertText, detail).
369
+ _SNIPPETS: tuple[tuple[str, str, str], ...] = (
370
+ ("BEGIN CREATION … END CREATION",
371
+ "BEGIN CREATION\n\t$0\nEND CREATION",
372
+ "Open a new scroll"),
373
+ ("DECLARE … AS …", "DECLARE ${1:name} AS ${2:value}$0",
374
+ "Declare a vessel"),
375
+ ("IF … THEN … ENDIF",
376
+ "IF ${1:condition} THEN\n\t$0\nENDIF",
377
+ "Weigh a condition"),
378
+ ("FOR … IN … ENDFOR",
379
+ "FOR ${1:item} IN ${2:list}\n\t$0\nENDFOR",
380
+ "Walk a multitude"),
381
+ ("WHILE … DO … ENDWHILE",
382
+ "WHILE ${1:condition} DO\n\t$0\nENDWHILE",
383
+ "Keep a vigil"),
384
+ ("DEFINE RITE … END RITE",
385
+ "DEFINE RITE ${1:name}(${2:params})\n\t$0\nEND RITE",
386
+ "Establish a rite"),
387
+ ("INVOKE …", "INVOKE ${1:name}(${2:args})$0", "Call a rite"),
388
+ )
389
+
390
+ _WORD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
391
+
392
+
393
+ # ---------------------------------------------------------------------------
394
+ # Diagnostics — the real parser, same as `godcode check`
395
+ # ---------------------------------------------------------------------------
396
+
397
+ def check_source(text: str) -> list[dict]:
398
+ """Parse *text* and return LSP diagnostics (empty when pure)."""
399
+ from godcode.errors import GodCodeError
400
+ from godcode.lexer import Lexer
401
+ from godcode.parser import Parser
402
+
403
+ try:
404
+ Parser(Lexer(text).lex()).parse()
405
+ except GodCodeError as err:
406
+ line = max((err.line or 1) - 1, 0) # LSP lines are 0-based
407
+ lines = text.splitlines()
408
+ line_len = len(lines[line]) if line < len(lines) else 0
409
+ start_char = max((err.col or 1) - 1, 0)
410
+ start_char = min(start_char, max(line_len - 1, 0))
411
+ end_char = min(start_char + 1, line_len)
412
+ return [{
413
+ "range": {
414
+ "start": {"line": line, "character": start_char},
415
+ "end": {"line": line, "character": end_char},
416
+ },
417
+ "severity": 1, # Error
418
+ "source": "godcode",
419
+ "message": str(err),
420
+ }]
421
+ return []
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # Hover
426
+ # ---------------------------------------------------------------------------
427
+
428
+ def _phrase_at(line_text: str, char: int) -> str | None:
429
+ upper = line_text.upper()
430
+ for phrase in _PHRASES:
431
+ start = 0
432
+ while True:
433
+ idx = upper.find(phrase, start)
434
+ if idx < 0:
435
+ break
436
+ if idx <= char <= idx + len(phrase):
437
+ return phrase
438
+ start = idx + 1
439
+ return None
440
+
441
+
442
+ def hover_word(line_text: str, char: int) -> str | None:
443
+ """Return the doc-table key under the cursor, or None."""
444
+ phrase = _phrase_at(line_text, char)
445
+ if phrase is not None:
446
+ return phrase
447
+ for match in _WORD_RE.finditer(line_text):
448
+ if match.start() <= char <= match.end():
449
+ return match.group(0).upper()
450
+ return None
451
+
452
+
453
+ def hover_markdown(word: str | None) -> str | None:
454
+ if word is None:
455
+ return None
456
+ return LSP_DOCS.get(word)
457
+
458
+
459
+ # ---------------------------------------------------------------------------
460
+ # Completion
461
+ # ---------------------------------------------------------------------------
462
+
463
+ _COMPLETION_KEYWORDS = sorted(LSP_DOCS)
464
+ _BUILTINS = ("LEN", "STR", "NUM", "TYPE", "RANDOM", "RANGE", "PUSH",
465
+ "UPPER", "LOWER", "SPLIT", "JOIN", "ASK", "BEHOLD", "REVERSE",
466
+ "ANCHOR", "CONSULT")
467
+
468
+
469
+ def completion_items() -> list[dict]:
470
+ items: list[dict] = []
471
+ for kw in _COMPLETION_KEYWORDS:
472
+ items.append({
473
+ "label": kw,
474
+ "kind": 14, # Keyword
475
+ "detail": "God Code " + ("built-in" if kw in _BUILTINS
476
+ else "keyword"),
477
+ "insertText": kw,
478
+ })
479
+ for label, insert, detail in _SNIPPETS:
480
+ items.append({
481
+ "label": label,
482
+ "kind": 15, # Snippet
483
+ "detail": detail,
484
+ "insertText": insert,
485
+ "insertTextFormat": 2, # Snippet
486
+ })
487
+ return items
488
+
489
+
490
+ # ---------------------------------------------------------------------------
491
+ # The server
492
+ # ---------------------------------------------------------------------------
493
+
494
+ def _log(message: str) -> None:
495
+ sys.stderr.write(f"[godcode-lsp] {message}\n")
496
+ sys.stderr.flush()
497
+
498
+
499
+ class LanguageServer:
500
+ """Hand-rolled LSP server over stdio."""
501
+
502
+ def __init__(self,
503
+ stdin: "io.BufferedReader | None" = None,
504
+ stdout: "io.BufferedWriter | None" = None) -> None:
505
+ import io
506
+ self.stdin = stdin or sys.stdin.buffer
507
+ self.stdout = stdout or sys.stdout.buffer
508
+ self.documents: dict[str, str] = {}
509
+ self._shutdown = False
510
+ self._handlers = {
511
+ "initialize": self._on_initialize,
512
+ "initialized": self._on_initialized,
513
+ "shutdown": self._on_shutdown,
514
+ "exit": self._on_exit,
515
+ "textDocument/didOpen": self._on_did_open,
516
+ "textDocument/didChange": self._on_did_change,
517
+ "textDocument/hover": self._on_hover,
518
+ "textDocument/completion": self._on_completion,
519
+ }
520
+
521
+ # -- transport --------------------------------------------------------
522
+ def _read_message(self) -> dict | None:
523
+ """Read one Content-Length framed message. None on clean EOF."""
524
+ headers: dict[str, str] = {}
525
+ while True:
526
+ line = self.stdin.readline()
527
+ if not line:
528
+ return None # EOF
529
+ line = line.decode("latin-1").strip()
530
+ if not line:
531
+ break
532
+ if ":" in line:
533
+ name, _, value = line.partition(":")
534
+ headers[name.strip().lower()] = value.strip()
535
+ try:
536
+ length = int(headers.get("content-length", ""))
537
+ except (TypeError, ValueError):
538
+ _log("malformed frame: bad Content-Length; skipping")
539
+ return {}
540
+ try:
541
+ body = self._read_exact(length)
542
+ except EOFError:
543
+ _log("malformed frame: truncated body; skipping")
544
+ return {}
545
+ try:
546
+ return json.loads(body.decode("utf-8"))
547
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
548
+ _log(f"malformed frame: {exc}; skipping")
549
+ return {}
550
+
551
+ def _read_exact(self, length: int) -> bytes:
552
+ body = b""
553
+ while len(body) < length:
554
+ chunk = self.stdin.read(length - len(body))
555
+ if not chunk:
556
+ raise EOFError("truncated frame")
557
+ body += chunk
558
+ return body
559
+
560
+ def _write(self, payload: dict) -> None:
561
+ body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
562
+ self.stdout.write(
563
+ f"Content-Length: {len(body)}\r\n\r\n".encode("ascii") + body)
564
+ self.stdout.flush()
565
+
566
+ def _notify(self, method: str, params: dict) -> None:
567
+ self._write({"jsonrpc": "2.0", "method": method, "params": params})
568
+
569
+ def _respond(self, msg_id, result) -> None:
570
+ self._write({"jsonrpc": "2.0", "id": msg_id, "result": result})
571
+
572
+ def _error(self, msg_id, code: int, message: str) -> None:
573
+ self._write({"jsonrpc": "2.0", "id": msg_id,
574
+ "error": {"code": code, "message": message}})
575
+
576
+ # -- main loop ----------------------------------------------------------
577
+ def serve(self) -> int:
578
+ _log("the sanctuary opens (stdio)")
579
+ while True:
580
+ message = self._read_message()
581
+ if message is None:
582
+ _log("stdin closed; ascending")
583
+ return 1 if not self._shutdown else 0
584
+ if not message:
585
+ continue # malformed frame: already logged, keep serving
586
+ method = message.get("method")
587
+ handler = self._handlers.get(method) if method else None
588
+ msg_id = message.get("id")
589
+ if handler is None:
590
+ _log(f"unknown method: {method!r}")
591
+ if msg_id is not None:
592
+ self._error(msg_id, -32601,
593
+ f"Method not found: {method}")
594
+ continue
595
+ try:
596
+ stop = handler(msg_id, message.get("params") or {})
597
+ except Exception as exc: # never let a handler kill the server
598
+ _log(f"handler for {method} failed: {exc}")
599
+ if msg_id is not None:
600
+ self._error(msg_id, -32603,
601
+ f"Internal error: {exc}")
602
+ continue
603
+ if stop:
604
+ return 0
605
+ # pragma: no cover - unreachable
606
+
607
+ # -- handlers -------------------------------------------------------------
608
+ def _on_initialize(self, msg_id, params: dict) -> None: # noqa: ARG002
609
+ _log("initialize received")
610
+ self._respond(msg_id, {
611
+ "capabilities": {
612
+ "textDocumentSync": 1, # Full
613
+ "hoverProvider": True,
614
+ "completionProvider": {"triggerCharacters": []},
615
+ },
616
+ "serverInfo": {"name": "godcode-lsp", "version": "4.0.0"},
617
+ })
618
+
619
+ def _on_initialized(self, msg_id, params: dict) -> None: # noqa: ARG002
620
+ _log("initialized; no-op")
621
+
622
+ def _on_shutdown(self, msg_id, params: dict) -> None: # noqa: ARG002
623
+ _log("shutdown requested")
624
+ self._shutdown = True
625
+ if msg_id is not None:
626
+ self._respond(msg_id, None)
627
+
628
+ def _on_exit(self, msg_id, params: dict) -> bool: # noqa: ARG002
629
+ _log("exit; the sanctuary rests")
630
+ return True
631
+
632
+ def _on_did_open(self, msg_id, params: dict) -> None: # noqa: ARG002
633
+ doc = params.get("textDocument", {})
634
+ uri = doc.get("uri", "")
635
+ text = doc.get("text", "")
636
+ self.documents[uri] = text
637
+ self._publish_diagnostics(uri, text)
638
+
639
+ def _on_did_change(self, msg_id, params: dict) -> None: # noqa: ARG002
640
+ doc = params.get("textDocument", {})
641
+ uri = doc.get("uri", "")
642
+ text = self.documents.get(uri, "")
643
+ for change in params.get("contentChanges", []):
644
+ if "range" not in change: # full-document sync
645
+ text = change.get("text", "")
646
+ self.documents[uri] = text
647
+ self._publish_diagnostics(uri, text)
648
+
649
+ def _publish_diagnostics(self, uri: str, text: str) -> None:
650
+ diagnostics = check_source(text)
651
+ _log(f"diagnostics for {uri}: {len(diagnostics)} error(s)")
652
+ self._notify("textDocument/publishDiagnostics",
653
+ {"uri": uri, "diagnostics": diagnostics})
654
+
655
+ def _on_hover(self, msg_id, params: dict) -> None:
656
+ doc = params.get("textDocument", {})
657
+ uri = doc.get("uri", "")
658
+ pos = params.get("position", {})
659
+ line_no = pos.get("line", 0)
660
+ char = pos.get("character", 0)
661
+ text = self.documents.get(uri, "")
662
+ lines = text.splitlines()
663
+ line_text = lines[line_no] if 0 <= line_no < len(lines) else ""
664
+ word = hover_word(line_text, char)
665
+ markdown = hover_markdown(word)
666
+ _log(f"hover at {uri}:{line_no}:{char} -> {word!r}")
667
+ self._respond(msg_id, {"contents": {"kind": "markdown",
668
+ "value": markdown}}
669
+ if markdown else None)
670
+
671
+ def _on_completion(self, msg_id, params: dict) -> None: # noqa: ARG002
672
+ self._respond(msg_id, completion_items())
673
+
674
+
675
+ def serve() -> int:
676
+ """Entry point for ``godcode lsp``."""
677
+ return LanguageServer().serve()