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