add first proc doc

This commit is contained in:
Christoph Brandau
2025-08-11 10:15:58 +02:00
parent 27e2b3bf6e
commit 0bb4ef731e
3 changed files with 167 additions and 28 deletions
+60 -27
View File
@@ -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.completion_items import completion, remove_existing_items, remove_shared_keys
from tools.inlay_hint import InlayHintGenerator from tools.inlay_hint import InlayHintGenerator
from tools.file_sourcing import get_all_psc_files, read_psc_file from tools.file_sourcing import get_all_psc_files, read_psc_file
from tools.proc_docs import ProcDocCollector
from lsp_tclserver import TclLanguageServer from lsp_tclserver import TclLanguageServer
@@ -209,48 +210,70 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
else: else:
return None return None
# 1) Built-in MOM procs / variables
command = token command = token
data = standard_items.json_data data = standard_items.json_data
all_items = data.get("MOM_procs", []) + data.get("mom_variables", []) all_items = data.get("MOM_procs", []) + data.get("mom_variables", [])
match = next((item for item in all_items if item.get("label") == command), None)
match = next((item for item in all_items if item["label"] == command), None) if match and match.get("kind") == "function":
if not match: label = match.get("label", "")
return None parameters = match.get("parameters", [])
param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_"
if not match.get("kind") == "function": example_data = match.get("example", [])
return None example_md = "\n".join(f"{line}" for line in example_data)
returns_data = match.get("returns", ["None"])
label = match.get("label", "") returns_md = "\n".join(f"- {line}" for line in returns_data)
doc_md = f"""\
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} ### 📘 {label}
**Purpose** **Purpose**
{match.get("description", "No description available.")} {match.get("description", "No description available.")}
**Format** **Format**
`{match.get("format", label)}` `{match.get("format", label)}`
**Parameters** **Parameters**
{param_lines} {param_lines}
**Return value** **Return value**
{returns_md} {returns_md}
**Example** **Example**
```tcl ```tcl
{example_md}""" {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)) 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( capabilities=lsp.ServerCapabilities(
document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True), document_formatting_provider=GLOBAL_SETTINGS.get("formatter", True),
semantic_tokens_provider=lsp.SemanticTokensOptions(legend=semantic_tokens_legend, full=True, range=False), 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(): if not filepath.exists():
continue continue
completion.reset() 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) tree = LSP_SERVER.parser.parse(document.source)
# completions and signatures
tree.accept(completion, recurse=True) tree.accept(completion, recurse=True)
remove_existing_items(completion.custom_functions, LSP_SERVER.poco_completion) remove_existing_items(completion.custom_functions, LSP_SERVER.poco_completion)
LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions LSP_SERVER.poco_completion[str(filepath)] = completion.custom_functions
remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures) remove_shared_keys(LSP_SERVER.proc_signatures, completion.proc_signatures)
LSP_SERVER.proc_signatures[str(filepath)] = 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: except Exception as e:
log_to_output(f"Fehler beim Parsen von {filepath}: {e}") log_to_output(f"Fehler beim Parsen von {filepath}: {e}")
+11 -1
View File
@@ -10,6 +10,7 @@ from plugins.poco_plugin import commands
from tools import checks, parser from tools import checks, parser
from pygls import server, uris from pygls import server, uris
from tools.completion_items import completion, remove_existing_items, remove_shared_keys from tools.completion_items import completion, remove_existing_items, remove_shared_keys
from tools.proc_docs import ProcDocCollector
DIAGNOSTIC_SOURCE = "nx-post-support" DIAGNOSTIC_SOURCE = "nx-post-support"
@@ -24,6 +25,7 @@ class TclLanguageServer(server.LanguageServer):
self.diagnostics = {} self.diagnostics = {}
self.poco_completion: dict = {} self.poco_completion: dict = {}
self.proc_signatures: dict = {} self.proc_signatures: dict = {}
self.proc_docs: dict = {}
def update_poco_completion_for_file(self, document: TextDocument): def update_poco_completion_for_file(self, document: TextDocument):
"""Update poco_completion for a specific file when it changes""" """Update poco_completion for a specific file when it changes"""
@@ -34,16 +36,24 @@ class TclLanguageServer(server.LanguageServer):
del self.poco_completion[filepath] del self.poco_completion[filepath]
if filepath in self.proc_signatures: if filepath in self.proc_signatures:
del self.proc_signatures[filepath] 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() completion.reset()
try: try:
tree = self.parser.parse(document.source) tree = self.parser.parse(document.source)
# completions and signatures
tree.accept(completion, recurse=True) tree.accept(completion, recurse=True)
remove_existing_items(completion.custom_functions, self.poco_completion) remove_existing_items(completion.custom_functions, self.poco_completion)
self.poco_completion[filepath] = completion.custom_functions self.poco_completion[filepath] = completion.custom_functions
remove_shared_keys(self.proc_signatures, completion.proc_signatures) remove_shared_keys(self.proc_signatures, completion.proc_signatures)
self.proc_signatures[filepath] = 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: except Exception as e:
logging.debug(f"Error parsing {filepath}: {e}") logging.debug(f"Error parsing {filepath}: {e}")
+96
View File
@@ -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 <name> { ... }` 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 <Documentation>, keep them; the
# hover can render them as-is or the consumer can improve formatting later.
return filtered