178 lines
6.0 KiB
Python
178 lines
6.0 KiB
Python
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(line_text) for line_text 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",
|
|
}
|
|
|
|
in_example = False
|
|
code_block_open = False
|
|
|
|
def close_code_block_if_open():
|
|
nonlocal code_block_open
|
|
if code_block_open:
|
|
md_lines.append("```")
|
|
code_block_open = False
|
|
|
|
for line in cleaned:
|
|
stripped = line.strip()
|
|
# Convert tags to headings and manage example sections
|
|
if stripped in tag_map:
|
|
# If we hit any new tag, close a pending code block
|
|
close_code_block_if_open()
|
|
heading = tag_map[stripped]
|
|
md_lines.append(heading)
|
|
in_example = heading in ("### Example", "### Internal Example")
|
|
continue
|
|
|
|
if in_example:
|
|
low = stripped.lower()
|
|
if low.startswith("code:"):
|
|
# Open code block if needed and append the code content
|
|
code_text = line.split(":", 1)[1].strip()
|
|
if not code_block_open:
|
|
md_lines.append("```tcl")
|
|
code_block_open = True
|
|
md_lines.append(code_text)
|
|
continue
|
|
# Keep name/desc lines as regular text outside code
|
|
if low.startswith("name:") or low.startswith("desc:"):
|
|
md_lines.append(line)
|
|
continue
|
|
|
|
md_lines.append(line)
|
|
|
|
# Close any dangling code fence at the end of the block
|
|
close_code_block_if_open()
|
|
|
|
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
|