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", "": "### Arguments", "": "### Return value", "": "### Example", "": "### Internal Documentation", "": "### 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