gdscript_analyzer
¶
Full name: tenets.core.analysis.implementations.gdscript_analyzer
gdscript_analyzer¶
GDScript code analyzer for Godot game development.
This module provides comprehensive analysis for GDScript source files, including support for Godot-specific features like signals, exports, node references, and engine lifecycle methods.
Classes¶
GDScriptAnalyzer¶
Bases: LanguageAnalyzer
GDScript code analyzer for Godot development.
Provides comprehensive analysis for GDScript files including: - Preload and load statements - Class inheritance (extends) - Signal declarations and connections - Export variable declarations - Onready variables and node references - Godot lifecycle methods (_ready, _process, etc.) - Tool scripts and custom resources - Typed GDScript (static typing) - Inner classes - Setget properties - Remote and master/puppet keywords (networking)
Supports Godot 3.x and 4.x GDScript syntax.
Initialize the GDScript analyzer with logger.
Source code in tenets/core/analysis/implementations/gdscript_analyzer.py
Functions¶
extract_imports¶
Extract preload, load, and class references from GDScript code.
Handles: - preload statements: preload("res://path/to/script.gd") - load statements: load("res://path/to/resource.tres") - const preloads: const MyClass = preload("res://MyClass.gd") - class_name declarations (Godot 3.1+) - Tool script declarations
PARAMETER | DESCRIPTION |
---|---|
content | GDScript source code TYPE: |
file_path | Path to the file being analyzed TYPE: |
RETURNS | DESCRIPTION |
---|---|
List[ImportInfo] | List of ImportInfo objects with import details |
Source code in tenets/core/analysis/implementations/gdscript_analyzer.py
def extract_imports(self, content: str, file_path: Path) -> List[ImportInfo]:
"""Extract preload, load, and class references from GDScript code.
Handles:
- preload statements: preload("res://path/to/script.gd")
- load statements: load("res://path/to/resource.tres")
- const preloads: const MyClass = preload("res://MyClass.gd")
- class_name declarations (Godot 3.1+)
- Tool script declarations
Args:
content: GDScript source code
file_path: Path to the file being analyzed
Returns:
List of ImportInfo objects with import details
"""
imports = []
lines = content.split("\n")
for i, line in enumerate(lines, 1):
# Skip comments
if line.strip().startswith("#"):
continue
# Preload statements
preload_pattern = r'(?:const\s+)?(\w+)?\s*=?\s*preload\s*\(\s*["\']([^"\']+)["\']\s*\)'
# Use finditer to support multiple preloads on a single line and avoid overlapping matches
for match in re.finditer(preload_pattern, line):
const_name = match.group(1)
resource_path = match.group(2)
imports.append(
ImportInfo(
module=resource_path,
alias=const_name,
line=i,
type="preload",
is_relative=resource_path.startswith("res://")
or resource_path.startswith("user://"),
is_resource=True,
resource_type=self._detect_resource_type(resource_path),
)
)
# Load statements (ensure we don't match the 'load' in 'preload')
load_pattern = r"(?<!\w)load\s*\("
for match in re.finditer(load_pattern, line):
# Extract the actual path argument following this 'load('
path_match = re.search(r'\(\s*["\']([^"\']+)["\']\s*\)', line[match.start() :])
if not path_match:
continue
resource_path = path_match.group(1)
imports.append(
ImportInfo(
module=resource_path,
line=i,
type="load",
is_relative=resource_path.startswith("res://")
or resource_path.startswith("user://"),
is_runtime_load=True,
resource_type=self._detect_resource_type(resource_path),
)
)
# Class inheritance (extends)
extends_pattern = r'^\s*extends\s+["\']?([^"\'\s]+)["\']?'
match = re.match(extends_pattern, line)
if match:
parent_class = match.group(1)
# Check if it's a path or class name
is_path = "/" in parent_class or parent_class.endswith(".gd")
imports.append(
ImportInfo(
module=parent_class,
line=i,
type="extends",
is_relative=is_path,
is_inheritance=True,
parent_type="script" if is_path else "class",
)
)
# Class_name declarations (for autoload/global classes)
class_name_pattern = r'^\s*class_name\s+(\w+)(?:\s*,\s*["\']([^"\']+)["\'])?'
match = re.match(class_name_pattern, line)
if match:
class_name = match.group(1)
icon_path = match.group(2)
if icon_path:
imports.append(
ImportInfo(
module=icon_path,
line=i,
type="icon",
is_relative=True,
is_resource=True,
associated_class=class_name,
)
)
# Check for tool script declaration
if re.search(r"^\s*tool\s*$", content, re.MULTILINE):
imports.append(
ImportInfo(
module="@tool",
line=1,
type="tool_mode",
is_relative=False,
is_editor_script=True,
)
)
# Check for @tool annotation (Godot 4.x)
if re.search(r"^\s*@tool\s*$", content, re.MULTILINE):
imports.append(
ImportInfo(
module="@tool",
line=1,
type="annotation",
is_relative=False,
is_editor_script=True,
)
)
return imports
extract_exports¶
Extract exported symbols from GDScript code.
In GDScript, exports include: - class_name declarations (global classes) - export variables - signals - Public functions (by convention, non-underscore prefixed)
PARAMETER | DESCRIPTION |
---|---|
content | GDScript source code TYPE: |
file_path | Path to the file being analyzed TYPE: |
RETURNS | DESCRIPTION |
---|---|
List[Dict[str, Any]] | List of exported symbols |
Source code in tenets/core/analysis/implementations/gdscript_analyzer.py
def extract_exports(self, content: str, file_path: Path) -> List[Dict[str, Any]]:
"""Extract exported symbols from GDScript code.
In GDScript, exports include:
- class_name declarations (global classes)
- export variables
- signals
- Public functions (by convention, non-underscore prefixed)
Args:
content: GDScript source code
file_path: Path to the file being analyzed
Returns:
List of exported symbols
"""
exports = []
# Extract class_name (makes class globally accessible)
class_name_pattern = r'^\s*class_name\s+(\w+)(?:\s*,\s*["\']([^"\']+)["\'])?'
match = re.search(class_name_pattern, content, re.MULTILINE)
if match:
exports.append(
{
"name": match.group(1),
"type": "global_class",
"line": content[: match.start()].count("\n") + 1,
"icon": match.group(2),
"is_autoload_candidate": True,
}
)
# Extract exported variables (Godot 3.x syntax)
export_var_pattern = r"^\s*export(?:\s*\(([^)]*)\))?\s+(?:var\s+)?(\w+)"
for match in re.finditer(export_var_pattern, content, re.MULTILINE):
export_type = match.group(1)
var_name = match.group(2)
exports.append(
{
"name": var_name,
"type": "export_var",
"line": content[: match.start()].count("\n") + 1,
"export_type": export_type,
"inspector_visible": True,
}
)
# Extract exported variables (Godot 4.x syntax with @export)
# Allow optional annotation arguments e.g., @export_range(0,1)
export_annotation_pattern = r"^\s*@export(?:_([a-z_]+))?(?:\([^)]*\))?\s+(?:var\s+)?(\w+)"
for match in re.finditer(export_annotation_pattern, content, re.MULTILINE):
export_modifier = match.group(1)
var_name = match.group(2)
exports.append(
{
"name": var_name,
"type": "export_var",
"line": content[: match.start()].count("\n") + 1,
"export_modifier": export_modifier,
"inspector_visible": True,
"godot_version": 4,
}
)
# Extract signals
signal_pattern = r"^\s*signal\s+(\w+)\s*(?:\(([^)]*)\))?"
for match in re.finditer(signal_pattern, content, re.MULTILINE):
signal_name = match.group(1)
parameters = match.group(2)
exports.append(
{
"name": signal_name,
"type": "signal",
"line": content[: match.start()].count("\n") + 1,
"parameters": self._parse_signal_parameters(parameters),
"is_event": True,
}
)
# Extract public functions (non-underscore prefixed)
func_pattern = r"^\s*(?:static\s+)?func\s+([a-zA-Z]\w*)\s*\("
for match in re.finditer(func_pattern, content, re.MULTILINE):
func_name = match.group(1)
exports.append(
{
"name": func_name,
"type": "function",
"line": content[: match.start()].count("\n") + 1,
"is_public": True,
"is_static": "static" in match.group(0),
}
)
# Extract enums
enum_pattern = r"^\s*enum\s+(\w+)\s*\{"
for match in re.finditer(enum_pattern, content, re.MULTILINE):
exports.append(
{
"name": match.group(1),
"type": "enum",
"line": content[: match.start()].count("\n") + 1,
}
)
# Extract constants (often used as exports in GDScript)
const_pattern = r"^\s*const\s+([A-Z][A-Z0-9_]*)\s*="
for match in re.finditer(const_pattern, content, re.MULTILINE):
exports.append(
{
"name": match.group(1),
"type": "constant",
"line": content[: match.start()].count("\n") + 1,
"is_public": True,
}
)
return exports
extract_structure¶
Extract code structure from GDScript file.
Extracts: - Class inheritance and structure - Inner classes - Functions with type hints - Godot lifecycle methods - Signals and their connections - Export variables - Onready variables - Node references - Setget properties - Enums and constants
PARAMETER | DESCRIPTION |
---|---|
content | GDScript source code TYPE: |
file_path | Path to the file being analyzed TYPE: |
RETURNS | DESCRIPTION |
---|---|
CodeStructure | CodeStructure object with extracted elements |
Source code in tenets/core/analysis/implementations/gdscript_analyzer.py
def extract_structure(self, content: str, file_path: Path) -> CodeStructure:
"""Extract code structure from GDScript file.
Extracts:
- Class inheritance and structure
- Inner classes
- Functions with type hints
- Godot lifecycle methods
- Signals and their connections
- Export variables
- Onready variables
- Node references
- Setget properties
- Enums and constants
Args:
content: GDScript source code
file_path: Path to the file being analyzed
Returns:
CodeStructure object with extracted elements
"""
structure = CodeStructure()
# Detect if it's a tool script
structure.is_tool_script = bool(re.search(r"^\s*(?:@)?tool\s*$", content, re.MULTILINE))
# Extract class name
class_name_match = re.search(r"^\s*class_name\s+(\w+)", content, re.MULTILINE)
if class_name_match:
structure.class_name = class_name_match.group(1)
# Extract parent class
extends_match = re.search(r'^\s*extends\s+["\']?([^"\'\s]+)["\']?', content, re.MULTILINE)
if extends_match:
structure.parent_class = extends_match.group(1)
# Detect Godot version (4.x uses @annotations)
structure.godot_version = (
4 if re.search(r"^\s*@(export|onready|tool)", content, re.MULTILINE) else 3
)
# Extract main class info
main_class = ClassInfo(
name=getattr(structure, "class_name", None) or file_path.stem,
line=1,
bases=(
[getattr(structure, "parent_class", None)]
if getattr(structure, "parent_class", None)
else []
),
)
# Extract functions
func_pattern = r"^\s*(?:static\s+)?func\s+(\w+)\s*\(([^)]*)\)(?:\s*->\s*([^:]+))?:"
for match in re.finditer(func_pattern, content, re.MULTILINE):
func_name = match.group(1)
params = match.group(2)
return_type = match.group(3)
is_private = func_name.startswith("_")
is_lifecycle = self._is_lifecycle_method(func_name)
is_virtual = func_name.startswith("_") and not func_name.startswith("__")
func_info = FunctionInfo(
name=func_name,
line=content[: match.start()].count("\n") + 1,
parameters=self._parse_function_parameters(params),
return_type=return_type.strip() if return_type else None,
is_private=is_private,
is_lifecycle=is_lifecycle,
is_virtual=is_virtual,
is_static="static" in content[match.start() - 20 : match.start()],
)
structure.functions.append(func_info)
main_class.methods.append(
{
"name": func_name,
"visibility": "private" if is_private else "public",
"is_lifecycle": is_lifecycle,
}
)
# Extract inner classes
inner_class_pattern = r"^\s*class\s+(\w+)(?:\s+extends\s+([^:]+))?:"
for match in re.finditer(inner_class_pattern, content, re.MULTILINE):
inner_class = ClassInfo(
name=match.group(1),
line=content[: match.start()].count("\n") + 1,
bases=[match.group(2).strip()] if match.group(2) else [],
is_inner=True,
)
structure.classes.append(inner_class)
# Add main class
structure.classes.insert(0, main_class)
# Extract signals
signal_pattern = r"^\s*signal\s+(\w+)\s*(?:\(([^)]*)\))?"
for match in re.finditer(signal_pattern, content, re.MULTILINE):
structure.signals.append(
{
"name": match.group(1),
"line": content[: match.start()].count("\n") + 1,
"parameters": self._parse_signal_parameters(match.group(2)),
}
)
# Extract export variables
# Godot 3.x
export_pattern = r"^\s*export(?:\s*\(([^)]*)\))?\s+(?:var\s+)?(\w+)(?:\s*:\s*([^=\n]+))?(?:\s*=\s*([^\n]+))?"
for match in re.finditer(export_pattern, content, re.MULTILINE):
structure.export_vars.append(
{
"name": match.group(2),
"export_hint": match.group(1),
"type": match.group(3).strip() if match.group(3) else None,
"default": match.group(4).strip() if match.group(4) else None,
"line": content[: match.start()].count("\n") + 1,
}
)
# Godot 4.x
export_4_pattern = r"^\s*@export(?:_([a-z_]+))?(?:\([^)]*\))?\s+(?:var\s+)?(\w+)(?:\s*:\s*([^=\n]+))?(?:\s*=\s*([^\n]+))?"
for match in re.finditer(export_4_pattern, content, re.MULTILINE):
structure.export_vars.append(
{
"name": match.group(2),
"export_modifier": match.group(1),
"type": match.group(3).strip() if match.group(3) else None,
"default": match.group(4).strip() if match.group(4) else None,
"line": content[: match.start()].count("\n") + 1,
"godot_4": True,
}
)
# Extract onready variables
# Godot 3.x
onready_pattern = r"^\s*onready\s+var\s+(\w+)(?:\s*:\s*([^=\n]+))?\s*=\s*([^\n]+)"
for match in re.finditer(onready_pattern, content, re.MULTILINE):
var_name = match.group(1)
var_type = match.group(2)
initialization = match.group(3)
# Check if it's a node reference
is_node_ref = bool(re.search(r"(?:\$|get_node)", initialization))
node_path = self._extract_node_path(initialization)
structure.onready_vars.append(
{
"name": var_name,
"type": var_type.strip() if var_type else None,
"initialization": initialization.strip(),
"is_node_ref": is_node_ref,
"node_path": node_path,
"line": content[: match.start()].count("\n") + 1,
}
)
# Godot 4.x
onready_4_pattern = r"^\s*@onready\s+var\s+(\w+)(?:\s*:\s*([^=\n]+))?\s*=\s*([^\n]+)"
for match in re.finditer(onready_4_pattern, content, re.MULTILINE):
var_name = match.group(1)
var_type = match.group(2)
initialization = match.group(3)
is_node_ref = bool(re.search(r"(?:\$|get_node)", initialization))
node_path = self._extract_node_path(initialization)
structure.onready_vars.append(
{
"name": var_name,
"type": var_type.strip() if var_type else None,
"initialization": initialization.strip(),
"is_node_ref": is_node_ref,
"node_path": node_path,
"line": content[: match.start()].count("\n") + 1,
"godot_4": True,
}
)
# Extract regular variables
var_pattern = r"^\s*var\s+(\w+)(?:\s*:\s*([^=\n]+))?(?:\s*=\s*([^\n]+))?"
for match in re.finditer(var_pattern, content, re.MULTILINE):
# Skip if it's an export or onready var
line_start = content[: match.start()].rfind("\n") + 1
line_content = content[line_start : match.end()]
if "export" in line_content or "onready" in line_content or "@" in line_content:
continue
structure.variables.append(
{
"name": match.group(1),
"type": match.group(2).strip() if match.group(2) else None,
"initial_value": match.group(3).strip() if match.group(3) else None,
"line": content[: match.start()].count("\n") + 1,
}
)
# Extract constants
const_pattern = r"^\s*const\s+(\w+)(?:\s*:\s*([^=\n]+))?\s*=\s*([^\n]+)"
for match in re.finditer(const_pattern, content, re.MULTILINE):
structure.constants.append(
{
"name": match.group(1),
"type": match.group(2).strip() if match.group(2) else None,
"value": match.group(3).strip(),
"line": content[: match.start()].count("\n") + 1,
}
)
# Extract enums
enum_pattern = r"^\s*enum\s+(\w+)\s*\{([^}]+)\}"
for match in re.finditer(enum_pattern, content, re.MULTILINE):
enum_name = match.group(1)
enum_body = match.group(2)
values = self._parse_enum_values(enum_body)
structure.enums.append(
{
"name": enum_name,
"values": values,
"line": content[: match.start()].count("\n") + 1,
}
)
# Extract setget properties
# Support optional setter/getter and missing entries: e.g., setget set_mana or setget , get_level
setget_pattern = r"^\s*var\s+(\w+)(?:[^=\n]*=\s*[^\n]+)?\s+setget\s*(?:([A-Za-z_]\w*)\s*)?(?:,\s*([A-Za-z_]\w*)\s*)?"
for match in re.finditer(setget_pattern, content, re.MULTILINE):
structure.setget_properties.append(
{
"name": match.group(1),
"setter": match.group(2) if match.group(2) else None,
"getter": match.group(3) if match.group(3) else None,
"line": content[: match.start()].count("\n") + 1,
}
)
# Count node references
structure.node_references = len(re.findall(r'\$["\']?[^"\'\s]+["\']?', content))
structure.get_node_calls = len(re.findall(r"get_node\s*\(", content))
# Count signal connections (method form and free function form)
structure.connect_calls = len(re.findall(r"\.connect\s*\(|(?<!\.)\bconnect\s*\(", content))
structure.emit_signal_calls = len(re.findall(r"emit_signal\s*\(", content))
# Detect if it's a custom resource
structure.is_custom_resource = bool(
structure.parent_class and "Resource" in structure.parent_class
)
# Detect if it's an editor plugin
structure.is_editor_plugin = bool(
structure.parent_class and "EditorPlugin" in structure.parent_class
)
return structure
calculate_complexity¶
Calculate complexity metrics for GDScript code.
Calculates: - Cyclomatic complexity - Cognitive complexity - Godot-specific complexity (signals, exports, node references) - Nesting depth - Function count and complexity distribution
PARAMETER | DESCRIPTION |
---|---|
content | GDScript source code TYPE: |
file_path | Path to the file being analyzed TYPE: |
RETURNS | DESCRIPTION |
---|---|
ComplexityMetrics | ComplexityMetrics object with calculated metrics |
Source code in tenets/core/analysis/implementations/gdscript_analyzer.py
def calculate_complexity(self, content: str, file_path: Path) -> ComplexityMetrics:
"""Calculate complexity metrics for GDScript code.
Calculates:
- Cyclomatic complexity
- Cognitive complexity
- Godot-specific complexity (signals, exports, node references)
- Nesting depth
- Function count and complexity distribution
Args:
content: GDScript source code
file_path: Path to the file being analyzed
Returns:
ComplexityMetrics object with calculated metrics
"""
metrics = ComplexityMetrics()
# Calculate cyclomatic complexity
complexity = 1
decision_keywords = [
r"\bif\b",
r"\belif\b",
r"\belse\b",
r"\bfor\b",
r"\bwhile\b",
r"\bmatch\b",
r"\bwhen\b",
r"\band\b",
r"\bor\b",
]
for keyword in decision_keywords:
complexity += len(re.findall(keyword, content))
# Each match-case branch contributes to complexity; count simple case labels (numbers, strings, or underscore)
case_label_pattern = r"^\s*(?:_|-?\d+|\"[^\"\n]+\"|\'[^\'\n]+\')\s*:"
complexity += len(re.findall(case_label_pattern, content, re.MULTILINE))
# Inline lambda expressions (func(...) :) add decision/branching potential
lambda_inline_pattern = (
r"func\s*\(" # named functions are 'func name(', lambdas are 'func(' directly
)
complexity += len(re.findall(lambda_inline_pattern, content))
metrics.cyclomatic = complexity
# Calculate cognitive complexity
cognitive = 0
nesting_level = 0
max_nesting = 0
lines = content.split("\n")
for line in lines:
# Skip comments
if line.strip().startswith("#"):
continue
# Track nesting by indentation (GDScript uses indentation)
if line.strip():
indent = len(line) - len(line.lstrip())
# Assuming tab or 4 spaces as one level
if "\t" in line[:indent]:
current_level = line[:indent].count("\t")
else:
current_level = indent // 4
max_nesting = max(max_nesting, current_level)
# Control structures with nesting penalty
control_patterns = [
(r"\bif\b", 1),
(r"\belif\b", 1),
(r"\belse\b", 0),
(r"\bfor\b", 1),
(r"\bwhile\b", 1),
(r"\bmatch\b", 1),
]
for pattern, weight in control_patterns:
if re.search(pattern, line):
cognitive += weight * (1 + max(0, current_level))
metrics.cognitive = cognitive
metrics.max_depth = max_nesting
# Count code elements
metrics.line_count = len(lines)
metrics.code_lines = len([l for l in lines if l.strip() and not l.strip().startswith("#")])
metrics.comment_lines = len([l for l in lines if l.strip().startswith("#")])
metrics.comment_ratio = (
metrics.comment_lines / metrics.line_count if metrics.line_count > 0 else 0
)
# Count functions
metrics.function_count = len(re.findall(r"\bfunc\s+\w+", content))
# Count classes
metrics.class_count = len(re.findall(r"\bclass\s+\w+", content))
metrics.class_count += 1 if re.search(r"^\s*extends\s+", content, re.MULTILINE) else 0
# Godot-specific metrics
metrics.signal_count = len(re.findall(r"\bsignal\s+\w+", content))
metrics.export_count = len(re.findall(r"(?:@)?export(?:_\w+)?(?:\([^)]*\))?\s+", content))
metrics.onready_count = len(re.findall(r"(?:@)?onready\s+var", content))
# Node reference metrics
metrics.node_ref_count = len(re.findall(r'\$["\']?[^"\'\s]+["\']?', content))
metrics.get_node_count = len(re.findall(r"get_node\s*\(", content))
# Signal connection metrics
metrics.connect_count = len(re.findall(r"\.connect\s*\(|(?<!\.)\bconnect\s*\(", content))
metrics.emit_count = len(re.findall(r"emit_signal\s*\(", content))
# Lifecycle method count
lifecycle_methods = [
"_ready",
"_enter_tree",
"_exit_tree",
"_process",
"_physics_process",
"_input",
"_unhandled_input",
"_draw",
"_gui_input",
"_notification",
]
metrics.lifecycle_count = sum(
1 for method in lifecycle_methods if re.search(rf"\bfunc\s+{method}\s*\(", content)
)
# RPC/Networking metrics
metrics.rpc_count = len(
re.findall(r"@rpc|rpc\(|rpc_unreliable\(|remotesync\s+func", content)
)
# Type hints metrics
metrics.typed_vars = len(re.findall(r"(?:var|const)\s+\w+\s*:\s*\w+", content))
metrics.typed_funcs = len(re.findall(r"func\s+\w+\s*\([^)]*:\s*\w+[^)]*\)", content))
metrics.return_types = len(re.findall(r"\)\s*->\s*\w+\s*:", content))
# Calculate Godot-specific complexity score
godot_complexity = (
metrics.signal_count * 2
+ metrics.export_count
+ metrics.onready_count
+ metrics.node_ref_count * 0.5
+ metrics.connect_count * 2
+ metrics.emit_count
)
# Calculate maintainability index
import math
if metrics.code_lines > 0:
# Adjusted for GDScript
godot_factor = 1 - (godot_complexity * 0.001)
type_factor = 1 + (metrics.typed_vars + metrics.typed_funcs) * 0.001
mi = (
171
- 5.2 * math.log(max(1, complexity))
- 0.23 * complexity
- 16.2 * math.log(metrics.code_lines)
+ 10 * godot_factor
+ 5 * type_factor
)
metrics.maintainability_index = max(0, min(100, mi))
return metrics