add first proc doc

This commit is contained in:
Christoph Brandau
2025-08-11 15:59:09 +02:00
parent 27e2b3bf6e
commit bc745622b9
4 changed files with 291 additions and 25 deletions
+39 -25
View File
@@ -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}")
+5
View File
@@ -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}")
+144
View File
@@ -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>": "### Documentation",
"<Arguments>": "### Arguments",
"<Returnvalue>": "### Return value",
"<Example>": "### Example",
"<Internal Documentation>": "### Internal Documentation",
"<Internal Example>": "### 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