From 4da250ec85be428695fb5c4b93a9f9cc8ca6419c Mon Sep 17 00:00:00 2001 From: NikolajDanger Date: Sat, 30 May 2026 19:07:01 +0200 Subject: [PATCH] :goat: LSP --- centvrion-lsp | 5 + centvrion/lsp/__init__.py | 6 + centvrion/lsp/analysis.py | 210 +++++++++++++++++++++++++++++ centvrion/lsp/diagnostics.py | 21 +++ centvrion/lsp/hover.py | 38 ++++++ centvrion/lsp/server.py | 89 ++++++++++++ tests/14_test_lsp_____.py | 113 ++++++++++++++++ vscode-extension/.gitignore | 3 + vscode-extension/.vscodeignore | 4 + vscode-extension/package-lock.json | 139 +++++++++++++++++++ vscode-extension/package.json | 32 ++++- vscode-extension/src/extension.ts | 55 ++++++++ vscode-extension/tsconfig.json | 12 ++ 13 files changed, 724 insertions(+), 3 deletions(-) create mode 100755 centvrion-lsp create mode 100644 centvrion/lsp/__init__.py create mode 100644 centvrion/lsp/analysis.py create mode 100644 centvrion/lsp/diagnostics.py create mode 100644 centvrion/lsp/hover.py create mode 100644 centvrion/lsp/server.py create mode 100644 tests/14_test_lsp_____.py create mode 100644 vscode-extension/.gitignore create mode 100644 vscode-extension/package-lock.json create mode 100644 vscode-extension/src/extension.ts create mode 100644 vscode-extension/tsconfig.json diff --git a/centvrion-lsp b/centvrion-lsp new file mode 100755 index 0000000..a38bd23 --- /dev/null +++ b/centvrion-lsp @@ -0,0 +1,5 @@ +#! /usr/bin/env python +from centvrion.lsp import run + +if __name__ == "__main__": + run() diff --git a/centvrion/lsp/__init__.py b/centvrion/lsp/__init__.py new file mode 100644 index 0000000..7010a66 --- /dev/null +++ b/centvrion/lsp/__init__.py @@ -0,0 +1,6 @@ +"""CENTVRION language server package.""" + +def run() -> None: + """Start the LSP server over stdio. Blocks until stdin closes.""" + from centvrion.lsp.server import create_server + create_server().start_io() diff --git a/centvrion/lsp/analysis.py b/centvrion/lsp/analysis.py new file mode 100644 index 0000000..f21ec40 --- /dev/null +++ b/centvrion/lsp/analysis.py @@ -0,0 +1,210 @@ +"""Pure analysis helpers over the CENTVRION AST. + +Functions here take source text and zero-based (line, character) LSP positions, +and return plain Python values. No pygls types. The server layer adapts these +to LSP messages. +""" +from dataclasses import dataclass +from typing import Iterator, Optional, Union + +from rply.errors import LexingError + +from centvrion import ast_nodes +from centvrion.ast_nodes import ( + Defini, Designa, DesignaDestructure, DesignaIndex, Fvnctio, ID, Invoca, + Program, +) +from centvrion.lexer import Lexer +from centvrion.parser import Parser + + +@dataclass +class ParseFailure: + """Lex or parse error with source position. + + line/character are zero-based (LSP convention). length is the number of + characters the squiggle should cover (always >= 1). + """ + line: int + character: int + length: int + message: str + + +def parse(source: str) -> Union[Program, ParseFailure]: + lexer = Lexer().get_lexer() + try: + tokens = lexer.lex(source + "\n") + program = Parser().parse(tokens) + except LexingError as e: + # rply's colno is sometimes off by one for the failing character, but idx + # is reliable, so compute (line, col) from idx. + line, character = _idx_to_line_col(source, e.source_pos.idx) + bad = source[e.source_pos.idx] if e.source_pos.idx < len(source) else "?" + return ParseFailure( + line=line, + character=character, + length=1, + message=f"Invalid character {bad!r}", + ) + except SyntaxError as e: + line, character = _extract_pos(str(e)) + return ParseFailure( + line=line, + character=character, + length=1, + message=str(e), + ) + if not isinstance(program, Program): + return ParseFailure(0, 0, 1, "Parser did not return a Program") + return program + + +def _idx_to_line_col(source: str, idx: int) -> tuple[int, int]: + if idx < 0: + return 0, 0 + prefix = source[:idx] + line = prefix.count("\n") + last_nl = prefix.rfind("\n") + character = idx - (last_nl + 1) if last_nl >= 0 else idx + return line, character + + +def _extract_pos(msg: str) -> tuple[int, int]: + """Pull (line, col) out of the parser's 'at line N, column M' error format. + + The CENTVRION parser's error handler (parser.py:476) builds messages like + 'Unexpected token KEYWORD_X at line 3, column 5'. Falls back to (0, 0). + """ + import re + m = re.search(r"line (\d+), column (\d+)", msg) + if m: + return max(int(m.group(1)) - 1, 0), max(int(m.group(2)) - 1, 0) + return 0, 0 + + +def walk(node) -> Iterator: + """Yield every AST node reachable from `node`, including `node` itself. + + Traverses any attribute that is a Node, ast_nodes.Program, list of those, or + list of (key, value) pairs. Sufficient for every concrete node in + ast_nodes.py. + """ + if node is None: + return + yield node + for value in vars(node).values(): + yield from _walk_value(value) + + +def _walk_value(value): + if value is None: + return + if isinstance(value, (ast_nodes.Node, Program)): + yield from walk(value) + elif isinstance(value, list): + for item in value: + if isinstance(item, tuple): + for sub in item: + yield from _walk_value(sub) + else: + yield from _walk_value(item) + + +@dataclass +class Definition: + """A symbol definition with its source position (zero-based).""" + name: str + line: int + character: int + kind: str # "function" or "variable" + + +def collect_definitions(program: Program) -> dict[str, Definition]: + """Return name -> Definition for every Defini and Designa-style assignment. + + Functions take precedence over variables when names collide; among + variables, the first assignment wins (CENTVRION re-assigns with the same + DESIGNA keyword, but go-to-def should land on the introducing site). + """ + funcs: dict[str, Definition] = {} + vars_: dict[str, Definition] = {} + for node in walk(program): + if isinstance(node, Defini) and node.name.pos is not None: + line, character = _to_zero_based(node.name.pos) + funcs.setdefault(node.name.name, Definition( + name=node.name.name, line=line, character=character, kind="function", + )) + elif isinstance(node, (Designa, DesignaIndex)) and node.id.pos is not None: + line, character = _to_zero_based(node.id.pos) + vars_.setdefault(node.id.name, Definition( + name=node.id.name, line=line, character=character, kind="variable", + )) + elif isinstance(node, DesignaDestructure): + for id_node in node.ids: + if id_node.pos is not None: + line, character = _to_zero_based(id_node.pos) + vars_.setdefault(id_node.name, Definition( + name=id_node.name, line=line, character=character, kind="variable", + )) + out = dict(vars_) + out.update(funcs) + return out + + +def definition_at(source: str, line: int, character: int) -> Optional[Definition]: + """Resolve an identifier at (line, character) to its definition site. + + Re-lexes (so this still works while the file fails to parse for unrelated + reasons), finds an ID token under the cursor, then looks it up against the + AST's definition table. Returns None if no identifier is at that position, + if parsing fails outright, or if the name has no definition. + """ + token = token_at(source, line, character) + if token is None or token.gettokentype() != "ID": + return None + result = parse(source) + if isinstance(result, ParseFailure): + return None + return collect_definitions(result).get(token.getstr()) + + +_WORD_TOKENS = ("ID", "DATA_NUMERAL", "BUILTIN", "MODULE") + + +def token_at(source: str, line: int, character: int): + """Return the rply Token under the cursor at (line, character), or None. + + Cursor position N (0-based) sits between characters N-1 and N, so a token + covering character columns [start, end) matches for any cursor in + [start, end] (end-inclusive). When two tokens touch the cursor at their + shared boundary (e.g. cursor between `digits` and `)`), prefer the + identifier-like one so go-to-def behaves the way users expect from other + editors. Tolerant of LexingError: works on whatever tokens were produced + before the failure. + """ + lexer = Lexer().get_lexer() + try: + tokens = list(lexer.lex(source + "\n")) + except LexingError: + return None + target_line = line + 1 + target_col = character + 1 + candidates = [] + for tok in tokens: + sp = tok.source_pos + if sp is None or sp.lineno != target_line: + continue + start = sp.colno + end = start + len(tok.getstr()) + if start <= target_col <= end: + candidates.append(tok) + for tok in candidates: + if tok.gettokentype() in _WORD_TOKENS: + return tok + return candidates[0] if candidates else None + + +def _to_zero_based(pos: tuple[int, int]) -> tuple[int, int]: + """rply (lineno, colno) is 1-based; LSP wants 0-based (line, character).""" + return pos[0] - 1, pos[1] - 1 diff --git a/centvrion/lsp/diagnostics.py b/centvrion/lsp/diagnostics.py new file mode 100644 index 0000000..d9ed26a --- /dev/null +++ b/centvrion/lsp/diagnostics.py @@ -0,0 +1,21 @@ +"""Convert CENTVRION parse failures into LSP-style diagnostic dicts. + +Returns a list of plain dicts so the server layer can map them to +lsprotocol types without this module importing pygls. Runtime errors +are explicitly out of scope: we only catch lex and parse failures. +""" +from centvrion.lsp.analysis import ParseFailure, parse + + +def build_diagnostics(source: str) -> list[dict]: + result = parse(source) + if not isinstance(result, ParseFailure): + return [] + return [{ + "line": result.line, + "character": result.character, + "length": result.length, + "message": result.message, + "severity": 1, # Error + "source": "centvrion", + }] diff --git a/centvrion/lsp/hover.py b/centvrion/lsp/hover.py new file mode 100644 index 0000000..efdf57a --- /dev/null +++ b/centvrion/lsp/hover.py @@ -0,0 +1,38 @@ +"""Hover information for the LSP server. + +Currently only handles Roman numeral literals: hovering a DATA_NUMERAL token +shows its decimal value. The MAGNVM / SVBNVLLA module gating is bypassed for +display purposes; if the user has written it, we'll show the decimal. +""" +from dataclasses import dataclass +from typing import Optional + +from centvrion.ast_nodes import num_to_int +from centvrion.errors import CentvrionError + +from centvrion.lsp.analysis import token_at + + +@dataclass +class HoverInfo: + text: str + line: int + start_character: int + end_character: int + + +def hover_at(source: str, line: int, character: int) -> Optional[HoverInfo]: + token = token_at(source, line, character) + if token is None or token.gettokentype() != "DATA_NUMERAL": + return None + try: + value = num_to_int(token.getstr(), m=True, s=True) + except CentvrionError: + return None + start_col = token.source_pos.colno - 1 + return HoverInfo( + text=f"`{token.getstr()}` = **{value}**", + line=line, + start_character=start_col, + end_character=start_col + len(token.getstr()), + ) diff --git a/centvrion/lsp/server.py b/centvrion/lsp/server.py new file mode 100644 index 0000000..3f952b8 --- /dev/null +++ b/centvrion/lsp/server.py @@ -0,0 +1,89 @@ +"""pygls language server for CENTVRION. + +Thin adapter: protocol handlers convert LSP params to source + position, +delegate to the pure analysis/hover/diagnostics modules, and convert results +back to lsprotocol types. +""" +import asyncio + +import lsprotocol.types as t +from pygls.lsp.server import LanguageServer + +from centvrion.lsp import analysis, diagnostics, hover + + +_DEBOUNCE_SECONDS = 0.15 +_pending: dict[str, asyncio.Task] = {} + + +def create_server() -> LanguageServer: + ls = LanguageServer("centvrion-lsp", "0.1.0") + + @ls.feature(t.TEXT_DOCUMENT_DID_OPEN) + def did_open(params: t.DidOpenTextDocumentParams): + _publish(ls, params.text_document.uri) + + @ls.feature(t.TEXT_DOCUMENT_DID_CHANGE) + def did_change(params: t.DidChangeTextDocumentParams): + uri = params.text_document.uri + task = _pending.pop(uri, None) + if task is not None and not task.done(): + task.cancel() + _pending[uri] = asyncio.get_event_loop().create_task(_debounced_publish(ls, uri)) + + @ls.feature(t.TEXT_DOCUMENT_HOVER) + def on_hover(params: t.HoverParams): + doc = ls.workspace.get_text_document(params.text_document.uri) + info = hover.hover_at(doc.source, params.position.line, params.position.character) + if info is None: + return None + return t.Hover( + contents=t.MarkupContent(kind=t.MarkupKind.Markdown, value=info.text), + range=t.Range( + start=t.Position(line=info.line, character=info.start_character), + end=t.Position(line=info.line, character=info.end_character), + ), + ) + + @ls.feature(t.TEXT_DOCUMENT_DEFINITION) + def on_definition(params: t.DefinitionParams): + doc = ls.workspace.get_text_document(params.text_document.uri) + defn = analysis.definition_at(doc.source, params.position.line, params.position.character) + if defn is None: + return None + return t.Location( + uri=params.text_document.uri, + range=t.Range( + start=t.Position(line=defn.line, character=defn.character), + end=t.Position(line=defn.line, character=defn.character + len(defn.name)), + ), + ) + + return ls + + +async def _debounced_publish(ls: LanguageServer, uri: str) -> None: + try: + await asyncio.sleep(_DEBOUNCE_SECONDS) + except asyncio.CancelledError: + return + _publish(ls, uri) + + +def _publish(ls: LanguageServer, uri: str) -> None: + doc = ls.workspace.get_text_document(uri) + diags = [ + t.Diagnostic( + range=t.Range( + start=t.Position(line=d["line"], character=d["character"]), + end=t.Position(line=d["line"], character=d["character"] + d["length"]), + ), + message=d["message"], + severity=t.DiagnosticSeverity(d["severity"]), + source=d["source"], + ) + for d in diagnostics.build_diagnostics(doc.source) + ] + ls.text_document_publish_diagnostics( + t.PublishDiagnosticsParams(uri=uri, diagnostics=diags) + ) diff --git a/tests/14_test_lsp_____.py b/tests/14_test_lsp_____.py new file mode 100644 index 0000000..c0c99a7 --- /dev/null +++ b/tests/14_test_lsp_____.py @@ -0,0 +1,113 @@ +import unittest + +from parameterized import parameterized + +from centvrion.lsp.analysis import ( + Definition, ParseFailure, collect_definitions, definition_at, parse, +) +from centvrion.lsp.diagnostics import build_diagnostics +from centvrion.lsp.hover import hover_at + + +# --- Diagnostics --- +# (source, expected_diagnostic_count, expected_first_position_or_None) +# Positions are 0-based (line, character). + +diagnostic_tests = [ + ("DESIGNA foo VT X", 0, None), + ("DIC(\"hello\")", 0, None), + ("DEFINI greet (x) VT {\n DIC(x)\n}", 0, None), + ("DESIGNA foo VT @@@", 1, (0, 15)), + ("DESIGNA foo VT", 1, (0, 14)), + ("DIC(\"unclosed", 1, (0, 4)), +] + +class TestDiagnostics(unittest.TestCase): + @parameterized.expand(diagnostic_tests) + def test_diagnostics(self, source, expected_count, expected_pos): + diags = build_diagnostics(source) + self.assertEqual(len(diags), expected_count) + if expected_pos is not None: + self.assertEqual((diags[0]["line"], diags[0]["character"]), expected_pos) + self.assertEqual(diags[0]["severity"], 1) + self.assertEqual(diags[0]["source"], "centvrion") + + def test_parse_returns_program_on_success(self): + result = parse("DIC(\"hi\")") + self.assertNotIsInstance(result, ParseFailure) + + def test_parse_returns_failure_on_error(self): + result = parse("DESIGNA") + self.assertIsInstance(result, ParseFailure) + + +# --- Hover --- +# (source, line, char, expected_text_substring_or_None) + +hover_tests = [ + ("DIC(X)", 0, 4, "10"), + ("DIC(III)", 0, 4, "3"), + ("DIC(MMXXV)", 0, 4, "2025"), + ("DIC(IV)", 0, 4, "4"), + ("DIC(X)", 0, 0, None), + ("DESIGNA foo VT X", 0, 8, None), + ("DESIGNA foo VT X\nDIC(@@@", 0, 15, "10"), +] + +class TestHover(unittest.TestCase): + @parameterized.expand(hover_tests) + def test_hover(self, source, line, char, expected): + info = hover_at(source, line, char) + if expected is None: + self.assertIsNone(info) + else: + self.assertIsNotNone(info) + self.assertIn(expected, info.text) + + def test_hover_range_covers_numeral(self): + info = hover_at("DIC(MMXXV)", 0, 4) + self.assertEqual(info.line, 0) + self.assertEqual(info.start_character, 4) + self.assertEqual(info.end_character, 9) + + +# --- Definitions / go-to-def --- + +definition_tests = [ + # variable use in DIC -> DESIGNA site + ("DESIGNA foo VT X\nDIC(foo)", 1, 4, ("foo", 0, 8, "variable")), + # function call -> DEFINI site + ("DEFINI greet (x) VT {\n DIC(x)\n}\nINVOCA greet (V)", 3, 8, ("greet", 0, 7, "function")), + # parameter inside body -> not a definition site we track; returns None + ("DEFINI greet (x) VT {\n DIC(x)\n}", 1, 6, None), + # undefined identifier + ("DIC(missing)", 0, 4, None), + # destructuring assignment + ("DESIGNA a, b VT [I, II]\nDIC(a)\nDIC(b)", 1, 4, ("a", 0, 8, "variable")), + ("DESIGNA a, b VT [I, II]\nDIC(a)\nDIC(b)", 2, 4, ("b", 0, 11, "variable")), + # cursor right after identifier (typical F12 position): the identifier wins + # over the adjacent punctuation, matching standard editor behavior. + ("DESIGNA foo VT X\nDIC(foo)", 1, 7, ("foo", 0, 8, "variable")), +] + +class TestDefinition(unittest.TestCase): + @parameterized.expand(definition_tests) + def test_definition(self, source, line, char, expected): + result = definition_at(source, line, char) + if expected is None: + self.assertIsNone(result) + else: + name, exp_line, exp_char, kind = expected + self.assertEqual( + result, + Definition(name=name, line=exp_line, character=exp_char, kind=kind), + ) + + def test_functions_shadow_variables_with_same_name(self): + src = "DESIGNA bar VT X\nDEFINI bar () VT {\n DIC(I)\n}\nINVOCA bar ()" + defs = collect_definitions(parse(src)) + self.assertEqual(defs["bar"].kind, "function") + + +if __name__ == "__main__": + unittest.main() diff --git a/vscode-extension/.gitignore b/vscode-extension/.gitignore new file mode 100644 index 0000000..d3e15b1 --- /dev/null +++ b/vscode-extension/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +out/ +*.vsix diff --git a/vscode-extension/.vscodeignore b/vscode-extension/.vscodeignore index f369b5e..34c49d4 100644 --- a/vscode-extension/.vscodeignore +++ b/vscode-extension/.vscodeignore @@ -2,3 +2,7 @@ .vscode-test/** .gitignore vsc-extension-quickstart.md +src/** +tsconfig.json +node_modules/** +out/**/*.map diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json new file mode 100644 index 0000000..e03c013 --- /dev/null +++ b/vscode-extension/package-lock.json @@ -0,0 +1,139 @@ +{ + "name": "centvrion", + "version": "0.0.2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "centvrion", + "version": "0.0.2", + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.68.0", + "typescript": "^5.4.0" + }, + "engines": { + "vscode": "^1.68.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/vscode": { + "version": "1.120.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", + "integrity": "sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageclient": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz", + "integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==", + "license": "MIT", + "dependencies": { + "minimatch": "^5.1.0", + "semver": "^7.3.7", + "vscode-languageserver-protocol": "3.17.5" + }, + "engines": { + "vscode": "^1.82.0" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "license": "MIT" + } + } +} diff --git a/vscode-extension/package.json b/vscode-extension/package.json index c17281a..f02c366 100644 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,13 +2,17 @@ "name": "centvrion", "displayName": "centvrion", "description": "Latin-inspired esoteric programming language with Roman numeral literals", - "version": "0.0.1", + "version": "0.0.2", "engines": { "vscode": "^1.68.0" }, "categories": [ "Programming Languages" ], + "main": "./out/extension.js", + "activationEvents": [ + "onLanguage:cent" + ], "contributes": { "languages": [{ "id": "cent", @@ -24,6 +28,28 @@ "snippets": [{ "language": "cent", "path": "./snippets/cent.json" - }] + }], + "configuration": { + "title": "CENTVRION", + "properties": { + "centvrion.languageServer.path": { + "type": "string", + "default": "centvrion-lsp", + "description": "Path to the centvrion-lsp executable (or just the name if on PATH)." + } + } + } + }, + "scripts": { + "compile": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, + "dependencies": { + "vscode-languageclient": "^9.0.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/vscode": "^1.68.0", + "typescript": "^5.4.0" } -} \ No newline at end of file +} diff --git a/vscode-extension/src/extension.ts b/vscode-extension/src/extension.ts new file mode 100644 index 0000000..ff103ed --- /dev/null +++ b/vscode-extension/src/extension.ts @@ -0,0 +1,55 @@ +import * as fs from "fs"; +import * as path from "path"; +import { workspace, ExtensionContext } from "vscode"; +import { + LanguageClient, + LanguageClientOptions, + ServerOptions, + TransportKind, +} from "vscode-languageclient/node"; + +let client: LanguageClient | undefined; + +const DEFAULT_COMMAND = "centvrion-lsp"; + +function resolveServerCommand(): string { + const configured = workspace + .getConfiguration("centvrion") + .get("languageServer.path", DEFAULT_COMMAND); + if (configured !== DEFAULT_COMMAND) { + return configured; + } + for (const folder of workspace.workspaceFolders ?? []) { + const candidate = path.join(folder.uri.fsPath, "centvrion-lsp"); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return configured; +} + +export function activate(_context: ExtensionContext) { + const command = resolveServerCommand(); + + const serverOptions: ServerOptions = { + command, + transport: TransportKind.stdio, + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [{ scheme: "file", language: "cent" }], + }; + + client = new LanguageClient( + "centvrion", + "CENTVRION Language Server", + serverOptions, + clientOptions, + ); + + client.start(); +} + +export function deactivate(): Thenable | undefined { + return client?.stop(); +} diff --git a/vscode-extension/tsconfig.json b/vscode-extension/tsconfig.json new file mode 100644 index 0000000..6c9a9b5 --- /dev/null +++ b/vscode-extension/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2020", + "outDir": "out", + "lib": ["ES2020"], + "sourceMap": true, + "rootDir": "src", + "strict": true + }, + "exclude": ["node_modules", ".vscode-test"] +}