diff options
| author | Radio <radiohotline@disroot.org> | 2026-06-04 15:15:11 +0300 |
|---|---|---|
| committer | Radio <radiohotline@disroot.org> | 2026-06-04 15:15:11 +0300 |
| commit | 9d74b49ab62908d4acf53dde444413886b8b28e5 (patch) | |
| tree | 0cb785267ea7e239b19d88684c0d64b9bd5d52f0 /tools/docstring_formatter.py | |
| parent | 2c85d452ad02a5f89cd43bad7f9de31d0e4fa0c1 (diff) | |
escoria setup and old branch archives
Diffstat (limited to 'tools/docstring_formatter.py')
| -rw-r--r-- | tools/docstring_formatter.py | 908 |
1 files changed, 908 insertions, 0 deletions
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() |
