From 0a26cb5c0fd5168ea871f5cdb273d106c65e8def Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 18:28:23 +0200 Subject: [PATCH] add outline to lsp --- client/src/extension.ts | 6 +- server/src/lsp_server.py | 182 +++++++++++++++++- .../python_tests/test_document_symbols.py | 115 +++++++++++ 3 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 server/tests/python_tests/test_document_symbols.py diff --git a/client/src/extension.ts b/client/src/extension.ts index 0474159..74b5d17 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -179,11 +179,7 @@ export async function activate(context: vscode.ExtensionContext) { context.subscriptions.push(formatDefProvider) - const tclOutlineProvider = vscode.languages.registerDocumentSymbolProvider( - { scheme: "file", language: "tcl" }, - { provideDocumentSymbols: tclDocumentSymbolProvider } - ) - context.subscriptions.push(tclOutlineProvider) + // Outline now provided by the language server (Document Symbols). Removing TS provider. // Diagnostics collection const diagnosticCollectionCdl = vscode.languages.createDiagnosticCollection("cdl") diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index fac66b2..199462b 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -144,9 +144,189 @@ def on_completion(params: lsp.CompletionParams) -> list[lsp.CompletionItem]: # return symbols +@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) +def document_symbols(params: lsp.DocumentSymbolParams): + doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + lines = doc.source.split("\n") + + # Regex patterns similar to the previous TS implementation + ns_re = re.compile(r"^\s*namespace\s+eval\s+([^\s\{]+)") + proc_re = re.compile(r"^\s*proc\s+([^\s\{]+)\s+\{.*\}\s+\{") + set_re = re.compile(r"^\s*set\s+([^\s\}]+)") + event_start_re = re.compile(r"^\s*LIB_GE_command_buffer_edit_(prepend|append|insert|replace)\b") + event_close_inline_re = re.compile(r"^\s*\}\s*(\S+)\s*.*$") + event_close_brace_only_re = re.compile(r"^\s*\}\s*$") + event_name_line_re = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\b") + pending_event = None + + class Scope: + def __init__(self, name: str, symbol: lsp.DocumentSymbol, start_line: int): + self.name = name + self.symbol = symbol + self.start_line = start_line + self.brace_count = 0 + + root_symbol = lsp.DocumentSymbol( + name="root", + detail="", + kind=lsp.SymbolKind.Namespace, + range=lsp.Range(start=lsp.Position(0, 0), end=lsp.Position(len(lines), 0)), + selection_range=lsp.Range(start=lsp.Position(0, 0), end=lsp.Position(0, 0)), + children=[], + ) + + scope_stack: list[Scope] = [Scope("", root_symbol, 0)] + + for i, line in enumerate(lines): + # If inside an event, update its brace count for this line + if "pending_event" in locals() and pending_event is not None: + pe_open = line.count("{") + pe_close = line.count("}") + pending_event["brace_count"] = pending_event.get("brace_count", 1) + pe_open - pe_close + # If this line closed the outer event block, finalize the event + if pending_event["brace_count"] <= 0: + # Try inline name on the same line + close_inline = event_close_inline_re.match(line) + if close_inline: + event_name = close_inline.group(1) + name_line_index = i + else: + # Look ahead to next non-empty line for the name + j = i + 1 + while j < len(lines) and lines[j].strip() == "": + j += 1 + event_name = None + name_line_index = i + if j < len(lines): + name_line = lines[j] + name_match = event_name_line_re.match(name_line) + if name_match: + event_name = name_match.group(1) + name_line_index = j + if event_name: + start_line = pending_event["start"] + edit_type = pending_event["edit_type"] + children = pending_event.get("children", []) + detail = f"Event ({edit_type})" + event_symbol = lsp.DocumentSymbol( + name=event_name, + detail=detail, + kind=lsp.SymbolKind.Event, + range=lsp.Range(start=lsp.Position(start_line, 0), end=lsp.Position(name_line_index, len(lines[name_line_index]))), + selection_range=lsp.Range(start=lsp.Position(name_line_index, 0), end=lsp.Position(name_line_index, len(lines[name_line_index]))), + children=children or [], + ) + if scope_stack[-1].symbol.children is None: + scope_stack[-1].symbol.children = [] + scope_stack[-1].symbol.children.append(event_symbol) + # Clear event tracking and continue + pending_event = None + continue + ns_match = ns_re.match(line) + proc_match = proc_re.match(line) + set_match = set_re.match(line) + + # Namespace + if ns_match: + ns_name = ns_match.group(1) + start = lsp.Position(i, 0) + end = lsp.Position(i, len(line)) + sel_start_char = line.find(ns_name) + sel_end_char = sel_start_char + len(ns_name) if sel_start_char >= 0 else len(line) + ns_symbol = lsp.DocumentSymbol( + name=ns_name, + detail="Namespace", + kind=lsp.SymbolKind.Namespace, + range=lsp.Range(start=start, end=end), + selection_range=lsp.Range( + start=lsp.Position(i, max(sel_start_char, 0)), + end=lsp.Position(i, max(sel_end_char, 0)), + ), + children=[], + ) + scope = Scope(ns_name, ns_symbol, i) + if scope_stack[-1].symbol.children is None: + scope_stack[-1].symbol.children = [] + scope_stack[-1].symbol.children.append(ns_symbol) + scope_stack.append(scope) + + # Proc + elif proc_match: + proc_name = proc_match.group(1) + start = lsp.Position(i, 0) + end = lsp.Position(i, len(line)) + sel_start_char = line.find(proc_name) + sel_end_char = sel_start_char + len(proc_name) if sel_start_char >= 0 else len(line) + proc_symbol = lsp.DocumentSymbol( + name=proc_name, + detail="Procedure", + kind=lsp.SymbolKind.Function, + range=lsp.Range(start=start, end=end), + selection_range=lsp.Range( + start=lsp.Position(i, max(sel_start_char, 0)), + end=lsp.Position(i, max(sel_end_char, 0)), + ), + children=[], + ) + scope = Scope(proc_name, proc_symbol, i) + if scope_stack[-1].symbol.children is None: + scope_stack[-1].symbol.children = [] + scope_stack[-1].symbol.children.append(proc_symbol) + scope_stack.append(scope) + + # set variable + elif set_match: + var_name = set_match.group(1) + start = lsp.Position(i, 0) + end = lsp.Position(i, len(line)) + sel_start_char = line.find(var_name) + sel_end_char = sel_start_char + len(var_name) if sel_start_char >= 0 else len(line) + var_symbol = lsp.DocumentSymbol( + name=var_name, + detail="Variable", + kind=lsp.SymbolKind.Variable, + range=lsp.Range(start=start, end=end), + selection_range=lsp.Range( + start=lsp.Position(i, max(sel_start_char, 0)), + end=lsp.Position(i, max(sel_end_char, 0)), + ), + children=None, + ) + # Attach to current scope or pending event as child + if "pending_event" in locals() and pending_event is not None: + pending_event["children"].append(var_symbol) + else: + if scope_stack[-1].symbol.children is None: + scope_stack[-1].symbol.children = [] + scope_stack[-1].symbol.children.append(var_symbol) + + # Event start (buffer edit) + if event_start_re.match(line): + m = event_start_re.match(line) + if m: + edit_type = m.group(1) + pending_event = {"start": i, "edit_type": edit_type, "children": []} + continue + # Brace balancing for scopes (namespace/proc) + open_count = line.count("{") + close_count = line.count("}") + scope_stack[-1].brace_count += open_count - close_count + + # Close finished scopes + while len(scope_stack) > 1 and scope_stack[-1].brace_count <= 0: + finished = scope_stack.pop() + # Set the full range from startLine to current line + finished.symbol.range = lsp.Range( + start=lsp.Position(finished.start_line, 0), + end=lsp.Position(i, len(line)), + ) + + # Return top-level children + return root_symbol.children + + @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_INLAY_HINT) def inlay_hints(params: lsp.InlayHintParams): - log_to_output(str(GLOBAL_SETTINGS.get("inlayHint", False))) if not GLOBAL_SETTINGS.get("inlayHint", False): return [] document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) diff --git a/server/tests/python_tests/test_document_symbols.py b/server/tests/python_tests/test_document_symbols.py new file mode 100644 index 0000000..c2eb66f --- /dev/null +++ b/server/tests/python_tests/test_document_symbols.py @@ -0,0 +1,115 @@ +import sys +from pathlib import Path + +THIS_DIR = Path(__file__).parent +SRC_DIR = THIS_DIR.parent.parent / "src" +if str(SRC_DIR) not in sys.path: + sys.path.insert(0, str(SRC_DIR)) + +import lsprotocol.types as lsp # type: ignore +from lsp_server import LSP_SERVER, document_symbols # type: ignore + + +def test_document_symbols_namespace_proc_set_hierarchy(tmp_path: Path): + source_lines = [ + "set top_var 1", + "namespace eval myns {", + " set ns_var 2", + " proc add {a b} {", + " set sum [expr {$a + $b}]", + " return $sum", + " }", + "}", + "proc top_proc {} {", + " set x 3", + "}", + ] + source = "\n".join(source_lines) + + uri = Path(tmp_path / "sym.tcl").as_uri() + # Put a text document into the workspace + LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source)) + + # Request document symbols + params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri)) + symbols = document_symbols(params) + + # Expect at least 2 top-level children: root contains 'set top_var' (variable) and 'namespace myns' and 'proc top_proc' + names_kinds = {(s.name, s.kind) for s in symbols} + assert ("root", lsp.SymbolKind.Namespace) not in names_kinds # root should not be included itself + + # Find namespace symbol + ns = next(s for s in symbols if s.name == "myns") + assert ns.kind == lsp.SymbolKind.Namespace + assert ns.children is not None + + # Inside namespace: has variable and proc + child_names = {c.name for c in ns.children} + assert "ns_var" in child_names + assert "add" in child_names + + # top-level variable and proc also present + top_names = {s.name for s in symbols} + assert "top_var" in top_names + assert "top_proc" in top_names + + # Check that proc add has no children (we're not extracting params as children here) + add = next(c for c in ns.children if c.name == "add") + assert add.kind == lsp.SymbolKind.Function + assert add.children == [] + + +def test_buffer_edit_events_are_symbols_with_type_and_name(tmp_path: Path): + source_lines = [ + "LIB_GE_command_buffer_edit_insert LIB_ROTARY_positioning_first_move_pos ROTARY_POSITIONING_FIRST_MOVE_POS {", + " MOM_enable_address Z M_coolant_off D M_coolant_1 M_coolant_2 H_pressure", + "}", + " Coolant after @DECOMPOSEZUL", + "", + "LIB_GE_command_buffer_edit_append MOM_start_of_path_LIB MOM_start_of_path_LIB_ENTRY_end {", + " MOM_force once M_coolant_1 M_coolant_2 H_pressure", + "}", + " force_coolant", + ] + source = "\n".join(source_lines) + + uri = Path(tmp_path / "events.tcl").as_uri() + LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source)) + params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri)) + symbols = document_symbols(params) + + # Find event symbols + events = [s for s in symbols if s.kind == lsp.SymbolKind.Event] + assert events, "Expected at least one event symbol" + + # Verify names and details + names = [e.name for e in events] + assert "Coolant" in names or "force_coolant" in names + for e in events: + assert e.detail.startswith("Event (") + + +def test_event_children_include_set_variable(tmp_path: Path): + source_lines = [ + "LIB_GE_command_buffer_edit_append MOM_rapid_move_LIB MOM_rapid_move_LIB_ENTRY_start {", + " if {[info exists ::mom_lift_off_output] && $::kapp_vars(retract_start) == 0} {", + " kapp_retract_subpgm", + " }", + " set ::kapp_vars(retract_start) 0", + "}", + " KappRetractSubPgm", + ] + source = "\n".join(source_lines) + + uri = Path(tmp_path / "event_children.tcl").as_uri() + LSP_SERVER.workspace.put_text_document(lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source)) + params = lsp.DocumentSymbolParams(text_document=lsp.TextDocumentIdentifier(uri=uri)) + symbols = document_symbols(params) + + events = [s for s in symbols if s.kind == lsp.SymbolKind.Event and s.name == "KappRetractSubPgm"] + assert events, "Expected event symbol for KappRetractSubPgm" + ev = events[0] + assert ev.children is not None + # Ensure the set variable is a child of the event + child_names = {c.name for c in ev.children} + assert "::kapp_vars(retract_start)" in child_names