diff options
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/__pycache__/reformat_docstrings.cpython-39.pyc | bin | 0 -> 23998 bytes | |||
| -rw-r--r-- | tools/docstring_formatter.py | 908 | ||||
| -rw-r--r-- | tools/reformat_docstrings.py | 1132 |
3 files changed, 2040 insertions, 0 deletions
diff --git a/tools/__pycache__/reformat_docstrings.cpython-39.pyc b/tools/__pycache__/reformat_docstrings.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..00b8880 --- /dev/null +++ b/tools/__pycache__/reformat_docstrings.cpython-39.pyc diff --git a/tools/docstring_formatter.py b/tools/docstring_formatter.py new file mode 100644 index 0000000..1aeaf88 --- /dev/null +++ b/tools/docstring_formatter.py @@ -0,0 +1,908 @@ +#!/usr/bin/env python3 +""" +Utility to normalize GDScript docstrings in Escoria to the format described in AGENTS.md. + +This script focuses on reformatting existing docstrings without discarding information. +It reconstructs parameter tables, enforces the `[br]` line break tokens, wraps type names +in backticks, and preserves warnings / notes. The primary target is public-facing methods +and command classes under `addons/escoria-core`. +""" + +from __future__ import annotations + +import argparse +import re +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + + +DOC_PREFIX = "##" +BR_TOKEN = "[br]" +PARAM_TABLE_HEADER = [ + "| Name | Type | Description | Required? |", + "|:-----|:-----|:------------|:----------|", +] + + +def strip_prefix(text: str, prefix: str) -> str: + if text.startswith(prefix): + return text[len(prefix) :] + return text + + +def strip_doc_line(line: str, indent: str) -> str: + if not line.startswith(indent): + # If indentation is inconsistent, fall back to lstrip to avoid losing content. + line = line.lstrip() + else: + line = line[len(indent) :] + content = strip_prefix(line, DOC_PREFIX).lstrip() + while content.startswith("#"): + content = content[1:].lstrip() + return content + + +def strip_br_suffix(text: str) -> Tuple[str, bool]: + """ + Remove a trailing `[br]` token from the given string. + Returns a tuple of (new_text, removed_br). + """ + updated = text.rstrip() + if updated.endswith(BR_TOKEN): + updated = updated[: -len(BR_TOKEN)].rstrip() + return updated, True + return text, False + + +def ensure_br(text: str) -> str: + stripped, had_br = strip_br_suffix(text) + if had_br: + return stripped + f"{BR_TOKEN}" + return stripped + + +def ensure_type_backticks(type_text: str) -> str: + clean = type_text.strip() + if not clean: + return "`Variant`" + if clean.startswith("`") and clean.endswith("`"): + return clean + return f"`{clean}`" + + +def collapse_spaces(text: str) -> str: + return re.sub(r"\s+", " ", text.strip()) + + +@dataclass +class ParameterDoc: + name: str + type_name: str + description: str + required: str + + def ensure_defaults(self) -> None: + if not self.type_name: + self.type_name = "Variant" + if not self.description: + self.description = "Description not provided." + if self.required not in {"yes", "no"}: + self.required = "yes" + + +@dataclass +class ReturnDoc: + type_name: str + description: str + + def ensure_defaults(self) -> None: + if not self.type_name: + self.type_name = "Variant" + if not self.description: + self.description = "Description not provided." + + +@dataclass +class DocstringData: + description_lines: List[str] = field(default_factory=list) + parameters: Dict[str, ParameterDoc] = field(default_factory=dict) + returns: Optional[ReturnDoc] = None + notes_lines: List[str] = field(default_factory=list) + extra_sections: Dict[str, List[str]] = field(default_factory=dict) + trailer_lines: List[str] = field(default_factory=list) + + +def parse_section_lines(raw_lines: Sequence[str]) -> Dict[str, List[str]]: + sections: Dict[str, List[str]] = {"description": []} + current = "description" + + for line in raw_lines: + raw = line.strip() + normalized = raw + if normalized.endswith(BR_TOKEN): + normalized = normalized[: -len(BR_TOKEN)].rstrip() + if normalized: + normalized_lower = normalized.lower() + matched = False + for key, tokens in SECTION_TOKENS.items(): + for token in tokens: + token_lower = token.lower() + rest = "" + if normalized_lower == token_lower: + matched = True + elif normalized_lower.startswith(token_lower + " "): + rest = normalized[len(token):].lstrip(" :-") + matched = True + elif normalized_lower.startswith(token_lower + ":"): + rest = normalized[len(token):].lstrip(" :-") + matched = True + elif normalized_lower.startswith(token_lower + "-"): + rest = normalized[len(token):].lstrip(" :-") + matched = True + if matched: + current = key + sections.setdefault(current, []) + if rest: + sections[current].append(rest) + break + if matched: + break + if matched: + continue + sections.setdefault(current, []).append(line) + + return sections + + +def clean_doc_lines(lines: Iterable[str]) -> List[str]: + cleaned: List[str] = [] + for line in lines: + text = line.strip() + if not text: + cleaned.append("") + continue + # Remove `[br]` tokens because we'll re-apply them later. + text, _ = strip_br_suffix(text) + cleaned.append(text) + # Trim leading / trailing blank entries. + while cleaned and not cleaned[0]: + cleaned.pop(0) + while cleaned and not cleaned[-1]: + cleaned.pop() + return cleaned + + +def parse_parameter_table(lines: Sequence[str]) -> Dict[str, ParameterDoc]: + params: Dict[str, ParameterDoc] = {} + for line in lines: + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [cell.strip() for cell in stripped.split("|")[1:-1]] + if len(cells) != 4: + continue + if cells[0].lower() == "name" and cells[1].lower().startswith("type"): + continue + if all(cell and all(ch in ":-" for ch in cell) for cell in cells): + continue + name = cells[0] + type_name = cells[1] + description = cells[2] + required = cells[3].lower() + params[name] = ParameterDoc( + name=name, + type_name=type_name.strip("`"), + description=description, + required=required, + ) + return params + + +def parse_parameter_bullets(lines: Sequence[str]) -> Dict[str, ParameterDoc]: + params: Dict[str, ParameterDoc] = {} + current: Optional[ParameterDoc] = None + + for line in lines: + stripped = line.strip() + if not stripped: + continue + text, _ = strip_br_suffix(stripped) + text = text.strip() + if not text: + continue + lowered = text.lower() + if lowered.startswith("this method does not accept parameters") or lowered.startswith("this command does not accept parameters"): + return {} + + if text.startswith("-"): + content = text[1:].strip() + if ":" in content: + name_part, desc_part = content.split(":", 1) + name = re.sub(r"[`\*]", "", name_part).strip() + description = desc_part.strip() + else: + name = re.sub(r"[`\*]", "", content).strip() + description = "" + current = ParameterDoc( + name=name, + type_name="", + description=description, + required="yes", + ) + params[name] = current + continue + + if current: + if current.description: + current.description += " " + text + else: + current.description = text + + return params + + +def parse_parameters_section(lines: Sequence[str]) -> Dict[str, ParameterDoc]: + if any(line.strip().startswith("|") for line in lines): + return parse_parameter_table(lines) + return parse_parameter_bullets(lines) + + +RETURN_PATTERN = re.compile( + r"^`?(?P<type>[A-Za-z0-9_\.]+)`?\s*(?:—|-)\s*(?P<desc>.+)$" +) +RETURN_MARKER_RE = re.compile(r"\*+returns?\*+", re.IGNORECASE) + + +def parse_return_section(lines: Sequence[str]) -> Optional[ReturnDoc]: + cleaned: List[str] = [] + for line in lines: + stripped = line.strip() + if not stripped: + continue + text, _ = strip_br_suffix(stripped) + text = text.strip() + if not text: + continue + text = RETURN_MARKER_RE.sub("", text).strip() + cleaned.append(collapse_spaces(text)) + if not cleaned: + return None + + first_line = cleaned[0] + match = RETURN_PATTERN.match(first_line) + if match: + return ReturnDoc(match.group("type"), match.group("desc")) + + if first_line.lower().startswith("returns"): + desc = first_line.split(":", 1)[1].strip() if ":" in first_line else first_line + return ReturnDoc("", desc) + + # Fall back to treating the line as description only. + return ReturnDoc("", cleaned[0]) + + +WARNING_PREFIXES = ("**Warning**", "**Note**", "**Notes**", "**Warning:**", "**Note:**") +SECTION_TOKENS = { + "parameters": ["#### parameters", "**parameters**", "*parameters*", "parameters", "parameters:"], + "returns": ["#### returns", "**returns**", "*returns*", "returns:", "return:", "return value", "return value:"], + "notes": ["#### notes", "**notes**", "*notes*", "notes", "notes:"], +} + + +def extract_description_and_notes(lines: Sequence[str]) -> Tuple[List[str], List[str]]: + description: List[str] = [] + notes: List[str] = [] + + for line in clean_doc_lines(lines): + if any(line.startswith(prefix) for prefix in WARNING_PREFIXES): + notes.append(line) + elif line.startswith("@"): + notes.append(line) + else: + description.append(line) + + return description, notes + + +def extract_note_lines(lines: Sequence[str]) -> Tuple[List[str], List[str]]: + notes: List[str] = [] + filtered: List[str] = [] + note_active = False + for line in lines: + stripped = line.strip() + if not stripped: + filtered.append(line) + note_active = False + continue + text, _ = strip_br_suffix(stripped) + text = text.strip() + if not text: + filtered.append(line) + note_active = False + continue + if note_active: + notes[-1] += f" {text}" + continue + if any(text.startswith(prefix) for prefix in WARNING_PREFIXES): + notes.append(text) + note_active = True + continue + if text.startswith("@"): + notes.append(text) + note_active = False + continue + filtered.append(line) + return notes, filtered + + +def parse_docstring(raw_lines: Sequence[str]) -> DocstringData: + sections = parse_section_lines(raw_lines) + description_lines, note_lines = extract_description_and_notes(sections.get("description", [])) + data = DocstringData(description_lines=description_lines, notes_lines=note_lines) + + if "parameters" in sections: + param_notes, param_lines = extract_note_lines(sections["parameters"]) + data.notes_lines.extend(param_notes) + data.parameters = parse_parameters_section(param_lines) + if "returns" in sections: + data.returns = parse_return_section(sections["returns"]) + if "notes" in sections: + note_desc, note_warn = extract_description_and_notes(sections["notes"]) + data.notes_lines.extend(note_desc) + data.notes_lines.extend(note_warn) + + extra_keys = { + key + for key in sections.keys() + if key not in {"description", "parameters", "returns", "notes"} + } + for key in extra_keys: + data.extra_sections[key] = clean_doc_lines(sections[key]) + + return data + + +FUNC_DEF_RE = re.compile(r"func\s+([A-Za-z0-9_]+)") + + +def get_function_signature(lines: Sequence[str], start_index: int) -> Tuple[Optional[str], Optional[str]]: + """ + Returns (function_name, signature_text) starting at `start_index`. + The caller should ensure that lines[start_index] is part of a `func` definition. + """ + if start_index >= len(lines): + return None, None + + idx = start_index + while idx < len(lines) and not lines[idx].strip(): + idx += 1 + + if idx >= len(lines): + return None, None + + first_line = lines[idx] + match = FUNC_DEF_RE.search(first_line) + if not match: + return None, None + name = match.group(1) + + signature_parts = [first_line.rstrip("\n")] + open_parens = first_line.count("(") - first_line.count(")") + while idx + 1 < len(lines) and (open_parens > 0 or not first_line.rstrip().endswith(":")): + idx += 1 + next_line = lines[idx].rstrip("\n") + signature_parts.append(next_line) + open_parens += next_line.count("(") - next_line.count(")") + first_line = next_line + if open_parens <= 0 and next_line.rstrip().endswith(":"): + break + + signature_text = " ".join(part.strip() for part in signature_parts) + return name, signature_text + + +TYPE_HINT_RE = re.compile(r":\s*([^=\s]+)") + + +def parse_func_parameters(signature_text: str) -> List[ParameterDoc]: + if "(" not in signature_text or ")" not in signature_text: + return [] + inner = signature_text[signature_text.index("(") + 1 : signature_text.rfind(")")] + # Remove trailing comments. + inner = inner.split("#", 1)[0] + + params: List[ParameterDoc] = [] + current = "" + depth = 0 + for char in inner: + if char == "(": + depth += 1 + current += char + elif char == ")": + depth = max(depth - 1, 0) + current += char + elif char == "," and depth == 0: + token = current.strip() + if token: + params.append(_build_param_from_token(token)) + current = "" + else: + current += char + + token = current.strip() + if token: + params.append(_build_param_from_token(token)) + + return params + + +DEFAULT_VALUE_RE = re.compile(r"=\s*(.+)$") + + +def _build_param_from_token(token: str) -> ParameterDoc: + name = token + type_name = "" + required = "yes" + description = "" + + default_match = DEFAULT_VALUE_RE.search(token) + if default_match: + required = "no" + token = token[: default_match.start()].strip() + + if ":" in token: + name_part, type_part = token.split(":", 1) + name = name_part.strip() + type_name = collapse_spaces(type_part) + else: + name = token.strip() + + if not type_name: + type_name = "Variant" + + return ParameterDoc( + name=name, + type_name=type_name, + description=description, + required=required, + ) + + +RETURN_TYPE_RE = re.compile(r"->\s*([^:\s]+)") + + +def parse_return_from_signature(signature_text: str) -> Optional[str]: + match = RETURN_TYPE_RE.search(signature_text) + if match: + return collapse_spaces(match.group(1)) + return None + + +COMMAND_EXTENDS_RE = re.compile(r"extends\s+ESCBaseCommand") + + +def detect_command_class(context_lines: Sequence[str], start_index: int) -> bool: + idx = start_index + while idx < len(context_lines) and not context_lines[idx].strip(): + idx += 1 + if idx >= len(context_lines): + return False + return COMMAND_EXTENDS_RE.search(context_lines[idx]) is not None + + +COMMAND_SIGNATURE_RE = re.compile(r"`([^`]+)`") + + +def parse_command_signature(line: str) -> Tuple[str, str, List[ParameterDoc]]: + """ + Parse a command signature line like + `anim(object: String[, reverse: Boolean])` + and return (signature_text, command_name, parameters). + """ + match = COMMAND_SIGNATURE_RE.search(line) + if not match: + return "", "", [] + signature = match.group(1) + if "(" not in signature: + return signature.strip(), signature.strip(), [] + name = signature[: signature.index("(")].strip() + params_text = signature[signature.index("(") + 1 : signature.rfind(")")] + + params: List[ParameterDoc] = [] + current = "" + depth = 0 + for char in params_text: + if char == "[": + if current.strip(): + params.append(_build_command_param(current.strip(), optional=(depth > 0))) + current = "" + depth += 1 + elif char == "]": + if current.strip(): + params.append(_build_command_param(current.strip(), optional=True)) + current = "" + depth = max(depth - 1, 0) + elif char == "," and depth == 0: + if current.strip(): + params.append(_build_command_param(current.strip(), optional=False)) + current = "" + else: + current += char + + if current.strip(): + params.append(_build_command_param(current.strip(), optional=(depth > 0))) + + # Filter out empty entries introduced by commas. + params = [param for param in params if param.name] + return signature.strip(), name, params + + +def _build_command_param(token: str, optional: bool) -> ParameterDoc: + token = token.strip() + required = "no" if optional else "yes" + if token.startswith(","): + token = token[1:].strip() + + if ":" in token: + name_part, type_part = token.split(":", 1) + name = name_part.strip() + type_name = collapse_spaces(type_part) + else: + name = collapse_spaces(token) + type_name = "Variant" + + return ParameterDoc( + name=name, + type_name=type_name, + description="", + required=required, + ) + + +def merge_parameter_details( + signature_params: List[ParameterDoc], + existing_params: Dict[str, ParameterDoc], +) -> List[ParameterDoc]: + merged: List[ParameterDoc] = [] + for param in signature_params: + existing = existing_params.get(param.name) + if existing: + description = existing.description or param.description + type_name = param.type_name or existing.type_name + required = param.required or existing.required + merged.append( + ParameterDoc( + name=param.name, + type_name=type_name, + description=description, + required=required, + ) + ) + else: + merged.append(param) + + # Include any existing parameters we could not match (to avoid data loss). + for name, param in existing_params.items(): + if name not in {p.name for p in merged}: + merged.append(param) + + for param in merged: + param.ensure_defaults() + + return merged + + +def merge_return_details( + existing: Optional[ReturnDoc], + fallback_type: Optional[str], +) -> ReturnDoc: + if existing: + existing.ensure_defaults() + if fallback_type and (not existing.type_name or existing.type_name == "Variant"): + existing.type_name = fallback_type + if existing.type_name == "void" and existing.description == "Description not provided.": + existing.description = "No value returned." + return existing + + type_name = fallback_type or "Variant" + default_description = "No value returned." if type_name == "void" else "Description not provided." + doc = ReturnDoc( + type_name=type_name, + description=default_description, + ) + doc.ensure_defaults() + return doc + + +def format_description_lines(lines: Sequence[str], indent: str) -> List[str]: + if not lines: + lines = ["Description not provided."] + formatted: List[str] = [] + for line in lines: + formatted.append(f"{indent}{DOC_PREFIX} {line}{BR_TOKEN}") + return formatted + + +def format_blank_line(indent: str) -> List[str]: + return [f"{indent}{DOC_PREFIX} {BR_TOKEN}"] + + +def format_parameters_section(parameters: List[ParameterDoc], indent: str) -> List[str]: + output: List[str] = [] + output.append(f"{indent}{DOC_PREFIX} #### Parameters{BR_TOKEN}") + output.extend(format_blank_line(indent)) + if parameters: + output.extend(f"{indent}{DOC_PREFIX} {line}{BR_TOKEN}" for line in PARAM_TABLE_HEADER) + for param in parameters: + output.append( + f"{indent}{DOC_PREFIX} |{param.name}|{ensure_type_backticks(param.type_name)}|{param.description}|{param.required}|{BR_TOKEN}" + ) + else: + output.append(f"{indent}{DOC_PREFIX} This method does not accept parameters.{BR_TOKEN}") + return output + + +def format_returns_section(return_doc: ReturnDoc, indent: str) -> List[str]: + output: List[str] = [] + output.append(f"{indent}{DOC_PREFIX} #### Returns{BR_TOKEN}") + output.extend(format_blank_line(indent)) + description = return_doc.description.strip() + if description and not description.endswith("."): + description += "." + output.append( + f"{indent}{DOC_PREFIX} {ensure_type_backticks(return_doc.type_name)} — {description}" + ) + return output + + +def format_notes_section(notes: Sequence[str], indent: str) -> List[str]: + if not notes: + return [] + output: List[str] = [] + output.append(f"{indent}{DOC_PREFIX} #### Notes{BR_TOKEN}") + output.extend(format_blank_line(indent)) + for note in notes: + output.append(f"{indent}{DOC_PREFIX} {note}{BR_TOKEN}") + return output + + +def format_extra_sections(extra: Dict[str, List[str]], indent: str) -> List[str]: + lines: List[str] = [] + for name, contents in extra.items(): + title = name.title() + lines.append(f"{indent}{DOC_PREFIX} #### {title}{BR_TOKEN}") + lines.extend(format_blank_line(indent)) + if contents: + for entry in contents: + lines.append(f"{indent}{DOC_PREFIX} {entry}{BR_TOKEN}") + else: + lines.append(f"{indent}{DOC_PREFIX} {BR_TOKEN}") + return lines + + +def rebuild_function_docstring( + raw_lines: Sequence[str], + indent: str, + surrounding_lines: Sequence[str], + context_index: int, +) -> List[str]: + data = parse_docstring(raw_lines) + func_name, signature_text = get_function_signature(surrounding_lines, context_index) + signature_params: List[ParameterDoc] = [] + return_type_hint: Optional[str] = None + + if signature_text: + signature_params = parse_func_parameters(signature_text) + return_type_hint = parse_return_from_signature(signature_text) + + parameters = merge_parameter_details(signature_params, data.parameters) + return_doc = merge_return_details(data.returns, return_type_hint) + + rebuilt: List[str] = [] + rebuilt.extend(format_description_lines(data.description_lines, indent)) + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_parameters_section(parameters, indent)) + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_returns_section(return_doc, indent)) + notes = data.notes_lines + extra = data.extra_sections + if notes: + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_notes_section(notes, indent)) + if extra: + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_extra_sections(extra, indent)) + return rebuilt + + +def rebuild_command_docstring( + raw_lines: Sequence[str], + indent: str, +) -> List[str]: + data = parse_docstring(raw_lines) + + cleaned_lines = clean_doc_lines(raw_lines) + first_nonempty = next((line for line in cleaned_lines if line), "") + signature_text, command_name, signature_params = parse_command_signature(first_nonempty) + if not signature_text: + return rebuild_generic_docstring(raw_lines, indent) + parameters = merge_parameter_details(signature_params, data.parameters) + + rebuilt: List[str] = [] + + # Recreate the brief description with no trailing [br] per requirement. + if signature_text: + rebuilt.append(f"{indent}{DOC_PREFIX} `{signature_text}`") + else: + # Preserve the first line even if no signature detected. + original_first = raw_lines[0].strip() + rebuilt.append(f"{indent}{DOC_PREFIX} {strip_prefix(original_first, DOC_PREFIX).strip()}") + + rebuilt.append(f"{indent}{DOC_PREFIX}") + # Append the remaining description lines (skip the first signature line). + signature_line = f"`{signature_text}`" if signature_text else "" + description_lines = [line for line in data.description_lines if line != signature_line] + if not description_lines: + # If removing the signature removed everything, fall back to original description lines. + description_lines = data.description_lines + description_lines = [line for line in description_lines if line] + rebuilt.extend(format_description_lines(description_lines, indent)) + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_parameters_section(parameters, indent)) + if data.notes_lines or data.extra_sections or data.returns: + rebuilt.extend(format_blank_line(indent)) + if data.notes_lines: + rebuilt.extend(format_notes_section(data.notes_lines, indent)) + if data.returns: + rebuilt.extend(format_returns_section(data.returns, indent)) + if data.extra_sections: + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_extra_sections(data.extra_sections, indent)) + if data.trailer_lines: + rebuilt.extend(data.trailer_lines) + return rebuilt + + +def rebuild_generic_docstring(raw_lines: Sequence[str], indent: str) -> List[str]: + data = parse_docstring(raw_lines) + rebuilt: List[str] = [] + rebuilt.extend(format_description_lines(data.description_lines, indent)) + if data.parameters: + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_parameters_section(list(data.parameters.values()), indent)) + if data.returns: + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_returns_section(data.returns, indent)) + if data.notes_lines: + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_notes_section(data.notes_lines, indent)) + if data.extra_sections: + rebuilt.extend(format_blank_line(indent)) + rebuilt.extend(format_extra_sections(data.extra_sections, indent)) + return rebuilt + + +def find_next_code_line(lines: Sequence[str], start_index: int) -> int: + idx = start_index + while idx < len(lines): + stripped = lines[idx].strip() + if not stripped: + idx += 1 + continue + if stripped.startswith("#"): + idx += 1 + continue + break + return idx + + +def collect_docstring_block( + lines: Sequence[str], start_index: int +) -> Tuple[int, List[str], List[str], str]: + indent = re.match(r"\s*", lines[start_index]).group(0) + original_block: List[str] = [] + content_block: List[str] = [] + idx = start_index + while idx < len(lines): + line = lines[idx] + stripped = line.lstrip() + if not stripped.startswith(DOC_PREFIX): + break + original_block.append(line.rstrip("\n")) + content_block.append(strip_doc_line(line.rstrip("\n"), indent)) + idx += 1 + return idx, original_block, content_block, indent + + +def rebuild_docstring( + raw_block: List[str], + indent: str, + full_lines: Sequence[str], + context_index: int, +) -> List[str]: + next_code_idx = find_next_code_line(full_lines, context_index) + if next_code_idx >= len(full_lines): + return [f"{indent}{DOC_PREFIX} {line}" for line in raw_block] + + next_line = full_lines[next_code_idx].strip() + if next_line.startswith("func "): + return rebuild_function_docstring(raw_block, indent, full_lines, next_code_idx) + if next_line.startswith("class_name") or next_line.startswith("extends"): + if detect_command_class(full_lines, next_code_idx): + return rebuild_command_docstring(raw_block, indent) + return rebuild_generic_docstring(raw_block, indent) + + return rebuild_generic_docstring(raw_block, indent) + + +def process_file(path: Path) -> bool: + original_lines = path.read_text(encoding="utf-8").splitlines() + new_lines: List[str] = [] + idx = 0 + changed = False + + while idx < len(original_lines): + line = original_lines[idx] + stripped = line.lstrip() + if stripped.startswith(DOC_PREFIX): + block_end, original_block, content_block, indent = collect_docstring_block( + original_lines, idx + ) + rebuilt = rebuild_docstring(content_block, indent, original_lines, block_end) + new_lines.extend(rebuilt) + idx = block_end + if not changed: + if len(original_block) != len(rebuilt): + changed = True + else: + for raw_line, new_line in zip(original_block, rebuilt): + if raw_line != new_line: + changed = True + break + continue + + new_lines.append(line) + idx += 1 + + if changed: + path.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + return changed + + +def iter_gd_files(root: Path) -> Iterable[Path]: + for path in root.rglob("*.gd"): + if path.is_file(): + yield path + + +def main() -> None: + parser = argparse.ArgumentParser(description="Normalize GDScript docstrings per AGENTS.md.") + parser.add_argument( + "paths", + nargs="*", + default=["addons/escoria-core"], + help="Directories or files to process (default: addons/escoria-core).", + ) + args = parser.parse_args() + + targets: List[Path] = [] + for value in args.paths: + path = Path(value) + if path.is_file(): + targets.append(path) + elif path.is_dir(): + targets.extend(iter_gd_files(path)) + + processed = 0 + changed = 0 + for path in sorted(set(targets)): + processed += 1 + if process_file(path): + changed += 1 + + print(f"Processed {processed} files; updated {changed}.") + + +if __name__ == "__main__": + main() diff --git a/tools/reformat_docstrings.py b/tools/reformat_docstrings.py new file mode 100644 index 0000000..31dafd6 --- /dev/null +++ b/tools/reformat_docstrings.py @@ -0,0 +1,1132 @@ +#!/usr/bin/env python3 +import re +from pathlib import Path +from typing import Dict, List, Optional, Tuple + + +ROOT = Path("addons/escoria-core") + +DEFAULT_PARAM_DESC = "No description provided." + +PARAM_OVERRIDES_BY_SIGNATURE = { + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmts.gd", + "func init(name: ESCToken, initializer: ESCGrammarExpr):", + "name", + ): "Token representing the variable's name.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_stmts.gd", + "func init(name: ESCToken, target: ESCGrammarExprs.Literal, flags: Dictionary, body: ESCGrammarStmts.Block, object_global_id: String):", + "name", + ): "Token representing the event name.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_exprs.gd", + "func init(name: ESCToken):", + "name", + ): "Token representing the variable's name.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_grammar_exprs.gd", + "func init(name: ESCToken, value: ESCGrammarExpr):", + "name", + ): "Token representing the variable's name to assign.", +} + +PARAM_OVERRIDES_BY_FUNCTION = { + ( + "addons/escoria-core/game/esc_project_settings_manager.gd", + "register_setting", + "name", + ): "Fully qualified Project Settings key to register.", + ( + "addons/escoria-core/game/esc_project_settings_manager.gd", + "remove_setting", + "name", + ): "Fully qualified Project Settings key to remove.", + ( + "addons/escoria-core/game/core-scripts/esc_animation_player.gd", + "_on_animation_finished", + "name", + ): "Name of the animation that triggered the callback.", + ( + "addons/escoria-core/game/scenes/dialogs/esc_dialog_player.gd", + "_update_dialog_manager", + "dialog_manager_type", + ): "Type name of the dialog manager implementation to instantiate.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_object_manager.gd", + "register_object", + "auto_unregister", + ): "(optional) Automatically unregister the object when its node exits the scene tree (default: `true`).", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "is_channel_free", + "name", + ): "Name of the channel to inspect.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "get_running_event", + "name", + ): "Name of the channel whose running event should be returned.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd", + "is_valid_key", + "name", + ): "Token describing the variable name to look up.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd", + "get_value", + "name", + ): "Token describing the variable name whose value is requested.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd", + "assign", + "name", + ): "Token describing the variable name to assign.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd", + "define", + "name", + ): "Variable name to register in this scope.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd", + "get_at", + "name", + ): "Variable name to resolve at the requested scope depth.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_environment.gd", + "assign_at", + "name", + ): "Token describing the variable name to modify at the requested scope depth.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_interpreter.gd", + "look_up_variable", + "name", + ): "Token representing the variable name to resolve.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_script_builder.gd", + "add_event", + "name", + ): "Event identifier to add to the script.", + ( + "addons/escoria-core/game/core-scripts/esc/compiler/esc_script_builder.gd", + "add_command", + "name", + ): "Command name to append to the script.", + ( + "addons/escoria-core/game/core-scripts/esc_animation_player.gd", + "play", + "name", + ): "Animation name to play.", + ( + "addons/escoria-core/game/core-scripts/esc_animation_player.gd", + "play_backwards", + "name", + ): "Animation name to play in reverse.", + ( + "addons/escoria-core/game/core-scripts/esc_animation_player.gd", + "has_animation", + "name", + ): "Animation name to test for availability.", + ( + "addons/escoria-core/game/core-scripts/esc_animation_player.gd", + "seek_end", + "name", + ): "Animation name to jump to the last frame of.", + ( + "addons/escoria-core/game/core-scripts/esc_animation_player.gd", + "get_length", + "name", + ): "Animation name whose duration should be returned.", + ( + "addons/escoria-core/game/scenes/transitions/esc_transition_player.gd", + "get_transition", + "name", + ): "Transition name whose material path should be resolved.", + ( + "addons/escoria-core/game/scenes/transitions/esc_transition_player.gd", + "has_transition", + "name", + ): "Transition name to check for availability.", + ( + "addons/escoria-core/plugin.gd", + "register_setting", + "name", + ): "Fully qualified Project Settings key to register.", +} + +PARAM_OVERRIDES_BY_COMMAND = { + ( + "addons/escoria-core/game/core-scripts/esc/commands/anim.gd", + "anim", + "name", + ): "Name of the animation to start on the object.", + ( + "addons/escoria-core/game/core-scripts/esc/commands/anim_block.gd", + "anim_block", + "name", + ): "Name of the animation to play before continuing.", + ( + "addons/escoria-core/game/core-scripts/esc/commands/dec_global.gd", + "dec_global", + "name", + ): "Name of the global variable to decrement.", + ( + "addons/escoria-core/game/core-scripts/esc/commands/inc_global.gd", + "inc_global", + "name", + ): "Name of the global variable to increment.", + ( + "addons/escoria-core/game/core-scripts/esc/commands/rand_global.gd", + "rand_global", + "name", + ): "Name of the global variable that will receive the random value.", + ( + "addons/escoria-core/game/core-scripts/esc/commands/set_global.gd", + "set_global", + "name", + ): "Name of the global variable to set.", +} + +PARAM_OVERRIDES_BY_SIGNAL = { + ( + "addons/escoria-core/game/core-scripts/esc_location.gd", + "editor_is_start_location_set", + "node_to_ignore", + ): "`ESCLocation` that should be ignored while validating start locations.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_globals_manager.gd", + "global_changed", + "global", + ): "Key of the global that changed.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_globals_manager.gd", + "global_changed", + "old_value", + ): "Value stored under the key before the change.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_globals_manager.gd", + "global_changed", + "new_value", + ): "Updated value stored for the key.", + ( + "addons/escoria-core/game/core-scripts/esc_animation_player.gd", + "animation_finished", + "name", + ): "Name of the animation that completed playback.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "event_started", + "event_name", + ): "Name of the event whose execution has started.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "background_event_started", + "channel_name", + ): "Name of the background channel where the event runs.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "background_event_started", + "event_name", + ): "Name of the event that began on the background channel.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "event_finished", + "return_code", + ): "Execution result returned by the event.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "event_finished", + "event_name", + ): "Name of the event that just finished.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "background_event_finished", + "return_code", + ): "Execution result returned by the background event.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "background_event_finished", + "event_name", + ): "Name of the background event that finished.", + ( + "addons/escoria-core/game/core-scripts/esc/esc_event_manager.gd", + "background_event_finished", + "channel_name", + ): "Background channel where the event finished.", + ( + "addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd", + "finished", + "event", + ): "`ESCStatement` representing the event whose execution completed.", + ( + "addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd", + "finished", + "statement", + ): "`ESCStatement` that was running when the signal fired.", + ( + "addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd", + "finished", + "return_code", + ): "Execution result code produced by the statement.", + ( + "addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd", + "interrupted", + "event", + ): "`ESCStatement` representing the event whose execution was interrupted.", + ( + "addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd", + "interrupted", + "statement", + ): "`ESCStatement` that was executing when the interruption occurred.", + ( + "addons/escoria-core/game/core-scripts/esc/types/esc_statement.gd", + "interrupted", + "return_code", + ): "Execution result code describing the interruption outcome.", + ( + "addons/escoria-core/tools/logging/esc_logger.gd", + "error_message_signal", + "message", + ): "Error or warning message emitted through the logger.", +} + + +def get_param_override( + context_type: str, + path: Path, + context_name: str, + param_name: str, + signature_text: Optional[str] = None, +) -> Optional[str]: + path_key = path.as_posix() + if context_type == "function": + if signature_text: + key = (path_key, signature_text, param_name) + if key in PARAM_OVERRIDES_BY_SIGNATURE: + return PARAM_OVERRIDES_BY_SIGNATURE[key] + key = (path_key, context_name, param_name) + if key in PARAM_OVERRIDES_BY_FUNCTION: + return PARAM_OVERRIDES_BY_FUNCTION[key] + elif context_type == "command": + key = (path_key, context_name, param_name) + if key in PARAM_OVERRIDES_BY_COMMAND: + return PARAM_OVERRIDES_BY_COMMAND[key] + elif context_type == "signal": + key = (path_key, context_name, param_name) + if key in PARAM_OVERRIDES_BY_SIGNAL: + return PARAM_OVERRIDES_BY_SIGNAL[key] + return None + + +def strip_doc_prefix(line: str, indent: str) -> str: + if not line.startswith(indent + "##"): + return "" + content = line[len(indent) + 2 :] + if content.startswith(" "): + content = content[1:] + return content.rstrip("\n") + + +def strip_trailing_br(text: str) -> str: + if not text: + return text + new_text = text + while new_text.endswith("[br]"): + new_text = new_text[: -4].rstrip() + return new_text.strip() + + +def parse_param_bullet(raw_line: str) -> Optional[Tuple[str, str]]: + stripped_original = raw_line.lstrip() + if stripped_original.startswith("##"): + content = stripped_original[2:] + else: + content = stripped_original + while content.startswith("#"): + content = content[1:] + content = content.lstrip() + if not content or not content.startswith(("-", "*")): + return None + line = strip_trailing_br(content) + if not line: + return None + line = line.lstrip("-* \t") + if not line: + return None + if ":" not in line: + return None + name_part, desc_part = line.split(":", 1) + name = name_part.strip().strip("*").strip("`") + desc = desc_part.strip() + if not name: + return None + return name, desc + + +def format_type_cell(type_name: Optional[str]) -> str: + type_str = (type_name or "").strip() + if not type_str: + type_str = "Variant" + # Remove surrounding backticks if present + if type_str.startswith("`") and type_str.endswith("`"): + type_str = type_str[1:-1] + parts = [part.strip() for part in type_str.split("|") if part.strip()] + if not parts: + parts = ["Variant"] + return " or ".join(f"`{part}`" for part in parts) + + +def is_table_structure_line(raw_line: str) -> bool: + text = strip_trailing_br(raw_line.strip()) + if not text.startswith("|"): + return False + inner = [part.strip() for part in text.strip().strip("|").split("|")] + if not inner: + return False + first = inner[0].lower() + return first in {"name", ":-----"} + + +def parse_table_row(raw_line: str) -> Optional[Dict[str, str]]: + text = strip_trailing_br(raw_line.strip()) + if not text.startswith("|"): + return None + inner = [part.strip() for part in text.strip().strip("|").split("|")] + if len(inner) != 4: + return None + if inner[0].lower() in {"name", ":-----"}: + return None + name_cell = inner[0].lstrip("\\").strip() + return { + "name": name_cell, + "type": inner[1].strip("`"), + "desc": inner[2], + "required": inner[3].lower(), + } + + +def extract_inline_return_type(text: str) -> Tuple[str, Optional[str]]: + stripped = text.strip() + match = re.search(r"\(`([^`]+)`\)\.?$", stripped) + if not match: + return text, None + type_name = match.group(1) + prefix = stripped[: match.start()].rstrip() + if prefix.endswith("."): + prefix = prefix[:-1].rstrip() + return prefix, type_name + + +def normalize_whitespace(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def parse_function_signature(lines: List[str]) -> Tuple[str, str, List[Dict], str]: + signature_text = " ".join(line.strip() for line in lines) + signature_text = normalize_whitespace(signature_text) + match = re.match(r"(?:static\s+)?func\s+([A-Za-z0-9_]+)\s*\((.*)\)\s*(?:->\s*([^:]+))?:", signature_text) + func_name = "" + params: List[Dict] = [] + return_type = "void" + if match: + func_name = match.group(1) + raw_params = match.group(2).strip() + return_type = match.group(3).strip() if match.group(3) else "void" + if raw_params: + params = parse_parameter_list(raw_params) + return func_name, signature_text, params, return_type + + +def parse_parameter_list(params_fragment: str) -> List[Dict]: + params: List[Dict] = [] + current = "" + depth = 0 + for ch in params_fragment: + if ch in "([{": + depth += 1 + current += ch + continue + if ch in ")]}": + depth = max(depth - 1, 0) + current += ch + continue + if ch == "," and depth == 0: + token = current.strip() + if token: + params.append(parse_parameter_token(token)) + current = "" + continue + current += ch + token = current.strip() + if token: + params.append(parse_parameter_token(token)) + return params + + +def parse_parameter_token(token: str) -> Dict: + required = True + name_part = token + if "=" in token: + name_part, _ = token.split("=", 1) + required = False + param_type = "Variant" + name = name_part.strip() + if ":" in name_part: + name_bits = name_part.split(":", 1) + name = name_bits[0].strip() + type_candidate = name_bits[1].strip() + if type_candidate: + param_type = type_candidate + if "=" in param_type: + param_type = param_type.split("=", 1)[0].strip() + if "=" in name: + name = name.split("=", 1)[0].strip() + required = False + name = name.lstrip("\\").strip() + if not name: + name = "param" + return {"name": name, "type": param_type, "required": required} + + +def sanitize_description(text: str) -> str: + text = strip_trailing_br(text) + lowered = text.strip().lower() + if lowered in {"none", "none."}: + return "" + return text + + +def clean_desc_text(text: str) -> str: + if not text: + return text + cleaned = re.sub(r"(?:\s*None\.)+$", "", text).strip() + return cleaned + + +def append_extra_text(current: str, extra: str) -> str: + extra = strip_trailing_br(extra.strip()) + extra = extra.lstrip("-* \t") + if not extra: + return current + if not current: + return extra + return f"{current} {extra}" + + +def merge_extra_rows(rows: List[Tuple[str, str, str, str]], extras: List[Tuple[str, str]]) -> List[Tuple[str, str, str, str]]: + if not extras: + return rows + merged = list(rows) + for name, desc in extras: + name = name.strip() + desc = desc.strip() + if not name and not desc: + continue + text = " ".join(filter(None, [name, desc])) + if merged: + last_name, last_type, last_desc, last_required = merged[-1] + merged[-1] = ( + last_name, + last_type, + append_extra_text(last_desc, text), + last_required, + ) + else: + merged.append((name or "Extra", format_type_cell("Variant"), desc or DEFAULT_PARAM_DESC, "yes")) + return merged + + +def reformat_function_docstring( + block_lines: List[str], + indent: str, + following_lines: List[str], + path: Path, +) -> Optional[List[str]]: + content_lines = [strip_doc_prefix(line, indent) for line in block_lines] + # Collect signature lines + signature_lines: List[str] = [] + for line in following_lines: + signature_lines.append(line) + if line.strip().endswith(":"): + break + func_name, signature_text, params_info, return_type = parse_function_signature(signature_lines) + + desc_lines: List[str] = [] + params: List[Dict[str, str]] = [] + param_buffer: Optional[Dict[str, str]] = None + return_lines: List[str] = [] + + for raw_line in content_lines: + stripped = raw_line.strip() + if not stripped: + continue + if stripped.startswith("@ESC"): + break + lowered = stripped.lower() + if lowered in {"[br]", "##"}: + continue + if lowered.startswith("#### parameters") or lowered.startswith("**parameters"): + param_buffer = None + continue + if lowered.startswith("parameters"): + param_buffer = None + continue + if lowered.startswith("#### returns") or lowered.startswith("**returns"): + param_buffer = None + continue + if lowered.startswith("*returns*"): + text = stripped[len("*Returns*") :].strip(" :-") + text = strip_trailing_br(text) + if text: + return_lines.append(text) + param_buffer = None + continue + if lowered.startswith("returns "): + text = stripped[len("returns ") :].strip() + text = strip_trailing_br(text) + if text: + return_lines.append(text) + param_buffer = None + continue + if is_table_structure_line(raw_line): + continue + table_entry = parse_table_row(raw_line) + if table_entry: + entry = {"name": table_entry["name"], "desc": table_entry["desc"]} + params.append(entry) + param_buffer = entry + continue + bullet = parse_param_bullet(raw_line) + if bullet: + name, desc = bullet + entry = {"name": name, "desc": desc} + params.append(entry) + param_buffer = entry + continue + if param_buffer: + param_buffer["desc"] = append_extra_text(param_buffer.get("desc", ""), stripped) + continue + if return_lines: + return_lines[-1] = append_extra_text(return_lines[-1], stripped) + continue + desc_lines.append(sanitize_description(raw_line)) + + desc_text = " ".join(filter(None, [strip_trailing_br(line) for line in desc_lines])).strip() + desc_text = clean_desc_text(desc_text) + if not desc_text and return_lines: + desc_text = return_lines[0] + if desc_text and desc_text[0].islower(): + desc_text = desc_text[0].upper() + desc_text[1:] + if not desc_text: + desc_text = "No description provided." + + # Prepare parameter rows following signature order + param_map = {entry["name"]: entry.get("desc", "") for entry in params} + rows: List[Tuple[str, str, str, str]] = [] + for param in params_info: + name = param["name"] + type_name = format_type_cell(param["type"]) + required = "yes" if param["required"] else "no" + desc = param_map.pop(name, "").strip() + desc_key = desc.strip() + if not desc_key or desc_key.startswith(DEFAULT_PARAM_DESC): + override = get_param_override("function", path, func_name, name, signature_text) + if override: + desc = override.strip() + desc_key = desc + if not desc_key: + desc = DEFAULT_PARAM_DESC + else: + desc = desc_key + rows.append((name, type_name, desc, required)) + rows = merge_extra_rows(rows, list(param_map.items())) + + raw_return_desc = " ".join(return_lines).strip() + cleaned_return_desc, inline_type = extract_inline_return_type(raw_return_desc) + return_desc = cleaned_return_desc.strip() + effective_declared_type = return_type + if effective_declared_type == "void" and inline_type: + test_desc = re.sub(r"[\.\s]+$", "", cleaned_return_desc.strip().lower()) + if test_desc not in {"", "returns nothing", "nothing"}: + effective_declared_type = inline_type + inferred_return_type = infer_return_type(effective_declared_type, return_desc or raw_return_desc) + if not return_desc: + if inferred_return_type == "void": + return_desc = "Returns nothing." + else: + return_desc = f"Returns a `{inferred_return_type}` value." + else: + lowered = return_desc.lower() + if lowered.startswith("returns "): + return_desc = return_desc[8:].strip() + elif lowered.startswith("return "): + return_desc = return_desc[7:].strip() + if return_desc and not return_desc.endswith("."): + return_desc += "." + return_desc = f"Returns {normalize_return_sentence(return_desc)}" + if inferred_return_type and inferred_return_type != "void": + return_desc = f"{return_desc} (`{inferred_return_type}`)" + + new_block: List[str] = [] + new_block.append(f"{indent}## {desc_text}[br]") + new_block.append(f"{indent}## [br]") + new_block.append(f"{indent}## #### Parameters[br]") + new_block.append(f"{indent}## [br]") + if rows: + new_block.append(f"{indent}## | Name | Type | Description | Required? |[br]") + new_block.append(f"{indent}## |:-----|:-----|:------------|:----------|[br]") + for name, type_name, description, required in rows: + new_block.append(f"{indent}## |{name}|{type_name}|{description}|{required}|[br]") + new_block.append(f"{indent}## [br]") + else: + new_block.append(f"{indent}## None.") + new_block.append(f"{indent}## [br]") + new_block.append(f"{indent}## #### Returns[br]") + new_block.append(f"{indent}## [br]") + new_block.append(f"{indent}## {return_desc}") + return new_block + + +def parse_command_signature(first_line: str) -> Tuple[str, List[Dict]]: + command_name = "" + fragment = "" + signature_match = re.match(r"`\s*([A-Za-z0-9_]+)\s*\((.*)\)`", first_line.strip()) + if signature_match: + command_name = signature_match.group(1) + fragment = signature_match.group(2).strip() + else: + match = re.search(r"`[^`]*\((.*)\)`", first_line) + if match: + fragment = match.group(1).strip() + if not fragment: + return command_name, [] + params: List[Dict] = [] + current = "" + optional_depth = 0 + for ch in fragment: + if ch == "[": + token = current.strip() + if token: + params.append(parse_command_token(token, optional_depth > 0)) + current = "" + optional_depth += 1 + continue + if ch == "]": + token = current.strip() + if token: + params.append(parse_command_token(token, optional_depth > 0)) + current = "" + optional_depth = max(optional_depth - 1, 0) + continue + if ch == ",": + token = current.strip() + if token: + params.append(parse_command_token(token, optional_depth > 0)) + current = "" + continue + current += ch + token = current.strip() + if token: + params.append(parse_command_token(token, optional_depth > 0)) + return command_name, params + + +def parse_command_token(token: str, is_optional: bool) -> Dict: + token = token.strip() + param_type = "Variant" + name = token + if ":" in token: + name_part, type_part = token.split(":", 1) + name = name_part.strip() + type_candidate = type_part.strip() + if type_candidate: + param_type = type_candidate + if "=" in param_type: + param_type = param_type.split("=", 1)[0].strip() + if "=" in name: + name = name.split("=", 1)[0].strip() + is_optional = True + return {"name": name, "type": param_type, "required": not is_optional} + + +def reformat_command_docstring( + block_lines: List[str], + indent: str, + path: Path, +) -> Optional[List[str]]: + content_lines = [strip_doc_prefix(line, indent) for line in block_lines] + if not content_lines: + return None + + signature_line = content_lines[0].strip() + command_name, params_info = parse_command_signature(signature_line) + + desc_lines: List[str] = [] + params: List[Dict[str, str]] = [] + param_buffer: Optional[Dict[str, str]] = None + extra_lines: List[str] = [] + in_param_section = False + + for raw_line in content_lines[1:]: + stripped = raw_line.strip() + lowered = stripped.lower() + if not stripped or lowered == "[br]": + continue + if stripped.startswith("@ESC"): + break + if lowered.startswith("#### parameters") or lowered.startswith("**parameters"): + in_param_section = True + param_buffer = None + continue + if lowered.startswith("parameters"): + in_param_section = True + param_buffer = None + continue + if in_param_section and is_table_structure_line(raw_line): + continue + if in_param_section: + table_entry = parse_table_row(raw_line) + if table_entry: + entry = {"name": table_entry["name"], "desc": table_entry["desc"]} + params.append(entry) + param_buffer = entry + continue + bullet = parse_param_bullet(raw_line) + if bullet and in_param_section: + name, desc = bullet + entry = {"name": name, "desc": desc} + params.append(entry) + param_buffer = entry + continue + if in_param_section and param_buffer: + param_buffer["desc"] = append_extra_text(param_buffer.get("desc", ""), stripped) + continue + if in_param_section: + extra_entry = strip_trailing_br(stripped) + if extra_entry and extra_entry.strip().lower() != "none.": + extra_lines.append(extra_entry) + continue + desc_lines.append(strip_trailing_br(stripped)) + + desc_text = " ".join(filter(None, desc_lines)).strip() + desc_text = clean_desc_text(desc_text) + param_map = {entry["name"]: entry.get("desc", "") for entry in params} + rows: List[Tuple[str, str, str, str]] = [] + for param in params_info: + name = param["name"] + type_name = format_type_cell(param["type"]) + required = "yes" if param["required"] else "no" + desc = param_map.pop(name, "").strip() + desc_key = desc.strip() + if not desc_key or desc_key.startswith(DEFAULT_PARAM_DESC): + override = get_param_override("command", path, command_name, name) + if override: + desc = override.strip() + desc_key = desc + if not desc_key: + desc = DEFAULT_PARAM_DESC + else: + desc = desc_key + rows.append((name, type_name, desc, required)) + rows = merge_extra_rows(rows, list(param_map.items())) + + new_block: List[str] = [] + new_block.append(f"{indent}## {signature_line}") + new_block.append(f"{indent}##") + if desc_text: + new_block.append(f"{indent}## {desc_text}[br]") + else: + new_block.append(f"{indent}## [br]") + new_block.append(f"{indent}## [br]") + new_block.append(f"{indent}## #### Parameters[br]") + new_block.append(f"{indent}## [br]") + if rows: + new_block.append(f"{indent}## | Name | Type | Description | Required? |[br]") + new_block.append(f"{indent}## |:-----|:-----|:------------|:----------|[br]") + for name, type_name, description, required in rows: + new_block.append(f"{indent}## |{name}|{type_name}|{description}|{required}|[br]") + new_block.append(f"{indent}## [br]") + for line in extra_lines: + if line: + new_block.append(f"{indent}## {line}[br]") + else: + new_block.append(f"{indent}## None.") + new_block.append(f"{indent}## [br]") + for line in extra_lines: + if line: + new_block.append(f"{indent}## {line}[br]") + return new_block + + +def normalize_return_sentence(text: str) -> str: + text = text.strip() + if not text: + return text + first_word = text.split(" ", 1)[0].lower() + if first_word in {"the", "a", "an"} and len(text) > 1: + text = text[0].lower() + text[1:] + return text + + +def infer_return_type(declared_type: str, return_desc: str) -> str: + declared_type = (declared_type or "").strip() + if declared_type and declared_type != "void": + return declared_type + if not return_desc: + return declared_type or "void" + lowered = return_desc.lower() + if "nothing" in lowered or "no value" in lowered: + return "void" + if "true" in lowered or "false" in lowered: + return "bool" + if "array" in lowered: + return "Array" + if "dictionary" in lowered: + return "Dictionary" + if "string" in lowered: + return "String" + if "float" in lowered: + return "float" + if "int" in lowered: + return "int" + if "vector2" in lowered: + return "Vector2" + if "vector3" in lowered: + return "Vector3" + return "Variant" + + +def parse_signal_signature(signal_line: str) -> Tuple[str, List[str]]: + match = re.match(r"\s*signal\s+([A-Za-z0-9_]+)\s*(?:\((.*)\))?", signal_line) + if not match: + return "", [] + signal_name = match.group(1) + params_fragment = (match.group(2) or "").strip() + if not params_fragment: + return signal_name, [] + params = [] + current = "" + depth = 0 + for ch in params_fragment: + if ch == "," and depth == 0: + token = current.strip() + if token: + params.append(token) + current = "" + continue + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth = max(depth - 1, 0) + current += ch + token = current.strip() + if token: + params.append(token) + names = [] + for token in params: + if ":" in token: + token = token.split(":", 1)[0].strip() + if "=" in token: + token = token.split("=", 1)[0].strip() + names.append(token) + return signal_name, names + + +def reformat_signal_docstring( + block_lines: List[str], + indent: str, + signal_line: str, + path: Path, +) -> Optional[List[str]]: + content_lines = [strip_doc_prefix(line, indent) for line in block_lines] + if not content_lines: + return None + + desc_lines: List[str] = [] + params: List[Dict[str, str]] = [] + param_buffer: Optional[Dict[str, str]] = None + in_param_section = False + + for raw_line in content_lines: + stripped = raw_line.strip() + lowered = stripped.lower() + if not stripped or lowered == "[br]": + continue + if lowered.startswith("#### parameters") or lowered.startswith("**parameters"): + in_param_section = True + param_buffer = None + continue + if lowered.startswith("parameters"): + in_param_section = True + param_buffer = None + continue + if lowered.startswith("#### returns") or lowered.startswith("**returns"): + in_param_section = False + continue + if in_param_section and is_table_structure_line(raw_line): + continue + if in_param_section: + table_entry = parse_table_row(raw_line) + if table_entry: + entry = {"name": table_entry["name"], "desc": table_entry["desc"]} + params.append(entry) + param_buffer = entry + continue + bullet = parse_param_bullet(raw_line) + if bullet and in_param_section: + name, desc = bullet + entry = {"name": name, "desc": desc} + params.append(entry) + param_buffer = entry + continue + if in_param_section and param_buffer: + param_buffer["desc"] = append_extra_text(param_buffer.get("desc", ""), stripped) + continue + if in_param_section: + continue + desc_lines.append(strip_trailing_br(stripped)) + + desc_text = " ".join(filter(None, desc_lines)).strip() + desc_text = clean_desc_text(desc_text) + if not desc_text: + desc_text = "No description provided." + + signal_name, signature_params = parse_signal_signature(signal_line) + param_map = {entry["name"]: entry.get("desc", "") for entry in params} + rows: List[Tuple[str, str, str, str]] = [] + for param_name in signature_params: + desc = param_map.pop(param_name, "").strip() + desc_key = desc.strip() + if not desc_key or desc_key.startswith(DEFAULT_PARAM_DESC): + override = get_param_override("signal", path, signal_name, param_name) + if override: + desc = override.strip() + desc_key = desc + if not desc_key: + desc = DEFAULT_PARAM_DESC + else: + desc = desc_key + rows.append((param_name, format_type_cell(None), desc, "yes")) + for extra_name, desc in param_map.items(): + name = extra_name.strip() + if not name: + continue + cleaned_desc = desc.strip() + if not cleaned_desc or cleaned_desc.startswith(DEFAULT_PARAM_DESC): + override = get_param_override("signal", path, signal_name, name) + if override: + cleaned_desc = override + if not cleaned_desc: + cleaned_desc = DEFAULT_PARAM_DESC + rows.append((name, format_type_cell(None), cleaned_desc, "yes")) + + new_block: List[str] = [] + new_block.append(f"{indent}## {desc_text}[br]") + new_block.append(f"{indent}## [br]") + new_block.append(f"{indent}## #### Parameters[br]") + new_block.append(f"{indent}## [br]") + if rows: + new_block.append(f"{indent}## | Name | Type | Description | Required? |[br]") + new_block.append(f"{indent}## |:-----|:-----|:------------|:----------|[br]") + for name, type_name, description, required in rows: + new_block.append(f"{indent}## |{name}|{type_name}|{description}|{required}|[br]") + new_block.append(f"{indent}## [br]") + else: + new_block.append(f"{indent}## None.") + new_block.append(f"{indent}## [br]") + return new_block + + +def process_file(path: Path) -> Tuple[str, bool]: + text = path.read_text() + lines = text.splitlines() + i = 0 + changed = False + while i < len(lines): + line = lines[i] + stripped = line.lstrip() + if not stripped.startswith("##") or stripped.startswith("## @"): + i += 1 + continue + indent_match = re.match(r"^(\s*)##", line) + if not indent_match: + i += 1 + continue + indent = indent_match.group(1) + block_start = i + block_end = i + while block_end < len(lines): + stripped_block = lines[block_end].lstrip() + if not stripped_block.startswith("##") or stripped_block.startswith("## @"): + break + block_end += 1 + block_lines = lines[block_start:block_end] + + # Determine context + k = block_end + signature_lines: List[str] = [] + while k < len(lines): + candidate = lines[k].strip() + if candidate == "": + k += 1 + continue + if candidate.startswith("## @"): + k += 1 + continue + if candidate.startswith("##"): + break + break + if k >= len(lines): + i = block_end + continue + next_line = lines[k] + + if re.match(r"\s*(?:static\s+)?func\b", next_line): + signature_lines = [] + sig_index = k + while sig_index < len(lines): + signature_lines.append(lines[sig_index].strip()) + if lines[sig_index].strip().endswith(":"): + break + sig_index += 1 + new_block = reformat_function_docstring(block_lines, indent, signature_lines, path) + if new_block: + lines[block_start:block_end] = new_block + changed = True + block_end = block_start + len(new_block) + i = block_end + continue + elif "extends ESCBaseCommand" in next_line: + new_block = reformat_command_docstring(block_lines, indent, path) + if new_block: + lines[block_start:block_end] = new_block + changed = True + block_end = block_start + len(new_block) + i = block_end + continue + elif next_line.strip().startswith("signal "): + new_block = reformat_signal_docstring(block_lines, indent, next_line.strip(), path) + if new_block: + lines[block_start:block_end] = new_block + changed = True + block_end = block_start + len(new_block) + i = block_end + continue + i = block_end + new_text = "\n".join(lines) + if text.endswith("\n"): + new_text += "\n" + return new_text, changed + + +def main() -> None: + total_changed = 0 + for path in ROOT.rglob("*.gd"): + new_text, changed = process_file(path) + if changed: + path.write_text(new_text) + total_changed += 1 + + +if __name__ == "__main__": + main() |
