add first proc doc
This commit is contained in:
+39
-25
@@ -76,6 +76,8 @@ def did_open(params: lsp.DidOpenTextDocumentParams) -> None:
|
|||||||
"""LSP handler for textDocument/didOpen request."""
|
"""LSP handler for textDocument/didOpen request."""
|
||||||
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
document = LSP_SERVER.workspace.get_text_document(params.text_document.uri)
|
||||||
LSP_SERVER.compute_diagnostics(document)
|
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)
|
@LSP_SERVER.feature(lsp.TEXT_DOCUMENT_DID_SAVE)
|
||||||
@@ -202,6 +204,13 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
|||||||
except IndexError:
|
except IndexError:
|
||||||
return None
|
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):
|
for m in re.finditer(r"\b\w+\b", line):
|
||||||
if m.start() <= col <= m.end():
|
if m.start() <= col <= m.end():
|
||||||
token = m.group(0)
|
token = m.group(0)
|
||||||
@@ -209,49 +218,50 @@ def hover(params: lsp.HoverParams) -> lsp.Hover:
|
|||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# 1) If token is a known MOM proc/variable, return built-in hover
|
||||||
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["label"] == command), None)
|
match = next((item for item in all_items if item["label"] == command), None)
|
||||||
if not match:
|
if match and match.get("kind") == "function":
|
||||||
return None
|
label = match.get("label", "")
|
||||||
|
parameters = match.get("parameters", [])
|
||||||
if not match.get("kind") == "function":
|
param_lines = "\n".join(f"- `{p['name']}`: {p['desc']}" for p in parameters) or "_None_"
|
||||||
return None
|
example_data = match.get("example", [])
|
||||||
|
example_md = "\n".join(f"{line}" for line in example_data)
|
||||||
label = match.get("label", "")
|
returns_data = match.get("returns", ["None"])
|
||||||
|
returns_md = "\n".join(f"- {line}" for line in returns_data)
|
||||||
parameters = match.get("parameters", [])
|
doc_md = f"""\
|
||||||
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))
|
||||||
|
|
||||||
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
|
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
|
||||||
|
# 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:
|
except Exception as e:
|
||||||
log_to_output(f"Fehler beim Parsen von {filepath}: {e}")
|
log_to_output(f"Fehler beim Parsen von {filepath}: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -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 build_proc_docs
|
||||||
|
|
||||||
|
|
||||||
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,6 +36,8 @@ 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
|
||||||
completion.reset()
|
completion.reset()
|
||||||
@@ -44,6 +48,7 @@ class TclLanguageServer(server.LanguageServer):
|
|||||||
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
|
||||||
|
self.proc_docs[filepath] = build_proc_docs(tree, document.source)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.debug(f"Error parsing {filepath}: {e}")
|
logging.debug(f"Error parsing {filepath}: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
"# <Documentation>\n"
|
||||||
|
"# This procedure creates a new directory if it does not exist.\n"
|
||||||
|
"# <Arguments>\n"
|
||||||
|
"# directory\n"
|
||||||
|
"#\tThe full pathname of the directory to be created.\n"
|
||||||
|
"# <Returnvalue>\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 = [
|
||||||
|
"#_________________________________________________________________________________________________",
|
||||||
|
"# <Documentation>",
|
||||||
|
"# 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
|
||||||
|
|
||||||
Reference in New Issue
Block a user