From bc745622b927b207205ebc1b1896c4e4897e2b25 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Mon, 11 Aug 2025 15:59:09 +0200 Subject: [PATCH] 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 +