From 0bb4ef731e2be804d6a394253c469d24f3d7393e Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 10:15:58 +0200 Subject: [PATCH 01/21] add first proc doc --- server/src/lsp_server.py | 87 +++++++++++++++++++++---------- server/src/lsp_tclserver.py | 12 ++++- server/src/tools/proc_docs.py | 96 +++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 server/src/tools/proc_docs.py diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 4ce9378..9e70d41 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -44,6 +44,7 @@ from tools.semantic_tokens import _Highlighter, TOKEN_TYPES, TokenModifier from tools.completion_items import completion, remove_existing_items, remove_shared_keys from tools.inlay_hint import InlayHintGenerator from tools.file_sourcing import get_all_psc_files, read_psc_file +from tools.proc_docs import ProcDocCollector from lsp_tclserver import TclLanguageServer @@ -209,48 +210,70 @@ def hover(params: lsp.HoverParams) -> lsp.Hover: else: return None + # 1) Built-in MOM procs / variables command = token data = standard_items.json_data - all_items = data.get("MOM_procs", []) + data.get("mom_variables", []) - - match = next((item for item in all_items if item["label"] == command), None) - if not match: - return None - - if not match.get("kind") == "function": - return None - - label = match.get("label", "") - - parameters = match.get("parameters", []) - param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_" - - example_data = match.get("example", []) - example_md = "\n".join(f"{line}" for line in example_data) - - returns_data = match.get("returns", ["None"]) - returns_md = "\n".join(f"- {line}" for line in returns_data) - - doc_md = f"""\ + match = next((item for item in all_items if item.get("label") == command), None) + if match and match.get("kind") == "function": + label = match.get("label", "") + parameters = match.get("parameters", []) + param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_" + example_data = match.get("example", []) + example_md = "\n".join(f"{line}" for line in example_data) + returns_data = match.get("returns", ["None"]) + returns_md = "\n".join(f"- {line}" for line in returns_data) + doc_md = f"""\ ### πŸ“˜ {label} -**Purpose** +**Purpose** {match.get("description", "No description available.")} -**Format** +**Format** `{match.get("format", label)}` -**Parameters** +**Parameters** {param_lines} -**Return value** +**Return value** {returns_md} -**Example** +**Example** ```tcl {example_md}""" + return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=doc_md)) + # 2) Custom procs collected from the workspace + # Find params + params_for_proc = None + for _file, mapping in LSP_SERVER.proc_signatures.items(): + if command in mapping: + params_for_proc = mapping[command] + break + + # Find docs + doc_text = None + if hasattr(LSP_SERVER, "proc_docs"): + for _file, mapping in getattr(LSP_SERVER, "proc_docs", {}).items(): + if command in mapping: + doc_text = mapping[command] + break + + if params_for_proc is None and not doc_text: + return None + + # Render hover for custom proc + param_lines = "\n".join(f"- `{p}`" for p in (params_for_proc or [])) or "_None_" + # Keep the doc block as-is; it's already normalized by the collector + body = doc_text or "" + doc_md = f"""\ +### πŸ› οΈ {command} + +**Parameters** +{param_lines} + +{body} +""" return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=doc_md)) @@ -318,6 +341,7 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult: capabilities=lsp.ServerCapabilities( document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True), semantic_tokens_provider=lsp.SemanticTokensOptions(legend=semantic_tokens_legend, full=True, range=False), + hover_provider=True, ) ) @@ -337,13 +361,22 @@ def initialized(params: lsp.InitializedParams): if not filepath.exists(): continue completion.reset() - document = LSP_SERVER.workspace.get_text_document(filepath.as_uri()) # filepath.read_text(encoding="utf-8") + document = LSP_SERVER.workspace.get_text_document(filepath.as_uri()) tree = LSP_SERVER.parser.parse(document.source) + # completions and signatures tree.accept(completion, recurse=True) remove_existing_items(completion.custom_functions, LSP_SERVER.poco_completion) LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures) LSP_SERVER.proc_signatures[str(filepath)] = completion.proc_signatures + # docs + from tools.proc_docs import ProcDocCollector + + doc_collector = ProcDocCollector() + tree.accept(doc_collector, recurse=True) + if not hasattr(LSP_SERVER, "proc_docs"): + LSP_SERVER.proc_docs = {} + LSP_SERVER.proc_docs[str(filepath)] = doc_collector.docs except Exception as e: log_to_output(f"Fehler beim Parsen von {filepath}: {e}") diff --git a/server/src/lsp_tclserver.py b/server/src/lsp_tclserver.py index 69370c4..5a5d54f 100644 --- a/server/src/lsp_tclserver.py +++ b/server/src/lsp_tclserver.py @@ -10,6 +10,7 @@ from plugins.poco_plugin import commands from tools import checks, parser from pygls import server, uris from tools.completion_items import completion, remove_existing_items, remove_shared_keys +from tools.proc_docs import ProcDocCollector DIAGNOSTIC_SOURCE = "nx-post-support" @@ -24,6 +25,7 @@ class TclLanguageServer(server.LanguageServer): self.diagnostics = {} self.poco_completion: dict = {} self.proc_signatures: dict = {} + self.proc_docs: dict = {} def update_poco_completion_for_file(self, document: TextDocument): """Update poco_completion for a specific file when it changes""" @@ -34,16 +36,24 @@ class TclLanguageServer(server.LanguageServer): del self.poco_completion[filepath] if filepath in self.proc_signatures: del self.proc_signatures[filepath] + if filepath in self.proc_docs: + del self.proc_docs[filepath] - # Parse and extract new completion items + # Parse and extract new completion items and docs completion.reset() try: tree = self.parser.parse(document.source) + # completions and signatures tree.accept(completion, recurse=True) remove_existing_items(completion.custom_functions, self.poco_completion) self.poco_completion[filepath] = completion.custom_functions remove_shared_keys(self.proc_signatures, completion.proc_signatures) self.proc_signatures[filepath] = completion.proc_signatures + + # proc docs + doc_collector = ProcDocCollector() + tree.accept(doc_collector, recurse=True) + self.proc_docs[filepath] = doc_collector.docs except Exception as e: logging.debug(f"Error parsing {filepath}: {e}") diff --git a/server/src/tools/proc_docs.py b/server/src/tools/proc_docs.py new file mode 100644 index 0000000..393149b --- /dev/null +++ b/server/src/tools/proc_docs.py @@ -0,0 +1,96 @@ +from tclint.syntax_tree import Visitor, Command, BareWord +from typing import Dict, List, Optional + + +class ProcDocCollector(Visitor): + """ + Collects documentation blocks preceding `proc` declarations. + + Heuristic: + - Accumulate consecutive comment lines into a pending block. + - When a `proc { ... }` command is visited, if there is a pending + block whose last line ends immediately above the proc line (same line or + previous line), attach the block to that proc name. + - Clear the pending block whenever we see a non-comment top-level node before + a matching `proc` to avoid associating stale docs. + + The collected docs are plain text (comment markers removed). The consumer can + render them as Markdown. + """ + + def __init__(self) -> None: + super().__init__() + self.docs: Dict[str, str] = {} + self._pending_lines: List[str] = [] + self._pending_end_line: Optional[int] = None + self._last_visited_node_line: Optional[int] = None + + # Comments are leaf nodes; visit order is sequential across the script + def visit_comment(self, comment): # type: ignore[override] + # comment.value is the text after '#', trimmed of trailing whitespace by parser + line_no = comment.pos[0] if comment.pos else None + if self._pending_lines and self._pending_end_line is not None: + # If the current comment is directly after the previous one, keep the block, + # otherwise start a new block. + if line_no is not None and self._pending_end_line is not None and line_no <= self._pending_end_line + 1: + pass # continue current block + else: + # gap; start a new block + self._pending_lines = [] + # Append this line to the pending block + value = comment.value.lstrip() if isinstance(comment.value, str) else "" + self._pending_lines.append(value) + self._pending_end_line = comment.end_pos[0] if comment.end_pos else line_no + self._last_visited_node_line = line_no + + def visit_command(self, command: Command): # type: ignore[override] + # Any command other than `proc` breaks the association with a pending + # doc block; we only attach to immediate `proc`s. + routine = command.routine + proc_line = command.pos[0] if command.pos else None + + if getattr(routine, "contents", None) == "proc": + # Extract proc name (first arg) + if not command.args: + self._clear_pending() + return + first = command.args[0] + name = getattr(first, "value", None) or getattr(first, "contents", None) + if not name or not isinstance(first, BareWord): + self._clear_pending() + return + + # Attach pending block if it's immediately above the proc (allow a single blank line) + attach = False + if self._pending_lines and self._pending_end_line is not None and proc_line is not None: + if self._pending_end_line in {proc_line, proc_line - 1}: + attach = True + + if attach: + # Normalize text to Markdown: join lines and trim surrounding divider lines + lines = self._normalize_lines(self._pending_lines) + self.docs[name] = "\n".join(lines).strip() + # Whether attached or not, clear pending to avoid reuse + self._clear_pending() + else: + # Non-proc command before a proc clears any pending doc + self._clear_pending() + + self._last_visited_node_line = proc_line + + def _clear_pending(self): + self._pending_lines = [] + self._pending_end_line = None + + @staticmethod + def _normalize_lines(lines: List[str]) -> List[str]: + # Drop long underscore divider lines commonly used + def is_divider(s: str) -> bool: + s_stripped = s.strip() + return len(s_stripped) >= 5 and set(s_stripped) in ({"_"}, {"-"}, {"="}) + + filtered = [ln for ln in lines if not is_divider(ln)] + # If the block contains pseudo-tags like , keep them; the + # hover can render them as-is or the consumer can improve formatting later. + return filtered + From bc745622b927b207205ebc1b1896c4e4897e2b25 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 15:59:09 +0200 Subject: [PATCH 02/21] add first proc doc --- server/src/lsp_server.py | 64 +++++--- server/src/lsp_tclserver.py | 5 + server/src/tools/proc_docs.py | 144 ++++++++++++++++++ .../python_tests/test_hover_proc_docs.py | 103 +++++++++++++ 4 files changed, 291 insertions(+), 25 deletions(-) create mode 100644 server/src/tools/proc_docs.py create mode 100644 server/tests/python_tests/test_hover_proc_docs.py diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 4ce9378..fac66b2 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -76,6 +76,8 @@ def did_open(params: lsp.DidOpenTextDocumentParams) -> None: """LSP handler for textDocument/didOpen request.""" document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) LSP_SERVER.compute_diagnostics(document) + # Also update custom completion and proc docs for this file + LSP_SERVER.update_poco_completion_for_file(document) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_SAVE) @@ -202,6 +204,13 @@ def hover(params: lsp.HoverParams) -> lsp.Hover: except IndexError: return None + # Do not show hover for proc name in its declaration + from tools.proc_docs import is_proc_declaration_position + + if is_proc_declaration_position(document.source, pos.line, pos.character): + return None + + # Identify the token under the cursor for m in re.finditer(r"\b\w+\b", line): if m.start() <= col <= m.end(): token = m.group(0) @@ -209,49 +218,50 @@ def hover(params: lsp.HoverParams) -> lsp.Hover: else: return None + # 1) If token is a known MOM proc/variable, return built-in hover command = token data = standard_items.json_data - all_items = data.get("MOM_procs", []) + data.get("mom_variables", []) match = next((item for item in all_items if item["label"] == command), None) - if not match: - return None - - if not match.get("kind") == "function": - return None - - label = match.get("label", "") - - parameters = match.get("parameters", []) - param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_" - - example_data = match.get("example", []) - example_md = "\n".join(f"{line}" for line in example_data) - - returns_data = match.get("returns", ["None"]) - returns_md = "\n".join(f"- {line}" for line in returns_data) - - doc_md = f"""\ + if match and match.get("kind") == "function": + label = match.get("label", "") + parameters = match.get("parameters", []) + param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_" + example_data = match.get("example", []) + example_md = "\n".join(f"{line}" for line in example_data) + returns_data = match.get("returns", ["None"]) + returns_md = "\n".join(f"- {line}" for line in returns_data) + doc_md = f"""\ ### πŸ“˜ {label} -**Purpose** +**Purpose** {match.get("description", "No description available.")} -**Format** +**Format** `{match.get("format", label)}` -**Parameters** +**Parameters** {param_lines} -**Return value** +**Return value** {returns_md} -**Example** +**Example** ```tcl {example_md}""" + return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=doc_md)) - return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=doc_md)) + # 2) Otherwise, check if the token is a custom proc and show its preceding doc block + # Build a merged map of proc -> docs gathered during initialization and updates + proc_docs: dict[str, str] = {} + for file_docs in LSP_SERVER.proc_docs.values(): + proc_docs.update(file_docs) + + if token in proc_docs: + return lsp.Hover(lsp.MarkupContent(kind=lsp.MarkupKind.Markdown, value=proc_docs[token])) + + return None # ********************************************************** @@ -344,6 +354,10 @@ def initialized(params: lsp.InitializedParams): LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures) LSP_SERVER.proc_signatures[str(filepath)] = completion.proc_signatures + # Build proc docs for this file + from tools.proc_docs import build_proc_docs + + LSP_SERVER.proc_docs[str(filepath)] = build_proc_docs(tree, document.source) except Exception as e: log_to_output(f"Fehler beim Parsen von {filepath}: {e}") diff --git a/server/src/lsp_tclserver.py b/server/src/lsp_tclserver.py index 69370c4..c88db35 100644 --- a/server/src/lsp_tclserver.py +++ b/server/src/lsp_tclserver.py @@ -10,6 +10,7 @@ from plugins.poco_plugin import commands from tools import checks, parser from pygls import server, uris from tools.completion_items import completion, remove_existing_items, remove_shared_keys +from tools.proc_docs import build_proc_docs DIAGNOSTIC_SOURCE = "nx-post-support" @@ -24,6 +25,7 @@ class TclLanguageServer(server.LanguageServer): self.diagnostics = {} self.poco_completion: dict = {} self.proc_signatures: dict = {} + self.proc_docs: dict = {} def update_poco_completion_for_file(self, document: TextDocument): """Update poco_completion for a specific file when it changes""" @@ -34,6 +36,8 @@ class TclLanguageServer(server.LanguageServer): del self.poco_completion[filepath] if filepath in self.proc_signatures: del self.proc_signatures[filepath] + if filepath in self.proc_docs: + del self.proc_docs[filepath] # Parse and extract new completion items completion.reset() @@ -44,6 +48,7 @@ class TclLanguageServer(server.LanguageServer): self.poco_completion[filepath] = completion.custom_functions remove_shared_keys(self.proc_signatures, completion.proc_signatures) self.proc_signatures[filepath] = completion.proc_signatures + self.proc_docs[filepath] = build_proc_docs(tree, document.source) except Exception as e: logging.debug(f"Error parsing {filepath}: {e}") diff --git a/server/src/tools/proc_docs.py b/server/src/tools/proc_docs.py new file mode 100644 index 0000000..8dbd21a --- /dev/null +++ b/server/src/tools/proc_docs.py @@ -0,0 +1,144 @@ +import re +from typing import Dict, List + +from tclint.syntax_tree import Visitor, Command +from tools.parser import CustomParser + + +def _strip_comment_prefix(line: str) -> str: + """Strip leading '# ' or '#' from a line.""" + if line.lstrip().startswith("#"): + # remove up to one leading '#' and one optional following space + return re.sub(r"^\s*#\s?", "", line) + return line + + +def extract_doc_block_above(lines: List[str], start_line_index: int) -> str | None: + """ + Extract a contiguous block of line comments immediately above the given line index. + + - lines: document split into lines + - start_line_index: 0-based index of the line where the proc command starts + + Returns the cleaned documentation text or None if no comment block found. + """ + i = start_line_index - 1 + if i < 0: + return None + + doc_lines: List[str] = [] + + # Skip trailing empty lines directly above + while i >= 0 and lines[i].strip() == "": + i -= 1 + + # Collect contiguous comment lines going upwards + while i >= 0 and lines[i].lstrip().startswith("#"): + doc_lines.append(lines[i]) + i -= 1 + + if not doc_lines: + return None + + # Reverse to original order and strip comment prefixes + doc_lines.reverse() + cleaned = [_strip_comment_prefix(l) for l in doc_lines] + + # Simple tag -> markdown conversions for nicer rendering + md_lines: List[str] = [] + tag_map = { + "": "### Documentation", + "": "### Arguments", + "": "### Return value", + "": "### Example", + "": "### Internal Documentation", + "": "### Internal Example", + } + + for line in cleaned: + stripped = line.strip() + if stripped in tag_map: + md_lines.append(tag_map[stripped]) + else: + md_lines.append(line) + + return "\n".join(md_lines).rstrip() + + +class ProcDocExtractor(Visitor): + """Visitor that collects documentation blocks above proc declarations.""" + + def __init__(self, source_text: str): + super().__init__() + self._lines = source_text.split("\n") + self.docs: Dict[str, str] = {} + + def visit_command(self, command: Command): + routine = getattr(command.routine, "contents", None) + if routine != "proc": + return + + if not command.args: + return + name_node = command.args[0] + proc_name = getattr(name_node, "contents", None) + if not proc_name: + return + + # Prefer line of the 'proc' keyword; fallback to the name node + pos = getattr(command.routine, "pos", None) or getattr(name_node, "pos", None) + if not pos: + return + line_idx = pos[0] - 1 # 0-based + block = extract_doc_block_above(self._lines, line_idx) + if block: + self.docs[proc_name] = block + + +def build_proc_docs(tree, source_text: str) -> Dict[str, str]: + """Build a mapping of proc name -> markdown doc from a parsed tree and source text.""" + extractor = ProcDocExtractor(source_text) + tree.accept(extractor, recurse=True) + return extractor.docs + + +def is_proc_declaration_position(source_text: str, line_zero_based: int, char_zero_based: int) -> bool: + """Return True if the position is on a proc name within its declaration.""" + parser = CustomParser() + tree = parser.parse(source_text) + + # Walk commands to find 'proc' declarations and check if position intersects the name arg + class _DeclFinder(Visitor): + def __init__(self): + self.is_decl = False + + def visit_command(self, command: Command): + if self.is_decl: + return + routine = getattr(command.routine, "contents", None) + if routine != "proc" or not command.args: + return + name_node = command.args[0] + if not hasattr(name_node, "pos"): + return + # Calculate range for the name token + try: + start_line, start_col = name_node.pos + end_line, end_col = getattr(name_node, "end_pos", name_node.pos) + except Exception: + return + if start_line - 1 == line_zero_based: + length = 0 + if hasattr(name_node, "value") and name_node.value is not None: + length = len(name_node.value) + elif hasattr(name_node, "contents") and name_node.contents is not None: + length = len(name_node.contents) + if length: + start_c = start_col - 1 + end_c = start_c + length + if start_c <= char_zero_based <= end_c: + self.is_decl = True + + finder = _DeclFinder() + tree.accept(finder, recurse=True) + return finder.is_decl diff --git a/server/tests/python_tests/test_hover_proc_docs.py b/server/tests/python_tests/test_hover_proc_docs.py new file mode 100644 index 0000000..ca9ce6d --- /dev/null +++ b/server/tests/python_tests/test_hover_proc_docs.py @@ -0,0 +1,103 @@ +import os +import sys +from pathlib import Path + +# Ensure server/src is on the path for imports +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 tools.proc_docs import build_proc_docs, is_proc_declaration_position +from lsp_server import LSP_SERVER, hover # type: ignore + + +def test_build_proc_docs_extracts_block_and_tags(): + source = ( + "#____________________________________________________________________________________________\n" + "# \n" + "# This procedure creates a new directory if it does not exist.\n" + "# \n" + "# directory\n" + "#\tThe full pathname of the directory to be created.\n" + "# \n" + "# 0 - directory created or already exists\n" + "# 1 - error\n" + "#______________________________________________________________________________________________\n" + "proc LIB_FH_create_directory {directory} {\n" + " return 0\n" + "}\n" + ) + + from tools.parser import CustomParser + + tree = CustomParser().parse(source) + docs = build_proc_docs(tree, source) + + assert "LIB_FH_create_directory" in docs + md = docs["LIB_FH_create_directory"] + # Tags become markdown headings + assert "### Documentation" in md + assert "### Arguments" in md + assert "### Return value" in md + # Content preserved + assert "creates a new directory" in md + + +def test_hover_shows_doc_on_usage_but_not_on_declaration(tmp_path: Path): + # Build a TCL file with a documented proc and a usage + source_lines = [ + "#_________________________________________________________________________________________________", + "# ", + "# Function to delete the file", + "#_________________________________________________________________________________________________", + "proc SERVICE_remove_file {file} {", + " if {![SERVICE_check_file_exists $file]} {return}", + " MOM_remove_file $file", + "}", + "", + "proc SERVICE_check_file_exists {file} {", + " if {[file exists $file]} {return 1}", + " return 0", + "}", + "", + "# usage below", + "SERVICE_remove_file \"C:/tmp/x\"", + ] + source = "\n".join(source_lines) + + # Register document with server + uri = Path(tmp_path / "test.tcl").as_uri() + LSP_SERVER.workspace.put_text_document( + lsp.TextDocumentItem(uri=uri, language_id="tcl", version=1, text=source) + ) + + # Force server to parse and build proc docs + doc = LSP_SERVER.workspace.get_text_document(uri) + LSP_SERVER.update_poco_completion_for_file(doc) + + # 1) Hover on usage -> should return docs + usage_line = source_lines.index("SERVICE_remove_file \"C:/tmp/x\"") + char_index = source_lines[usage_line].find("SERVICE_remove_file") + 5 # inside the token + + params = lsp.HoverParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=usage_line, character=char_index), + ) + result = hover(params) + assert result is not None + assert "Function to delete the file" in result.contents.value # type: ignore[attr-defined] + + # 2) Hover on declaration name -> should be None + decl_line = source_lines.index("proc SERVICE_remove_file {file} {") + decl_char = source_lines[decl_line].find("SERVICE_remove_file") + 2 + assert is_proc_declaration_position(source, decl_line, decl_char) + + params_decl = lsp.HoverParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=decl_line, character=decl_char), + ) + none_result = hover(params_decl) + assert none_result is None + From 2c2579319d5217f04cfade3a1563f7fd9803b9f8 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 16:20:14 +0200 Subject: [PATCH 03/21] update hover feature --- server/src/tools/proc_docs.py | 41 +++++++++-- server/tests/python_tests/test_proc_docs.py | 75 +++++++++++++++++++++ 2 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 server/tests/python_tests/test_proc_docs.py diff --git a/server/src/tools/proc_docs.py b/server/src/tools/proc_docs.py index 8dbd21a..c603ab1 100644 --- a/server/src/tools/proc_docs.py +++ b/server/src/tools/proc_docs.py @@ -42,7 +42,7 @@ def extract_doc_block_above(lines: List[str], start_line_index: int) -> str | No # Reverse to original order and strip comment prefixes doc_lines.reverse() - cleaned = [_strip_comment_prefix(l) for l in doc_lines] + cleaned = [_strip_comment_prefix(line_text) for line_text in doc_lines] # Simple tag -> markdown conversions for nicer rendering md_lines: List[str] = [] @@ -55,12 +55,45 @@ def extract_doc_block_above(lines: List[str], start_line_index: int) -> str | No "": "### Internal Example", } + in_example = False + code_block_open = False + + def close_code_block_if_open(): + nonlocal code_block_open + if code_block_open: + md_lines.append("```") + code_block_open = False + for line in cleaned: stripped = line.strip() + # Convert tags to headings and manage example sections if stripped in tag_map: - md_lines.append(tag_map[stripped]) - else: - md_lines.append(line) + # If we hit any new tag, close a pending code block + close_code_block_if_open() + heading = tag_map[stripped] + md_lines.append(heading) + in_example = heading in ("### Example", "### Internal Example") + continue + + if in_example: + low = stripped.lower() + if low.startswith("code:"): + # Open code block if needed and append the code content + code_text = line.split(":", 1)[1].strip() + if not code_block_open: + md_lines.append("```tcl") + code_block_open = True + md_lines.append(code_text) + continue + # Keep name/desc lines as regular text outside code + if low.startswith("name:") or low.startswith("desc:"): + md_lines.append(line) + continue + + md_lines.append(line) + + # Close any dangling code fence at the end of the block + close_code_block_if_open() return "\n".join(md_lines).rstrip() diff --git a/server/tests/python_tests/test_proc_docs.py b/server/tests/python_tests/test_proc_docs.py new file mode 100644 index 0000000..e94397a --- /dev/null +++ b/server/tests/python_tests/test_proc_docs.py @@ -0,0 +1,75 @@ +import sys +from pathlib import Path + +# Ensure server/src is on sys.path for imports +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 tools.parser import CustomParser +from tools.proc_docs import build_proc_docs + + +def test_example_is_marked_as_tcl_code_block(): + source_lines = [ + "#____________________________________________________________________________________________", + "# ", + "# This procedure creates a new directory if it does not exist.", + "# ", + "# directory", + "#\tThe full pathname of the directory to be created.", + "# ", + "# 0 - directory created or already exists", + "# 1 - error", + "# ", + "# name: Example 1", + "# code: LIB_FH_create_directory \"C:/Temp/Test\"", + "# desc: If error = 0, the directory is created.", + "proc LIB_FH_create_directory {directory} {", + " return 0", + "}", + ] + source = "\n".join(source_lines) + + tree = CustomParser().parse(source) + docs = build_proc_docs(tree, source) + + assert "LIB_FH_create_directory" in docs + md = docs["LIB_FH_create_directory"] + + # Headings preserved + assert "### Documentation" in md + assert "### Arguments" in md + assert "### Return value" in md + assert "### Example" in md + + # Code fence with tcl language hint and the code line present + assert "```tcl" in md + assert "LIB_FH_create_directory \"C:/Temp/Test\"" in md + assert md.strip().endswith("```") + + +def test_internal_example_is_marked_as_tcl_code_block(): + source_lines = [ + "# ", + "# Helper utility", + "# ", + "# code: puts \"hello\"", + "proc helper {} {", + " return", + "}", + ] + source = "\n".join(source_lines) + + tree = CustomParser().parse(source) + docs = build_proc_docs(tree, source) + + assert "helper" in docs + md = docs["helper"] + + assert "### Internal Documentation" in md + assert "### Internal Example" in md + assert "```tcl" in md and "puts \"hello\"" in md and md.strip().endswith("```") + From a90e15514bb04d321b2288d9e6ed9d4102204528 Mon Sep 17 00:00:00 2001 From: Christoph Date: Mon, 11 Aug 2025 14:23:08 +0000 Subject: [PATCH 04/21] Update .gitea/workflows/build_and_puplish.yml --- .gitea/workflows/build_and_puplish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/build_and_puplish.yml b/.gitea/workflows/build_and_puplish.yml index 65ec672..84486bf 100644 --- a/.gitea/workflows/build_and_puplish.yml +++ b/.gitea/workflows/build_and_puplish.yml @@ -33,7 +33,7 @@ jobs: git config user.email "${{ vars.EMAIL_GIT }}" git add package.json git commit -m "Update version to ${{ github.ref_name }}" - git push origin HEAD:main + git push origin HEAD:${GITHUB_HEAD_REF:-$(git rev-parse --abbrev-ref HEAD)} - name: Publish to Visual Studio Marketplace (Pre-Release) if: ${{ github.event.release.prerelease }} run: vsce publish --pre-release From c25bf794273619277a44fbca1001284886c147c5 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 17:00:14 +0200 Subject: [PATCH 05/21] update pipline --- .gitea/workflows/build_and_puplish.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build_and_puplish.yml b/.gitea/workflows/build_and_puplish.yml index 84486bf..9f294b4 100644 --- a/.gitea/workflows/build_and_puplish.yml +++ b/.gitea/workflows/build_and_puplish.yml @@ -32,8 +32,10 @@ jobs: git config user.name "${{ vars.USERNAME_GIT }}" git config user.email "${{ vars.EMAIL_GIT }}" git add package.json - git commit -m "Update version to ${{ github.ref_name }}" - git push origin HEAD:${GITHUB_HEAD_REF:-$(git rev-parse --abbrev-ref HEAD)} + git commit -m "Update version to ${{ github.ref_name }}" || echo "No changes to commit." + BRANCH=$(git for-each-ref --format='%(refname:short)' --contains HEAD refs/remotes/origin | head -n1) + BRANCH=${BRANCH#origin/} + git push origin HEAD:refs/heads/$BRANCH - name: Publish to Visual Studio Marketplace (Pre-Release) if: ${{ github.event.release.prerelease }} run: vsce publish --pre-release From 9d5ef23162e3d05c7030928d8430ed7e233087b9 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 17:16:27 +0200 Subject: [PATCH 06/21] update pipline --- .gitea/workflows/build_and_puplish.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/build_and_puplish.yml b/.gitea/workflows/build_and_puplish.yml index 9f294b4..ef082e7 100644 --- a/.gitea/workflows/build_and_puplish.yml +++ b/.gitea/workflows/build_and_puplish.yml @@ -5,8 +5,11 @@ jobs: build_and_publish: runs-on: ubuntu-latest steps: - - name: Checkout code + - name: Checkout branch from release uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.target_commitish }} + fetch-depth: 0 - name: GitHub Tag Name example run: | echo "Tag name from GITHUB_REF_NAME: $GITHUB_REF_NAME" From ef558c3aa31e60ab80aa3f1c34d39089abc5eb7b Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 17:19:47 +0200 Subject: [PATCH 07/21] update pipline --- .gitea/workflows/build_and_puplish.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build_and_puplish.yml b/.gitea/workflows/build_and_puplish.yml index ef082e7..1ccdcba 100644 --- a/.gitea/workflows/build_and_puplish.yml +++ b/.gitea/workflows/build_and_puplish.yml @@ -36,8 +36,23 @@ jobs: git config user.email "${{ vars.EMAIL_GIT }}" git add package.json git commit -m "Update version to ${{ github.ref_name }}" || echo "No changes to commit." - BRANCH=$(git for-each-ref --format='%(refname:short)' --contains HEAD refs/remotes/origin | head -n1) - BRANCH=${BRANCH#origin/} + + # Get current branch; if detached, resolve to a remote branch that contains this commit + BRANCH="$(git rev-parse --abbrev-ref HEAD)" + if [[ "$BRANCH" == "HEAD" || -z "$BRANCH" ]]; then + # If target_commitish is a branch, use it; if it’s a SHA, find a branch that contains it + CANDIDATE='${{ github.event.release.target_commitish }}' + if [[ "$CANDIDATE" =~ ^[0-9a-f]{40}$ ]]; then + BRANCH="$(git branch -r --contains "$GITHUB_SHA" | sed -n 's|.*origin/||p' | head -n1)" + else + BRANCH="$CANDIDATE" + fi + fi + + if [[ -z "$BRANCH" ]]; then + echo "Could not determine branch for push."; git branch -a; exit 1 + fi + git push origin HEAD:refs/heads/$BRANCH - name: Publish to Visual Studio Marketplace (Pre-Release) if: ${{ github.event.release.prerelease }} From b3cac5c29231b7fec89251f6554b1360e022a1c2 Mon Sep 17 00:00:00 2001 From: Christoph Date: Mon, 11 Aug 2025 15:20:46 +0000 Subject: [PATCH 08/21] Update version to 2025.9.2 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1b020a3..44997ab 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "nx-post-support", "displayName": "NX Postprocessor Support", "description": "", - "version": "2025.8.200", + "version": "2025.9.2", "publisher": "Christoph", "icon": "images/nx-1.png", "serverInfo": { @@ -129,4 +129,4 @@ "prettier": "^3.4.2", "typescript": "^5.7.2" } -} \ No newline at end of file +} From 0a26cb5c0fd5168ea871f5cdb273d106c65e8def Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 18:28:23 +0200 Subject: [PATCH 09/21] 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 From 72f894b05da39ef0495d44a0b4c4b11eb2ce4a24 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 18:40:06 +0200 Subject: [PATCH 10/21] add goto --- server/src/lsp_server.py | 72 +++++++++++ .../python_tests/test_goto_definition.py | 116 ++++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 server/tests/python_tests/test_goto_definition.py diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 199462b..09b3286 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -444,6 +444,77 @@ def hover(params: lsp.HoverParams) -> lsp.Hover: return None +@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DEFINITION) +def goto_definition(params: lsp.DefinitionParams): + """Provide go-to-definition locations for Tcl procs. + + Strategy: + - Find the token under the cursor. + - If it matches a custom proc collected in proc_signatures, locate its declaration + by searching the current document first, then other indexed files. + - Return a Location pointing to the proc name in its declaration line. + """ + doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + pos = params.position + try: + line = doc.lines[pos.line] + except IndexError: + return None + + # Identify token under cursor + token = None + for m in re.finditer(r"\b\w+\b", line): + if m.start() <= pos.character <= m.end(): + token = m.group(0) + break + if not token: + return None + + # Helper to search a single source text for a proc declaration + def find_decl_in_source(source_text: str, uri: str) -> Optional[lsp.Location]: + lines = source_text.split("\n") + pattern = re.compile(r"^\s*proc\s+" + re.escape(token) + r"\b") + for i, ln in enumerate(lines): + m = pattern.match(ln) + if m: + start_char = ln.find(token) + if start_char < 0: + start_char = max(m.end() - len(token), 0) + start = lsp.Position(i, start_char) + end = lsp.Position(i, start_char + len(token)) + return lsp.Location(uri=uri, range=lsp.Range(start=start, end=end)) + return None + + # 1) Search in current document + loc = find_decl_in_source(doc.source, doc.uri) + if loc: + return loc + + # 2) Search in indexed files from proc_signatures + # Build list of candidate files that declare this token as a proc + candidate_files: List[str] = [] + for file_path, procs in LSP_SERVER.proc_signatures.items(): + if token in procs: + candidate_files.append(file_path) + + for fp in candidate_files: + uri = pathlib.Path(fp).as_uri() + # Try to get from workspace if available; else read from disk + try: + other_doc = LSP_SERVER.workspace.get_text_document(uri) + source = other_doc.source + except Exception: + try: + source = pathlib.Path(fp).read_text(encoding="utf-8") + except Exception: + continue + loc = find_decl_in_source(source, uri) + if loc: + return loc + + return None + + # ********************************************************** # Linting features end here # ********************************************************** @@ -508,6 +579,7 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult: capabilities=lsp.ServerCapabilities( document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True), semantic_tokens_provider=lsp.SemanticTokensOptions(legend=semantic_tokens_legend, full=True, range=False), + definition_provider=True, ) ) diff --git a/server/tests/python_tests/test_goto_definition.py b/server/tests/python_tests/test_goto_definition.py new file mode 100644 index 0000000..09cd93e --- /dev/null +++ b/server/tests/python_tests/test_goto_definition.py @@ -0,0 +1,116 @@ +import sys +from pathlib import Path + +# Ensure server/src is on the path for imports +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, goto_definition # type: ignore + + +def _loc_to_tuple(loc: lsp.Location) -> tuple[str, int, int, int, int]: + """Helper to normalize Location into a tuple for easy asserts.""" + return ( + loc.uri, + loc.range.start.line, + loc.range.start.character, + loc.range.end.line, + loc.range.end.character, + ) + + +def _extract_first_location(result) -> lsp.Location | None: + if result is None: + return None + if isinstance(result, list): + return result[0] if result else None + return result + + +def test_goto_definition_same_file(tmp_path: Path): + source_lines = [ + "proc add {a b} {", + " return [expr {$a + $b}]", + "}", + "", + "set x [add 1 2]", + ] + source = "\n".join(source_lines) + + uri = Path(tmp_path / "same.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) + ) + + # Position on the word 'add' in the last line + line_idx = 4 + char_idx = source_lines[line_idx].index("add") + 1 # somewhere inside token + + params = lsp.DefinitionParams( + text_document=lsp.TextDocumentIdentifier(uri=uri), + position=lsp.Position(line=line_idx, character=char_idx), + ) + + result = goto_definition(params) + loc = _extract_first_location(result) + + assert loc is not None + assert loc.uri == uri + # Definition should be on line 0 at the token 'add' + start = loc.range.start + end = loc.range.end + assert start.line == 0 + assert end.line == 0 + assert source_lines[0][start.character : end.character] == "add" + + +def test_goto_definition_cross_file(tmp_path: Path): + # File A declares the proc + a_lines = [ + "proc myproc {arg} {", + " return $arg", + "}", + ] + a_src = "\n".join(a_lines) + a_path = tmp_path / "a.tcl" + a_uri = a_path.as_uri() + LSP_SERVER.workspace.put_text_document( + lsp.TextDocumentItem(uri=a_uri, language_id="tcl", version=1, text=a_src) + ) + + # Update indices for file A so proc_signatures gets populated + doc_a = LSP_SERVER.workspace.get_text_document(a_uri) + LSP_SERVER.update_poco_completion_for_file(doc_a) + + # File B calls the proc + b_lines = [ + "set y [myproc 42]", + ] + b_src = "\n".join(b_lines) + b_uri = (tmp_path / "b.tcl").as_uri() + LSP_SERVER.workspace.put_text_document( + lsp.TextDocumentItem(uri=b_uri, language_id="tcl", version=1, text=b_src) + ) + + call_line = 0 + call_char = b_lines[0].index("myproc") + 2 + + params = lsp.DefinitionParams( + text_document=lsp.TextDocumentIdentifier(uri=b_uri), + position=lsp.Position(line=call_line, character=call_char), + ) + + result = goto_definition(params) + loc = _extract_first_location(result) + + assert loc is not None + assert loc.uri == a_uri + start = loc.range.start + end = loc.range.end + assert start.line == 0 + assert a_lines[start.line][start.character : end.character] == "myproc" + From b14181825193e56df8e3480a5c96ca45452dbc82 Mon Sep 17 00:00:00 2001 From: Christoph Date: Mon, 11 Aug 2025 16:41:00 +0000 Subject: [PATCH 11/21] Update version to 2025.9.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 44997ab..28285f0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "nx-post-support", "displayName": "NX Postprocessor Support", "description": "", - "version": "2025.9.2", + "version": "2025.9.3", "publisher": "Christoph", "icon": "images/nx-1.png", "serverInfo": { From feba88212fe283bbfebda7a45eaf9d168422fc04 Mon Sep 17 00:00:00 2001 From: christoph_xd Date: Mon, 11 Aug 2025 21:35:14 +0200 Subject: [PATCH 12/21] fix: some highlight error --- syntaxes/tcl.tmLanguage.json | 273 ++++++++--------------------------- 1 file changed, 59 insertions(+), 214 deletions(-) diff --git a/syntaxes/tcl.tmLanguage.json b/syntaxes/tcl.tmLanguage.json index 2d7bc5f..ace983e 100644 --- a/syntaxes/tcl.tmLanguage.json +++ b/syntaxes/tcl.tmLanguage.json @@ -1,30 +1,19 @@ { - "fileTypes": [ - "tcl" - ], + "fileTypes": [ "tcl" ], "name": "tcl", "patterns": [ - { - "include": "#main" - } + { "include": "#main" } ], "scopeName": "source.tcl", + "uuid": "c7017136-2ff2-48e9-bdb0-570cf238b4a2", "repository": { "main": { - "patterns": [ - { - "include": "#command" - } - ] + "patterns": [ { "include": "#command" } ] }, "args": { "patterns": [ - { - "include": "#quoted_args" - }, - { - "include": "#numeric" - }, + { "include": "#quoted_args" }, + { "include": "#numeric" }, { "match": "((?<=;)\\s*#.*$)", "name": "comment.tcl", @@ -36,22 +25,10 @@ "patterns": [ { "begin": "((?(?:\\\\{|\\\\}|[^\\x{007b}\\x{007d}])*)*)})", "captures": { - "1": { - "name": "keyword.tcl" - }, - "2": { - "name": "entity.name.function.tcl" - }, - "3": { - "name": "none.tcl" - } + "1": { "name": "keyword.tcl" }, + "2": { "name": "entity.name.function.tcl" }, + "3": { "name": "none.tcl" } }, "comment": "Proc command." }, + { "include": "#regexp" }, + { "include": "#keywords" }, { - "include": "#regexp" - }, - { - "include": "#keywords" - }, - { - "match": "(^\\s*\\S+|(?:(?<=[^\\x{005c}]\\[)[^\\s\\]]++(?!\\])))", + "match": "(^\\s*[^\\s\\x{0022}]+|(?:(?<=[^\\x{005c}]\\[)[^\\s\\]]+))", "name": "keyword.tcl", "comment": "All other commands. (NOTE: Iro doesn't support possessive quantifiers, but the grammar does; be sure to replace the second + with ++ after regenerating!)" }, - { - "include": "#args" - } + { "include": "#args" } ] }, "command__1": { "patterns": [ - { - "include": "#keywords" - }, - { - "include": "#args" - } + { "include": "#keywords" }, + { "include": "#args" } ] }, "embedded": { "patterns": [ { "begin": "((? Date: Mon, 11 Aug 2025 19:46:20 +0000 Subject: [PATCH 13/21] Update version to 2025.9.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 28285f0..1f2b208 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "nx-post-support", "displayName": "NX Postprocessor Support", "description": "", - "version": "2025.9.3", + "version": "2025.9.4", "publisher": "Christoph", "icon": "images/nx-1.png", "serverInfo": { From f24b280edfd6c79ea1852fce50196b9601eb3632 Mon Sep 17 00:00:00 2001 From: christoph_xd Date: Mon, 11 Aug 2025 22:06:49 +0200 Subject: [PATCH 14/21] mod: Keywords --- package.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 1f2b208..fc9e5db 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "keywords": [ "cdl", "NX CDL", - "UDE" + "UDE", + "Postprocessor", + "NX CAM", + "Siemens NX" ], "engines": { "vscode": "^1.96.0" @@ -129,4 +132,4 @@ "prettier": "^3.4.2", "typescript": "^5.7.2" } -} +} \ No newline at end of file From 8fb98d2f40a7f35bcc9c1eefd490ec1e9bd38f8f Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 12 Aug 2025 07:15:18 +0200 Subject: [PATCH 15/21] refacxtor the code --- server/src/lsp_server.py | 218 +++------------------------ server/src/tools/document_symbols.py | 189 +++++++++++++++++++++++ server/src/tools/semantic_tokens.py | 5 + 3 files changed, 214 insertions(+), 198 deletions(-) create mode 100644 server/src/tools/document_symbols.py diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index 09b3286..aa33920 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -9,7 +9,7 @@ import os import pathlib import re import sys -from typing import Any, List, Optional, Tuple +from typing import Any, Optional import operator from functools import reduce @@ -49,7 +49,6 @@ from lsp_tclserver import TclLanguageServer WORKSPACE_SETTINGS = {} GLOBAL_SETTINGS = {} -RUNNER = pathlib.Path(__file__).parent / "lsp_runner.py" MAX_WORKERS = 5 @@ -58,9 +57,7 @@ LSP_SERVER = TclLanguageServer(name="NX Postprocessor Support", version="0.0.1", # ********************************************************** # Tool specific code goes below this. # ********************************************************** -TOOL_MODULE = "nx-post-support" -TOOL_DISPLAY = "NX Postprocessor Support" -TOOL_ARGS = [] # default arguments always passed to your tool. + # Delete "Linting features" section if your tool is NOT a linter. # ********************************************************** @@ -87,7 +84,7 @@ def did_save(params: lsp.DidSaveTextDocumentParams) -> None: @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_CLOSE) -def did_close(params: lsp.DidCloseTextDocumentParams) -> None: +def did_close(_: lsp.DidCloseTextDocumentParams) -> None: """LSP handler for textDocument/didClose request.""" @@ -125,12 +122,9 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams): @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION) -def on_completion(params: lsp.CompletionParams) -> list[lsp.CompletionItem]: +def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: _ = LSP_SERVER.workspace.get_text_document(params.text_document.uri) - poco = [] - - for key, value in LSP_SERVER.poco_completion.items(): - poco.extend(value) + poco = [item for items in LSP_SERVER.poco_completion.values() for item in items] items = standard_items.tcl_keyword_list + standard_items.nx_procs + standard_items.nx_variables + poco return lsp.CompletionList(is_incomplete=False, items=items) @@ -146,183 +140,10 @@ def on_completion(params: lsp.CompletionParams) -> list[lsp.CompletionItem]: @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) def document_symbols(params: lsp.DocumentSymbolParams): + from tools.document_symbols import build_document_symbols + 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 + return build_document_symbols(doc.source) @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_INLAY_HINT) @@ -332,13 +153,14 @@ def inlay_hints(params: lsp.InlayHintParams): document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) tree = LSP_SERVER.parser.parse(document.source) - # collect Inlay Hints - hints = [] - for key, value in LSP_SERVER.proc_signatures.items(): - generator = InlayHintGenerator(LSP_SERVER.proc_signatures[key]) - tree.accept(generator, recurse=True) - hints += generator.hints - return hints + # Merge proc signatures across files and traverse once + merged_signatures = {} + for sigs in LSP_SERVER.proc_signatures.values(): + merged_signatures.update(sigs) + + generator = InlayHintGenerator(merged_signatures) + tree.accept(generator, recurse=True) + return generator.hints @LSP_SERVER.feature( @@ -364,7 +186,7 @@ def semantic_tokens(params: lsp.SemanticTokensParams): [ token.line, token.offset, - token.lenght, + token.length, TOKEN_TYPES.index(token.tok_type), reduce(operator.or_, token.tok_modifiers, 0), ] @@ -492,7 +314,7 @@ def goto_definition(params: lsp.DefinitionParams): # 2) Search in indexed files from proc_signatures # Build list of candidate files that declare this token as a proc - candidate_files: List[str] = [] + candidate_files: list[str] = [] for file_path, procs in LSP_SERVER.proc_signatures.items(): if token in procs: candidate_files.append(file_path) @@ -553,7 +375,7 @@ def formatting(params: lsp.DocumentFormattingParams) -> list[lsp.TextEdit] | Non # Required Language Server Initialization and Exit handlers. # ********************************************************** @LSP_SERVER.feature(lsp.WORKSPACE_DID_CHANGE_CONFIGURATION) -def did_change_configuration(params: lsp.DidChangeConfigurationParams): +def did_change_configuration(_: lsp.DidChangeConfigurationParams): """LSP Handler for Config Changes""" @@ -573,7 +395,7 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult: log_to_output(f"Global settings:\r\n{json.dumps(GLOBAL_SETTINGS, indent=4, ensure_ascii=False)}\r\n") semantic_tokens_legend = lsp.SemanticTokensLegend( token_types=TOKEN_TYPES, - token_modifiers=TokenModifier, + token_modifiers=[m.name for m in TokenModifier], ) return lsp.InitializeResult( capabilities=lsp.ServerCapabilities( diff --git a/server/src/tools/document_symbols.py b/server/src/tools/document_symbols.py new file mode 100644 index 0000000..7f976ef --- /dev/null +++ b/server/src/tools/document_symbols.py @@ -0,0 +1,189 @@ +from __future__ import annotations + +import re +import lsprotocol.types as lsp + +# Precompiled regex patterns for performance +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_NAME_LINE_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\b") + + +def build_document_symbols(source: str) -> list[lsp.DocumentSymbol]: + """Parse a Tcl document source and return DocumentSymbols. + + This mirrors the previous inline implementation in lsp_server, but is extracted + for readability and reuse. + """ + lines = source.split("\n") + + 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)] + pending_event: dict | None = None + + for i, line in enumerate(lines): + # If inside an event, update its brace count for this line + if 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 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) + 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 + diff --git a/server/src/tools/semantic_tokens.py b/server/src/tools/semantic_tokens.py index 9c5eb4f..b35c155 100644 --- a/server/src/tools/semantic_tokens.py +++ b/server/src/tools/semantic_tokens.py @@ -26,6 +26,11 @@ class Token: tok_type: str = "" tok_modifiers: List[TokenModifier] = attrs.field(factory=list) + @property + def length(self) -> int: + """Compatibility alias for misspelled 'lenght' field.""" + return self.lenght + TOKEN_TYPES = [ "keyword", From 7263b6f5305f559ece42f11e8dcc28db9cc3cb71 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 12 Aug 2025 07:48:05 +0200 Subject: [PATCH 16/21] optimze Server --- server/src/lsp_server.py | 70 ++++++++++++++++++++++--------------- server/src/lsp_tclserver.py | 38 +++++++++++++++++--- 2 files changed, 75 insertions(+), 33 deletions(-) diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index aa33920..a73491f 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -9,6 +9,8 @@ import os import pathlib import re import sys +import threading + from typing import Any, Optional import operator from functools import reduce @@ -151,7 +153,9 @@ def inlay_hints(params: lsp.InlayHintParams): if not GLOBAL_SETTINGS.get("inlayHint", False): return [] document = LSP_SERVER.workspace.get_text_document(params.text_document.uri) - tree = LSP_SERVER.parser.parse(document.source) + + # Reuse cached AST + tree = LSP_SERVER.get_tree(document) # Merge proc signatures across files and traverse once merged_signatures = {} @@ -177,7 +181,8 @@ def semantic_tokens(params: lsp.SemanticTokensParams): plugins = [] hl = _Highlighter(plugins, LSP_SERVER.poco_completion) - tree = LSP_SERVER.parser.parse(document.source) + # Reuse cached AST + tree = LSP_SERVER.get_tree(document) tree.accept(hl, recurse=True) tokens = hl.tokens() @@ -407,33 +412,42 @@ def initialize(params: lsp.InitializeParams) -> lsp.InitializeResult: @LSP_SERVER.feature(lsp.INITIALIZED) -def initialized(params: lsp.InitializedParams): - root = LSP_SERVER.workspace.root_path - psc_files = get_all_psc_files(pathlib.Path(root)) - for psc_file in psc_files: - poco_files = read_psc_file(psc_file) - for sourced_layer in poco_files: - completion.reset() - try: - file_root = pathlib.Path(root).joinpath(sourced_layer.subfolder if sourced_layer.subfolder else "") - for tcl_file in sourced_layer.files: - filepath = pathlib.Path(file_root).joinpath(f"{tcl_file}.tcl") - if not filepath.exists(): - continue - completion.reset() - document = LSP_SERVER.workspace.get_text_document(filepath.as_uri()) # filepath.read_text(encoding="utf-8") - tree = LSP_SERVER.parser.parse(document.source) - tree.accept(completion, recurse=True) - remove_existing_items(completion.custom_functions, LSP_SERVER.poco_completion) - LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions - remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures) - LSP_SERVER.proc_signatures[str(filepath)] = completion.proc_signatures - # Build proc docs for this file - from tools.proc_docs import build_proc_docs +def initialized(_params: lsp.InitializedParams): + """Kick off background indexing to avoid blocking initialization.""" - LSP_SERVER.proc_docs[str(filepath)] = build_proc_docs(tree, document.source) - except Exception as e: - log_to_output(f"Fehler beim Parsen von {filepath}: {e}") + def index_workspace(): + try: + root = LSP_SERVER.workspace.root_path + log_to_output("Background indexing started...") + psc_files = get_all_psc_files(pathlib.Path(root)) + for psc_file in psc_files: + poco_files = read_psc_file(psc_file) + for sourced_layer in poco_files: + completion.reset() + try: + file_root = pathlib.Path(root).joinpath(sourced_layer.subfolder if sourced_layer.subfolder else "") + for tcl_file in sourced_layer.files: + filepath = pathlib.Path(file_root).joinpath(f"{tcl_file}.tcl") + if not filepath.exists(): + continue + completion.reset() + document = LSP_SERVER.workspace.get_text_document(filepath.as_uri()) + tree = LSP_SERVER.parser.parse(document.source) + tree.accept(completion, recurse=True) + remove_existing_items(completion.custom_functions, LSP_SERVER.poco_completion) + LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions + remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures) + LSP_SERVER.proc_signatures[str(filepath)] = completion.proc_signatures + from tools.proc_docs import build_proc_docs + + LSP_SERVER.proc_docs[str(filepath)] = build_proc_docs(tree, document.source) + except Exception as e: + log_to_output(f"Fehler beim Parsen von {filepath}: {e}") + log_to_output("Background indexing completed.") + except Exception as e: + log_to_output(f"Background indexing failed: {e}") + + threading.Thread(target=index_workspace, name="nxps-indexer", daemon=True).start() @LSP_SERVER.feature(lsp.EXIT) diff --git a/server/src/lsp_tclserver.py b/server/src/lsp_tclserver.py index c88db35..d59a340 100644 --- a/server/src/lsp_tclserver.py +++ b/server/src/lsp_tclserver.py @@ -26,6 +26,37 @@ class TclLanguageServer(server.LanguageServer): self.poco_completion: dict = {} self.proc_signatures: dict = {} self.proc_docs: dict = {} + # Cache: (uri, version) -> (tree, violations) + self._ast_cache = {} + + def get_tree(self, document: TextDocument): + key = (document.uri, document.version) + cached = self._ast_cache.get(key) + if cached: + return cached[0] + # Parse and cache + self.parser.violations = [] + tree = self.parser.parse(document.source) + violations = list(self.parser.violations) + self._ast_cache[key] = (tree, violations) + return tree + + def get_tree_and_violations(self, document: TextDocument): + key = (document.uri, document.version) + cached = self._ast_cache.get(key) + if cached: + return cached + # Parse and cache + self.parser.violations = [] + tree = self.parser.parse(document.source) + violations = list(self.parser.violations) + self._ast_cache[key] = (tree, violations) + return tree, violations + + def clear_cache_for_uri(self, uri: str): + to_delete = [k for k in self._ast_cache.keys() if k[0] == uri] + for k in to_delete: + del self._ast_cache[k] def update_poco_completion_for_file(self, document: TextDocument): """Update poco_completion for a specific file when it changes""" @@ -42,7 +73,7 @@ class TclLanguageServer(server.LanguageServer): # Parse and extract new completion items completion.reset() try: - tree = self.parser.parse(document.source) + tree = self.get_tree(document) tree.accept(completion, recurse=True) remove_existing_items(completion.custom_functions, self.poco_completion) self.poco_completion[filepath] = completion.custom_functions @@ -81,10 +112,7 @@ class TclLanguageServer(server.LanguageServer): self, document: TextDocument, ) -> List[Violation]: - violations = [] - self.parser.violations = [] - tree = self.parser.parse(document.source) - violations += self.parser.violations + tree, violations = self.get_tree_and_violations(document) for checker in checks.get_checkers(): violations += checker.check(document.source, tree) return violations From 12b7b62aa5afde2b2a2525a1401f39fe064d879d Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 12 Aug 2025 09:07:04 +0200 Subject: [PATCH 17/21] add variable indexing keep track of postion --- server/src/lsp_server.py | 42 ++++++++++- server/src/tools/completion_items.py | 36 ++++++++-- server/src/tools/variable_index.py | 100 +++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 10 deletions(-) create mode 100644 server/src/tools/variable_index.py diff --git a/server/src/lsp_server.py b/server/src/lsp_server.py index a73491f..42b939f 100644 --- a/server/src/lsp_server.py +++ b/server/src/lsp_server.py @@ -125,10 +125,46 @@ def document_diagnostic(params: lsp.DocumentDiagnosticParams): @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_COMPLETION) def on_completion(params: lsp.CompletionParams) -> lsp.CompletionList: - _ = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + from tools.variable_index import build_variable_index + from tools.completion_items import BUILTIN_VAR_LABELS + + doc = LSP_SERVER.workspace.get_text_document(params.text_document.uri) + + # Base items poco = [item for items in LSP_SERVER.poco_completion.values() for item in items] - items = standard_items.tcl_keyword_list + standard_items.nx_procs + standard_items.nx_variables + poco - return lsp.CompletionList(is_incomplete=False, items=items) + base_items = standard_items.tcl_keyword_list + standard_items.nx_procs + standard_items.nx_variables + poco + + # Build variable index from current document + globals_set, procs_locals, proc_ranges = build_variable_index(doc.source) + + # Always include globals (excluding built-ins) + dynamic_items = [] + for name in sorted(globals_set): + if name not in BUILTIN_VAR_LABELS: + dynamic_items.append(lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable)) + + # Include proc-local variables when cursor is inside that proc + pos = params.position + if pos is not None: + for pr in proc_ranges: + if pr.start_line <= pos.line <= (pr.end_line or pr.start_line): + for name in sorted(procs_locals.get(pr.name, set())): + # Exclude built-ins and globals to avoid duplication + if name not in BUILTIN_VAR_LABELS and name not in globals_set: + dynamic_items.append(lsp.CompletionItem(label=name, kind=lsp.CompletionItemKind.Variable)) + break + + # Merge with de-duplication for variables only + merged: list[lsp.CompletionItem] = [] + seen_var_labels: set[str] = set() + for it in base_items + dynamic_items: + if getattr(it, "kind", None) == lsp.CompletionItemKind.Variable: + if it.label in seen_var_labels: + continue + seen_var_labels.add(it.label) + merged.append(it) + + return lsp.CompletionList(is_incomplete=False, items=merged) # @LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DOCUMENT_SYMBOL) diff --git a/server/src/tools/completion_items.py b/server/src/tools/completion_items.py index f5b42fe..3cade14 100644 --- a/server/src/tools/completion_items.py +++ b/server/src/tools/completion_items.py @@ -2,6 +2,8 @@ from tclint.syntax_tree import Visitor, Command, BareWord, List import lsprotocol.types as lsp from common.load_data import standard_items +BUILTIN_VAR_LABELS = {ci.label for ci in standard_items.nx_variables} + class CompletionItems: def __init__(self): @@ -34,23 +36,25 @@ class _Completion(Visitor): self._custom_functions = [] self._proc_signatures = {} + def _append_unique(self, item: lsp.CompletionItem): + # Avoid duplicate labels within the same file scan + if not any(ci.label == item.label for ci in self._custom_functions): + self._custom_functions.append(item) + def visit_command(self, command: Command): routine = command.routine + # Collect custom proc names and their signatures if routine.contents == "proc" and command.args: first_arg = command.args[0] - if not first_arg.value: + if not getattr(first_arg, "value", None): return if any(item.label == first_arg.value for item in standard_items.nx_procs): return - try: - self._custom_functions.remove(first_arg.value) - except ValueError: - pass - - self._custom_functions.append(lsp.CompletionItem(label=first_arg.value, kind=lsp.CompletionItemKind.Function)) + # Record proc name as a completion item + self._append_unique(lsp.CompletionItem(label=first_arg.value, kind=lsp.CompletionItemKind.Function)) if len(command.args) < 2: return @@ -69,6 +73,24 @@ class _Completion(Visitor): self._proc_signatures[first_arg.value] = param_names + # Collect global variables declared with: global var1 var2 ... + elif routine.contents == "global" and command.args: + for arg in command.args: + if isinstance(arg, BareWord) and getattr(arg, "value", None): + if arg.value not in BUILTIN_VAR_LABELS: + self._append_unique(lsp.CompletionItem(label=arg.value, kind=lsp.CompletionItemKind.Variable)) + + # Collect variables set with explicit global namespace: set ::var_name ... + elif routine.contents == "set" and command.args: + first = command.args[0] + if isinstance(first, BareWord) and getattr(first, "value", None): + var_name = first.value + if var_name.startswith("::"): + base_name = var_name.split("(", 1)[0] + clean_name = base_name[2:] # remove leading '::' for completion display + if clean_name not in BUILTIN_VAR_LABELS: + self._append_unique(lsp.CompletionItem(label=clean_name, kind=lsp.CompletionItemKind.Variable)) + def remove_existing_items(items: list[lsp.CompletionItem], store: dict) -> None: """ diff --git a/server/src/tools/variable_index.py b/server/src/tools/variable_index.py new file mode 100644 index 0000000..b04514c --- /dev/null +++ b/server/src/tools/variable_index.py @@ -0,0 +1,100 @@ +import re +from dataclasses import dataclass +from typing import Dict, Set, List, Tuple + +# Reuse patterns similar to document_symbols +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\}]+)") + + +@dataclass +class ProcRange: + name: str + start_line: int + end_line: int | None = None + + +def build_variable_index(source: str) -> tuple[Set[str], Dict[str, Set[str]], List[ProcRange]]: + """ + Parse Tcl source text and build: + - globals: set of variable names considered global suggestions + - procs: mapping proc_name -> set of local variable names (set without :: inside that proc) + - proc_ranges: list of ProcRange (name, start_line, end_line) + + Rules: + - set ::var -> global var suggestion (strip leading :: and any array index "(") + - set var without :: at top level (not in namespace/proc) -> global suggestion + - set var without :: inside proc -> local to that proc + - set var inside namespace (no ::) is ignored for global suggestions + """ + lines = source.split("\n") + + class Scope: + def __init__(self, name: str, kind: str, start_line: int): + self.name = name + self.kind = kind # "namespace" or "proc" or "root" + self.start_line = start_line + self.brace_count = 0 + + globals_set: Set[str] = set() + procs: Dict[str, Set[str]] = {} + proc_ranges: List[ProcRange] = [] + + scope_stack: List[Scope] = [Scope("", "root", 0)] + + for i, line in enumerate(lines): + ns_match = NS_RE.match(line) + proc_match = PROC_RE.match(line) + set_match = SET_RE.match(line) + + # Namespace scope + if ns_match: + scope_stack.append(Scope(ns_match.group(1), "namespace", i)) + + # Proc scope + elif proc_match: + pname = proc_match.group(1) + scope_stack.append(Scope(pname, "proc", i)) + proc_ranges.append(ProcRange(name=pname, start_line=i, end_line=None)) + + # Track set statements + if set_match: + raw_name = set_match.group(1) + # Normalize array names and leading :: + base = raw_name.split("(", 1)[0] + if base.startswith("::"): + clean = base[2:] + globals_set.add(clean) + else: + top = scope_stack[-1] + if top.kind == "root": + globals_set.add(base) + elif top.kind == "proc": + procs.setdefault(top.name, set()).add(base) + else: + # inside namespace without :: -> ignore for globals + pass + + # Brace balancing for current top scope + 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() + if finished.kind == "proc": + # Update the last matching proc range end_line + for pr in reversed(proc_ranges): + if pr.name == finished.name and pr.end_line is None: + pr.end_line = i + break + + # Finalize any unterminated proc ranges + for pr in proc_ranges: + if pr.end_line is None: + pr.end_line = len(lines) - 1 + + return globals_set, procs, proc_ranges + From 0ed1e887fc3e37670eb466df4c5001c47e8e797b Mon Sep 17 00:00:00 2001 From: Christoph Date: Tue, 12 Aug 2025 07:21:23 +0000 Subject: [PATCH 18/21] Update version to 2025.9.100 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index fc9e5db..c542a52 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "nx-post-support", "displayName": "NX Postprocessor Support", "description": "", - "version": "2025.9.4", + "version": "2025.9.100", "publisher": "Christoph", "icon": "images/nx-1.png", "serverInfo": { @@ -132,4 +132,4 @@ "prettier": "^3.4.2", "typescript": "^5.7.2" } -} \ No newline at end of file +} From ae835089b6cc966b3d16edc8fe0dd04e9fde98e9 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 12 Aug 2025 11:26:52 +0200 Subject: [PATCH 19/21] fix: formatter when \ was deleting --- server/src/tools/formatter.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/server/src/tools/formatter.py b/server/src/tools/formatter.py index e69de29..58edfac 100644 --- a/server/src/tools/formatter.py +++ b/server/src/tools/formatter.py @@ -0,0 +1,31 @@ +from tclint.format import Formatter as BaseFormatter +from typing import List + + +class NxFormatter(BaseFormatter): + """ + Custom formatter that inherits from tclint's Formatter but preserves explicit + line continuations (\\) inside braced expressions when they span multiple lines. + + This avoids generating syntax errors in environments that require a trailing + backslash for multi-line expressions (e.g., certain NX Post interpreters), + while leaving all other formatting behavior unchanged. + """ + + def format_braced_expression(self, expr) -> List[str]: # type: ignore[override] + # This method mirrors BaseFormatter.format_braced_expression but inserts + # a line continuation (" \") between continuation lines similar to + # BaseFormatter.format_expression. + formatted = [""] + for child in expr.children: + lines = self.format(child) + formatted[-1] += lines[0] + for line in lines[1:]: + # add continuation on the previous line; keep next line at the same level + formatted[-1] += " \\" # keep explicit continuation + formatted += [line] + + if expr.pos[0] == expr.end_pos[0]: + return self._brace(formatted) + + return ["{"] + self._indent(formatted, self.opts.indent) + ["}"] From 04798328bdeaf003f895ca960af39560612bdd7b Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 12 Aug 2025 11:27:14 +0200 Subject: [PATCH 20/21] fix: formatter --- server/src/lsp_tclserver.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/src/lsp_tclserver.py b/server/src/lsp_tclserver.py index d59a340..6be449a 100644 --- a/server/src/lsp_tclserver.py +++ b/server/src/lsp_tclserver.py @@ -4,7 +4,8 @@ from typing import List, Optional, Tuple import lsprotocol.types as lsp from pygls.workspace.text_document import TextDocument from tclint.lexer import TclSyntaxError -from tclint.format import Formatter, FormatterOpts +from tclint.format import FormatterOpts +from tools.formatter import NxFormatter as Formatter from tclint.violations import Violation from plugins.poco_plugin import commands from tools import checks, parser From e20bb85ac67013184dea43ac00c43566812b7dc4 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 12 Aug 2025 13:18:08 +0200 Subject: [PATCH 21/21] fix: wrong highligthing --- server/src/tools/semantic_tokens.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/server/src/tools/semantic_tokens.py b/server/src/tools/semantic_tokens.py index b35c155..597634d 100644 --- a/server/src/tools/semantic_tokens.py +++ b/server/src/tools/semantic_tokens.py @@ -4,7 +4,6 @@ from tclint.syntax_tree import Visitor, QuotedWord, Command, BareWord from tclint.commands import get_commands import attrs from common.load_data import standard_items -from tools.completion_items import completion import lsprotocol.types as lsp @@ -86,19 +85,23 @@ class _Highlighter(Visitor): pass def visit_bare_word(self, word: BareWord): - name = word.value - - in_standard = any(item.label == name for item in standard_items.nx_procs) - - in_custom = any(item.label == name for items in self.custom_functions.values() for item in items) - - if in_standard or in_custom: - line, col = word.pos - self._tokens.append((((line - 1, col - 1), len(name), "function", []))) + # Intentionally do not classify bare words as functions here. + # Function highlighting is handled in visit_command for the routine only, + # using completion items (custom functions) as the source of truth. + return def visit_command(self, command: Command): routine = command.routine + # Highlight functions (custom or standard) when used as the routine + name = getattr(routine, "contents", None) + if name: + in_custom = any(item.label == name for items in self.custom_functions.values() for item in items) + in_standard = any(item.label == name for item in standard_items.nx_procs) + if in_custom or in_standard: + line, col = routine.contents_pos + self._tokens.append((((line - 1, col - 1), len(name), "function", []))) + if routine.contents == "puts": line, col = routine.contents_pos self._tokens.append(