summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
Diffstat (limited to 'tools')
-rw-r--r--tools/__pycache__/reformat_docstrings.cpython-39.pycbin0 -> 23998 bytes
-rw-r--r--tools/docstring_formatter.py908
-rw-r--r--tools/reformat_docstrings.py1132
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
new file mode 100644
index 0000000..00b8880
--- /dev/null
+++ b/tools/__pycache__/reformat_docstrings.cpython-39.pyc
Binary files differ
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()