Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4da250ec85 | |||
| 1d6a93be32 | |||
| 19f8cb5232 |
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#! /usr/bin/env python
|
||||
from centvrion.lsp import run
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
+12
-23
@@ -1287,10 +1287,6 @@ class PerStatement(Node):
|
||||
def __eq__(self, other):
|
||||
return type(self) == type(other) and self.data_list == other.data_list and self.variable_name == other.variable_name and self.statements == other.statements
|
||||
|
||||
@property
|
||||
def destructure(self):
|
||||
return isinstance(self.variable_name, list)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
test = repr(self.data_list)
|
||||
variable_name = repr(self.variable_name)
|
||||
@@ -1299,25 +1295,21 @@ class PerStatement(Node):
|
||||
return f"Per({dum_string})"
|
||||
|
||||
def print(self):
|
||||
body = "\n".join(s.print() for s in self.statements)
|
||||
if self.destructure:
|
||||
var_str = ", ".join(v.print() for v in self.variable_name)
|
||||
# Re-sugar the parse-time destructure rewrite back into `PER a, b IN ...`
|
||||
# so the printed source round-trips through the lexer (which rejects `#`).
|
||||
if (isinstance(self.variable_name, ID)
|
||||
and self.variable_name.name == "#per_item"
|
||||
and self.statements
|
||||
and isinstance(self.statements[0], DesignaDestructure)
|
||||
and isinstance(self.statements[0].value, ID)
|
||||
and self.statements[0].value.name == "#per_item"):
|
||||
var_str = ", ".join(i.print() for i in self.statements[0].ids)
|
||||
body = "\n".join(s.print() for s in self.statements[1:])
|
||||
else:
|
||||
var_str = self.variable_name.print()
|
||||
body = "\n".join(s.print() for s in self.statements)
|
||||
return f"PER {var_str} IN {self.data_list.print()} FAC {{\n{body}\n}}"
|
||||
|
||||
def _assign_loop_var(self, vtable, item):
|
||||
if self.destructure:
|
||||
if not isinstance(item, ValList):
|
||||
raise CentvrionError("Cannot destructure non-array value in PER loop")
|
||||
if len(item.value()) != len(self.variable_name):
|
||||
raise CentvrionError(
|
||||
f"Destructuring mismatch: {len(self.variable_name)} targets, {len(item.value())} values")
|
||||
for id_node, val in zip(self.variable_name, item.value()):
|
||||
vtable[id_node.name] = val
|
||||
else:
|
||||
vtable[self.variable_name.name] = item
|
||||
|
||||
def _eval(self, vtable):
|
||||
vtable, array = self.data_list.eval(vtable)
|
||||
if isinstance(array, ValDict):
|
||||
@@ -1327,7 +1319,7 @@ class PerStatement(Node):
|
||||
raise CentvrionError("PER requires an array or dict")
|
||||
last_val = ValNul()
|
||||
for item in array:
|
||||
self._assign_loop_var(vtable, item)
|
||||
vtable[self.variable_name.name] = item
|
||||
for statement in self.statements:
|
||||
vtable, val = statement.eval(vtable)
|
||||
if vtable["#break"] or vtable["#continue"] or vtable["#return"] is not None:
|
||||
@@ -1474,9 +1466,6 @@ class BuiltIn(Node):
|
||||
)
|
||||
print(print_string)
|
||||
return vtable, ValStr(print_string)
|
||||
case "ERVMPE":
|
||||
vtable["#break"] = True
|
||||
return vtable, ValNul()
|
||||
case "FORTVITVS_NVMERVS":
|
||||
if "FORS" not in vtable["#modules"]:
|
||||
raise CentvrionError("Cannot use 'FORTVITVS_NVMERVS' without module 'FORS'")
|
||||
|
||||
@@ -310,11 +310,6 @@ def _emit_builtin(node, ctx):
|
||||
else:
|
||||
lines.append(f"CentValue {tmp} = cent_senatus(NULL, 0);")
|
||||
|
||||
case "ERVMPE":
|
||||
# break as expression (side-effecting; result is unused)
|
||||
lines.append("break;")
|
||||
lines.append(f"CentValue {tmp} = cent_null();")
|
||||
|
||||
case "CLAVES":
|
||||
lines.append(f"CentValue {tmp} = cent_dict_keys({param_vars[0]});")
|
||||
|
||||
|
||||
@@ -90,27 +90,6 @@ def _emit_stmt_body(node, ctx):
|
||||
arr_lines, arr_var = emit_expr(node.data_list, ctx)
|
||||
i_var = ctx.fresh_tmp()
|
||||
body_lines = _emit_body(node.statements, ctx)
|
||||
|
||||
if node.destructure:
|
||||
# Destructuring PER — each element must be a list
|
||||
elem_var = ctx.fresh_tmp()
|
||||
assign_lines = [
|
||||
f"CentValue {elem_var} = {arr_var}.lval.items[{i_var}];",
|
||||
f'if ({elem_var}.type != CENT_LIST) cent_type_error("Cannot destructure non-array value in PER loop");',
|
||||
f'if ({elem_var}.lval.len != {len(node.variable_name)}) cent_runtime_error("Destructuring mismatch");',
|
||||
]
|
||||
for j, id_node in enumerate(node.variable_name):
|
||||
tmp = ctx.fresh_tmp()
|
||||
assign_lines.append(f"CentValue {tmp} = cent_list_index({elem_var}, cent_int({j + 1}));")
|
||||
assign_lines.append(f'cent_scope_set(&_scope, "{id_node.name}", {tmp});')
|
||||
lines = arr_lines + [
|
||||
f'if ({arr_var}.type != CENT_LIST) cent_type_error("PER requires an array");',
|
||||
f"for (int {i_var} = 0; {i_var} < {arr_var}.lval.len; {i_var}++) {{",
|
||||
]
|
||||
lines += [f" {l}" for l in assign_lines]
|
||||
lines += [f" {l}" for l in body_lines]
|
||||
lines += ["}"]
|
||||
else:
|
||||
var_name = node.variable_name.name
|
||||
lines = arr_lines + [
|
||||
f"if ({arr_var}.type == CENT_DICT) {{",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}]
|
||||
@@ -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()),
|
||||
)
|
||||
@@ -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)
|
||||
)
|
||||
+5
-1
@@ -284,7 +284,11 @@ class Parser():
|
||||
|
||||
@self.pg.production('per_statement : KEYWORD_PER id SYMBOL_COMMA id_list_rest KEYWORD_IN expression KEYWORD_FAC SYMBOL_LCURL statements SYMBOL_RCURL')
|
||||
def per_destructure(tokens):
|
||||
return _at(ast_nodes.PerStatement(tokens[5], [tokens[1]] + tokens[3], tokens[8]), tokens[0])
|
||||
ids = [tokens[1]] + tokens[3]
|
||||
sentinel = ast_nodes.ID("#per_item")
|
||||
destructure = _at(ast_nodes.DesignaDestructure(ids, sentinel), tokens[0])
|
||||
body = [destructure] + tokens[8]
|
||||
return _at(ast_nodes.PerStatement(tokens[5], sentinel, body), tokens[0])
|
||||
|
||||
@self.pg.production('per_statement : KEYWORD_PER id KEYWORD_IN expression KEYWORD_FAC SYMBOL_LCURL statements SYMBOL_RCURL')
|
||||
def per(tokens):
|
||||
|
||||
@@ -138,15 +138,21 @@ control_tests = [
|
||||
("PER a, b IN [[I, II], [III, IV]] FAC { DIC(a + b) }",
|
||||
Program([], [PerStatement(
|
||||
DataArray([DataArray([Numeral("I"), Numeral("II")]), DataArray([Numeral("III"), Numeral("IV")])]),
|
||||
[ID("a"), ID("b")],
|
||||
[ExpressionStatement(BuiltIn("DIC", [BinOp(ID("a"), ID("b"), "SYMBOL_PLUS")]))])]),
|
||||
ID("#per_item"),
|
||||
[
|
||||
DesignaDestructure([ID("a"), ID("b")], ID("#per_item")),
|
||||
ExpressionStatement(BuiltIn("DIC", [BinOp(ID("a"), ID("b"), "SYMBOL_PLUS")])),
|
||||
])]),
|
||||
ValStr("VII"), "III\nVII\n"),
|
||||
# PER destructuring: three variables
|
||||
("PER a, b, c IN [[I, II, III]] FAC { DIC(a + b + c) }",
|
||||
Program([], [PerStatement(
|
||||
DataArray([DataArray([Numeral("I"), Numeral("II"), Numeral("III")])]),
|
||||
[ID("a"), ID("b"), ID("c")],
|
||||
[ExpressionStatement(BuiltIn("DIC", [BinOp(BinOp(ID("a"), ID("b"), "SYMBOL_PLUS"), ID("c"), "SYMBOL_PLUS")]))])]),
|
||||
ID("#per_item"),
|
||||
[
|
||||
DesignaDestructure([ID("a"), ID("b"), ID("c")], ID("#per_item")),
|
||||
ExpressionStatement(BuiltIn("DIC", [BinOp(BinOp(ID("a"), ID("b"), "SYMBOL_PLUS"), ID("c"), "SYMBOL_PLUS")])),
|
||||
])]),
|
||||
ValStr("VI"), "VI\n"),
|
||||
]
|
||||
|
||||
@@ -287,8 +293,9 @@ loop_edge_tests = [
|
||||
Designa(ID("r"), Numeral("I")),
|
||||
PerStatement(
|
||||
DataArray([DataArray([Numeral("I"), Numeral("II")]), DataArray([Numeral("III"), Numeral("IV")]), DataArray([Numeral("V"), Numeral("VI")])]),
|
||||
[ID("a"), ID("b")],
|
||||
[SiStatement(BinOp(ID("a"), Numeral("III"), "KEYWORD_EST"), [Erumpe()], None),
|
||||
ID("#per_item"),
|
||||
[DesignaDestructure([ID("a"), ID("b")], ID("#per_item")),
|
||||
SiStatement(BinOp(ID("a"), Numeral("III"), "KEYWORD_EST"), [Erumpe()], None),
|
||||
Designa(ID("r"), BinOp(BinOp(ID("r"), ID("a"), "SYMBOL_PLUS"), ID("b"), "SYMBOL_PLUS"))],
|
||||
),
|
||||
ExpressionStatement(ID("r")),
|
||||
@@ -300,8 +307,9 @@ loop_edge_tests = [
|
||||
Defini(ID("f"), [],
|
||||
[PerStatement(
|
||||
DataArray([DataArray([Numeral("I"), Numeral("II")]), DataArray([Numeral("III"), Numeral("IV")])]),
|
||||
[ID("a"), ID("b")],
|
||||
[SiStatement(BinOp(ID("a"), Numeral("III"), "KEYWORD_EST"), [Redi([ID("b")])], None)],
|
||||
ID("#per_item"),
|
||||
[DesignaDestructure([ID("a"), ID("b")], ID("#per_item")),
|
||||
SiStatement(BinOp(ID("a"), Numeral("III"), "KEYWORD_EST"), [Redi([ID("b")])], None)],
|
||||
)]),
|
||||
ExpressionStatement(Invoca(ID("f"), [])),
|
||||
]),
|
||||
|
||||
@@ -107,6 +107,8 @@ error_tests = [
|
||||
("SI [] TVNC { DESIGNA r VT I }", CentvrionError), # non-bool SI condition: empty list
|
||||
("DESIGNA x VT I\nDVM x FAC {\nDESIGNA x VT x + I\n}", CentvrionError), # non-bool DVM condition: int
|
||||
("NON I", CentvrionError), # NON on integer
|
||||
("ERVMPE()", SyntaxError), # ERVMPE is statement-only
|
||||
("CONTINVA()", SyntaxError), # CONTINVA is statement-only
|
||||
("DESIGNA z VT I - I\nNON z", CentvrionError), # NON on zero integer
|
||||
('NON "hello"', CentvrionError), # NON on string
|
||||
("DESIGNA a, b VT III", CentvrionError), # destructure non-array
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
out/
|
||||
*.vsix
|
||||
@@ -2,3 +2,7 @@
|
||||
.vscode-test/**
|
||||
.gitignore
|
||||
vsc-extension-quickstart.md
|
||||
src/**
|
||||
tsconfig.json
|
||||
node_modules/**
|
||||
out/**/*.map
|
||||
|
||||
Generated
+139
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<string>("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<void> | undefined {
|
||||
return client?.stop();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2020",
|
||||
"outDir": "out",
|
||||
"lib": ["ES2020"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true
|
||||
},
|
||||
"exclude": ["node_modules", ".vscode-test"]
|
||||
}
|
||||
Reference in New Issue
Block a user