Skip to content

feat(lsp): hexa-native port of n6_lsp.py (PoC) - #4

Merged
dancinlife merged 2 commits into
mainfrom
feat/n6-lsp-hexa
May 25, 2026
Merged

feat(lsp): hexa-native port of n6_lsp.py (PoC)#4
dancinlife merged 2 commits into
mainfrom
feat/n6-lsp-hexa

Conversation

@dancinlife

Copy link
Copy Markdown
Contributor

What

Hexa-native port of the n6 LSP server (lsp/n6_lsp.py → new lsp/n6_lsp.hexa), per dancinlab governance g1 (hexa-native). The .py is KEPT — the ~/.local/bin/n6-lsp wrapper still points at it; repointing the wrapper to the .hexa is a separate follow-up (see below). PoC to be replicated for hxc + kosmos.

Standalone (does NOT use hexa-lang's self/lsp): the LSP stdin/stdout framing is implemented INLINE, copying the shape from hexa-lang main's self/lsp/protocol.hexa / server.hexa (PR #1163). Ported features: TYPES/EDGES maps, validate_n6(text) diagnostics, hover(line), read_message()/write_message() framing, and the serve() dispatch loop (initialize / shutdown / exit / didOpen / didChange / didClose / hover).

Verify (built + run via hexa-lang main hexa-fast, all local POOL_DISABLE=1)

Test 1 — framing + loop (two framed messages → two framed responses + clean exit)

Input: initialize then shutdown. od -c of stdout:

Content-Length: 125\r\n\r\n{"jsonrpc":"2.0","id":1,"result":{"capabilities":{"textDocumentSync":1,"hoverProvider":true},"serverInfo":{"name":"n6-lsp"}}}
Content-Length: 38\r\n\r\n{"jsonrpc":"2.0","id":2,"result":null}

EXIT: 0 (clean EOF exit). Two correctly framed responses; hoverProvider:true present.

Test 2 — diagnostics with a RAW newline in the text (byte-exact framing proof)

Input: didOpen with text = @Z foo\n bogus continuation (the \n is a real newline inside the JSON string). Output frame Content-Length: 688 and the 688-byte body is:

{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///t.n6","diagnostics":[
  {"range":{"start":{"line":0,"character":1},"end":{"line":0,"character":2}},"severity":1,"source":"n6-lsp","message":"unknown entry type @Z (alphabet: @P @C @L @F @R @S @X @? @E)"},
  {"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":6}},"severity":1,"source":"n6-lsp","message":"malformed header — expected `@<type> <id>[ = <expr>] :: <domain> [<grade>]`"},
  {"range":{"start":{"line":1,"character":2},"end":{"line":1,"character":20}},"severity":2,"source":"n6-lsp","message":"unrecognised continuation — expected an edge (<- -> => == ~> |> !!) or \"prose\""}
]}}

The raw \n was decoded to a real newline and split into line 0 / line 1 — the reader did NOT stop at the newline (Content-Length 688 = full body). LEN matches.

Parity cross-check (.py --check vs .hexa didOpen, identical mixed sample)

.py and .hexa produce the SAME four diagnostics (unknown @z, malformed header, unrecognised continuation [hint/sev2], continuation-not-2-space [sev1]) on the same file. Hover also verified end-to-end: a valid @F gravity :: physics [C] doc yields empty diagnostics, hover on the header → **@F** — Formula — explicit functional form, hover on <- mass**<-** — depends_on edge.

Hexa-porting gotchas (for the hxc/kosmos replication)

  • read_stdin_n_c returns a char* AS AN INT (a pointer). Declare extern fn read_stdin_n_c(n: int) -> int and wrap with from_cstring(read_stdin_n_c(N)). A -> str return does NOT auto-convert — it hands back the raw pointer integer.
  • No null literal — the parser rejects it. The docs map (uri→text) is built with #{}; membership is has_key(docs, uri), not != null. The LSP result: null is just the literal JSON string null baked into the response body.
  • No usable re — the two .py regexes (HDR, KV) are hand-rolled with string ops. HDR: starts_with("@") + known type letter at [1] + whitespace after it + contains :: + a [ after :: with a non-empty domain token + trimmed line ends_with("]"). KV: index_of("=") > 0, key (substring before =, trim_end) non-empty and contains no space/tab.
  • hexa-fast mis-handles null in its fast path — another reason to keep null out of the .hexa source entirely.
  • Whitespace-tolerant JSON key extraction is REQUIRED. The reference protocol.hexa extractors match the COMPACT form "method":"x" only. Python json.dumps (and many real clients) emit "method": "x" (space after the colon). The naive matcher silently missed those and fell through to the unknown-method branch. Fix shipped here: value_start(msg, name) finds "<name>", skips whitespace around the :, returns the value index; read_string_at decodes from there. Replication note: copy these two helpers, do NOT reuse protocol.hexa's space-sensitive extractors verbatim.
  • Stdout is unbuffered (printwrite(2)), no flush needed — matches the .py flush calls' effect.

Fidelity gaps vs the .py

  • BOM diagnostic dropped. The .py emits byte-canonical: UTF-8 with no BOM when text starts with . Skipped here (encoding-fragile in hexa). The CRLF check (byte-canonical: LF line endings only, via text.contains("\r")) is KEPT and verified.
  • --check one-shot CLI mode not ported. The .hexa is the stdio LSP server only (the wrapper invokes it that way). --check FILE (read a file, print lint, exit 1 on error) can be added when the wrapper is repointed.
  • didChange uses the FIRST "text", not the LAST. The .py takes contentChanges[-1].text. Under textDocumentSync: 1 (full sync — what this server advertises) clients send exactly one change, so first == last. Equivalent in practice for this sync mode.

Follow-up (NOT in this PR)

  • Repoint ~/.local/bin/n6-lsp from the .py to the built .hexa (separate change; the .py stays as the canonical reference until then).
  • Optionally restore the BOM diagnostic + --check mode once the encoding/CLI surface is settled.

dancinlife and others added 2 commits May 26, 2026 04:09
Port the n6 LSP server from Python to hexa-lang (dancinlab g1
hexa-native). New lsp/n6_lsp.hexa alongside the kept .py. Inline
LSP stdin/stdout framing (read_stdin_n_c + from_cstring), hand-rolled
HDR/KV checks (no re), substring JSON extraction (no json_parse dep),
docs map via #{} + has_key (no null literal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…oads)

Real LSP clients and Python json.dumps emit "method": "x" (space after
the colon); the initial compact-only substring matcher missed those.
Add value_start() (skips whitespace around the colon) + read_string_at()
and route all five extractors through them. Now matches the .py
--check output byte-for-byte on a mixed sample.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dancinlife
dancinlife merged commit 030d5a1 into main May 25, 2026
@dancinlife
dancinlife deleted the feat/n6-lsp-hexa branch May 25, 2026 19:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant