1081 lines
37 KiB
GDScript
1081 lines
37 KiB
GDScript
## This plug-in adds support for automatically formatting GDScript files on save
|
|
## and via a command in the Godot Editor, using the GDQuest GDScript Formatter.
|
|
##
|
|
## It also provides an option to install or update the formatter binary from the GitHub releases.
|
|
##
|
|
## See our website for more information on the formatter and how to use it:
|
|
## https://www.gdquest.com/library/gdscript_formatter/
|
|
@tool
|
|
extends EditorPlugin
|
|
|
|
const FormatterInstaller = preload("install_and_update.gd")
|
|
const FormatterMenu = preload("menu.gd")
|
|
|
|
const EDITOR_SETTINGS_CATEGORY = "gdquest_gdscript_formatter/"
|
|
const SETTING_FORMAT_ON_SAVE = "format_on_save"
|
|
const SETTING_SHORTCUT = "shortcut"
|
|
const SETTING_USE_SPACES = "use_spaces"
|
|
const SETTING_INDENT_SIZE = "indent_size"
|
|
const SETTING_FORMAT_MODE = "format_mode"
|
|
const SETTING_REORDER_CODE = "reorder_code"
|
|
const SETTING_SAFE_MODE = "safe_mode"
|
|
const SETTING_FORMATTER_PATH = "formatter_path"
|
|
const SETTING_LINT_ON_SAVE = "lint_on_save"
|
|
const SETTING_LINT_LINE_LENGTH = "lint_line_length"
|
|
const SETTING_LINT_IGNORED_RULES = "lint_ignored_rules"
|
|
# Directories to ignore when Format on Save is enabled
|
|
const SETTING_IGNORED_DIRECTORIES = "format_on_save_ignored_directories"
|
|
|
|
## Represents the modes the user wants to use by default, notably when running
|
|
## the formatter on save.
|
|
enum FormatMode {
|
|
NORMAL,
|
|
## Reorder the code according to the official style guide every time the
|
|
## formatter runs. Note that without this option, you can still reorder any
|
|
## time from the format menu in the script editor.
|
|
REORDER_CODE,
|
|
## Reparse the formatted code and compare its structure with the original.
|
|
##
|
|
## [b]WARNING:[/b] this is an imperfect check. It does not guarantee that
|
|
## formatting is 100% safe or semantically equivalent. We always recommend
|
|
## using a version control system when running the formatter.
|
|
##
|
|
## This is an option used notably for development of the formatter, when
|
|
## testing it on new codebases, to quickly catch bugs on unsupported
|
|
## GDScript syntax.
|
|
##
|
|
## When using the formatter normally on individual scripts, you can always
|
|
## undo after formatting or use a version control system to track, review,
|
|
## and undo changes.
|
|
VERIFY_STRUCTURE,
|
|
}
|
|
|
|
const COMMAND_PALETTE_CATEGORY = "gdquest gdscript formatter/"
|
|
const COMMAND_PALETTE_FORMAT_SCRIPT = "Format GDScript"
|
|
const COMMAND_PALETTE_LINT_SCRIPT = "Lint GDScript"
|
|
const COMMAND_PALETTE_INSTALL_UPDATE = "Install or Update Formatter"
|
|
const COMMAND_PALETTE_UNINSTALL = "Uninstall Formatter"
|
|
const COMMAND_PALETTE_REPORT_ISSUE = "Report Issue"
|
|
|
|
var DEFAULT_SETTINGS = {
|
|
SETTING_FORMAT_ON_SAVE: false,
|
|
SETTING_USE_SPACES: false,
|
|
SETTING_INDENT_SIZE: 4,
|
|
SETTING_FORMAT_MODE: FormatMode.NORMAL,
|
|
SETTING_FORMATTER_PATH: "",
|
|
SETTING_LINT_ON_SAVE: false,
|
|
SETTING_LINT_LINE_LENGTH: 100,
|
|
SETTING_LINT_IGNORED_RULES: "",
|
|
SETTING_IGNORED_DIRECTORIES: PackedStringArray(["addons/"]),
|
|
}
|
|
|
|
## Which gutter lint icons are shown in.
|
|
## By default, gutter 0 is for breakpoints and 1 is for things like overrides.
|
|
const GUTTER_LINT_ICON_INDEX = 2
|
|
const GUTTER_LINT_ICONS_NAME = "gdscript_formatter_lint_icons"
|
|
|
|
var connection_list: Array[Resource] = []
|
|
var installer: FormatterInstaller = null
|
|
var formatter_cache_dir: String
|
|
var menu: FormatterMenu = null
|
|
var _has_uninstall_command := false
|
|
var _has_formatter_command := false
|
|
var _has_format_command := false
|
|
var _has_lint_command := false
|
|
var _already_warned_about_reorder_on_save := false
|
|
var _already_warned_about_builtin_format_on_save := false
|
|
# Used to auto detect changes to the project's .editorconfig file.
|
|
var _editorconfig_last_modified_time := -1
|
|
# Editorconfig allows setting rules per path glob. We track globs for the format
|
|
# on save rule here so users can enable it selectively for specific folders.
|
|
var _editorconfig_format_on_save_rules: Array[Dictionary] = []
|
|
|
|
|
|
func _init() -> void:
|
|
migrate_format_mode_setting()
|
|
if not has_editor_setting(SETTING_FORMAT_MODE):
|
|
set_editor_setting(SETTING_FORMAT_MODE, DEFAULT_SETTINGS[SETTING_FORMAT_MODE])
|
|
register_format_mode_setting()
|
|
|
|
for setting: String in DEFAULT_SETTINGS.keys():
|
|
if setting == SETTING_FORMAT_MODE:
|
|
continue
|
|
if not has_editor_setting(setting):
|
|
set_editor_setting(setting, DEFAULT_SETTINGS[setting])
|
|
|
|
if not has_editor_setting(SETTING_SHORTCUT):
|
|
var default_shortcut := InputEventKey.new()
|
|
default_shortcut.echo = false
|
|
default_shortcut.pressed = true
|
|
default_shortcut.keycode = KEY_I
|
|
default_shortcut.ctrl_pressed = true
|
|
default_shortcut.shift_pressed = false
|
|
default_shortcut.alt_pressed = true
|
|
|
|
var shortcut := Shortcut.new()
|
|
shortcut.events.push_back(default_shortcut)
|
|
|
|
set_editor_setting(SETTING_SHORTCUT, shortcut)
|
|
|
|
|
|
func register_format_mode_setting() -> void:
|
|
var editor_settings := EditorInterface.get_editor_settings()
|
|
var setting_name := EDITOR_SETTINGS_CATEGORY + SETTING_FORMAT_MODE
|
|
editor_settings.add_property_info(
|
|
{
|
|
"name": setting_name,
|
|
"type": TYPE_INT,
|
|
"hint": PROPERTY_HINT_ENUM,
|
|
"hint_string": "Normal,Reorder code,Verify structure",
|
|
}
|
|
)
|
|
editor_settings.set_initial_value(setting_name, DEFAULT_SETTINGS[SETTING_FORMAT_MODE], false)
|
|
|
|
|
|
## Converts the old independent settings to the mutually exclusive format mode.
|
|
## The legacy safe mode takes priority because it is the least destructive option.
|
|
func migrate_format_mode_setting() -> void:
|
|
if has_editor_setting(SETTING_FORMAT_MODE):
|
|
return
|
|
|
|
# Inferring a version number from the old settings; editor settings does not
|
|
# give us a neat way to version our settings so we do it manually. It's just
|
|
# to keep track of migrations.
|
|
var version := -1
|
|
if has_editor_setting(SETTING_REORDER_CODE) or has_editor_setting(SETTING_SAFE_MODE):
|
|
version = 1
|
|
|
|
# Upgrade to version 2; that's when we merged safe mode and reorder code
|
|
# into a single format mode (because they're mutually exclusive).
|
|
if version == 1:
|
|
var format_mode := FormatMode.NORMAL
|
|
if has_editor_setting(SETTING_SAFE_MODE) and get_editor_setting(SETTING_SAFE_MODE) as bool:
|
|
format_mode = FormatMode.VERIFY_STRUCTURE
|
|
elif (
|
|
has_editor_setting(SETTING_REORDER_CODE)
|
|
and get_editor_setting(SETTING_REORDER_CODE) as bool
|
|
):
|
|
format_mode = FormatMode.REORDER_CODE
|
|
|
|
set_editor_setting(SETTING_FORMAT_MODE, format_mode)
|
|
|
|
# Remove the old settings so users do not see two conflicting configurations.
|
|
var editor_settings := EditorInterface.get_editor_settings()
|
|
for setting_name: String in [SETTING_REORDER_CODE, SETTING_SAFE_MODE]:
|
|
if has_editor_setting(setting_name):
|
|
editor_settings.erase(EDITOR_SETTINGS_CATEGORY + setting_name)
|
|
|
|
version = 2
|
|
|
|
|
|
func _enter_tree() -> void:
|
|
formatter_cache_dir = EditorInterface.get_editor_paths().get_cache_dir().path_join("gdquest")
|
|
installer = FormatterInstaller.new(formatter_cache_dir)
|
|
add_child(installer)
|
|
installer.installation_completed.connect(
|
|
func _on_installation_completed(binary_path: String) -> void:
|
|
set_editor_setting(SETTING_FORMATTER_PATH, binary_path)
|
|
_has_formatter_command = has_command(binary_path)
|
|
if not _has_formatter_command:
|
|
push_error(
|
|
"GDScript Formatter: Installed binary cannot be executed: " + binary_path
|
|
)
|
|
return
|
|
add_format_command()
|
|
add_lint_command()
|
|
# After installing the formatter we can add the menu option to show the uninstall command
|
|
if is_instance_valid(menu):
|
|
menu.update_menu(true),
|
|
)
|
|
installer.installation_failed.connect(
|
|
func _on_installation_failed(error_message: String) -> void:
|
|
push_error("Formatter installation failed: ", error_message),
|
|
)
|
|
|
|
_has_formatter_command = has_command(get_editor_setting(SETTING_FORMATTER_PATH))
|
|
add_format_command()
|
|
add_lint_command()
|
|
add_install_update_command()
|
|
add_uninstall_command()
|
|
add_report_issue_command()
|
|
|
|
menu = FormatterMenu.new()
|
|
add_child(menu)
|
|
menu.menu_item_selected.connect(_on_menu_item_selected)
|
|
menu.update_menu(is_formatter_installed_locally())
|
|
|
|
update_shortcut()
|
|
resource_saved.connect(_on_resource_saved)
|
|
|
|
|
|
func _exit_tree() -> void:
|
|
resource_saved.disconnect(_on_resource_saved)
|
|
|
|
remove_format_command()
|
|
remove_lint_command()
|
|
remove_install_update_command()
|
|
remove_uninstall_command()
|
|
remove_report_issue_command()
|
|
|
|
installer.queue_free()
|
|
installer = null
|
|
|
|
if is_instance_valid(menu):
|
|
menu.menu_item_selected.disconnect(_on_menu_item_selected)
|
|
menu.remove_formatter_menu()
|
|
menu.queue_free()
|
|
menu = null
|
|
|
|
|
|
func _shortcut_input(event: InputEvent) -> void:
|
|
var shortcut := get_editor_setting(SETTING_SHORTCUT) as Shortcut
|
|
if not is_instance_valid(shortcut):
|
|
return
|
|
if not shortcut.matches_event(event) or not event.is_pressed() or event.is_echo():
|
|
return
|
|
if format_current_script():
|
|
get_tree().root.set_input_as_handled()
|
|
|
|
|
|
func format_current_script() -> bool:
|
|
if not is_formatter_available():
|
|
return false
|
|
if not EditorInterface.get_script_editor().is_visible_in_tree():
|
|
return false
|
|
var current_script := EditorInterface.get_script_editor().get_current_script()
|
|
if not is_instance_valid(current_script) or not current_script is GDScript:
|
|
return false
|
|
var code_edit: CodeEdit = EditorInterface \
|
|
.get_script_editor() \
|
|
.get_current_editor() \
|
|
.get_base_editor()
|
|
|
|
var formatted_code := format_code(current_script, false, code_edit.text)
|
|
if formatted_code.is_empty():
|
|
return false
|
|
|
|
reload_code_edit(code_edit, formatted_code)
|
|
return true
|
|
|
|
|
|
func lint_current_script() -> bool:
|
|
if not is_formatter_available():
|
|
return false
|
|
if not EditorInterface.get_script_editor().is_visible_in_tree():
|
|
return false
|
|
|
|
var current_script := EditorInterface.get_script_editor().get_current_script()
|
|
if not is_instance_valid(current_script) or not current_script is GDScript:
|
|
return false
|
|
|
|
var code_edit: CodeEdit = EditorInterface \
|
|
.get_script_editor() \
|
|
.get_current_editor() \
|
|
.get_base_editor()
|
|
|
|
var lint_issues := lint_code(current_script)
|
|
if lint_issues.is_empty():
|
|
print("No linting issues found.")
|
|
clear_lint_highlights(code_edit)
|
|
return true
|
|
|
|
apply_lint_highlights(code_edit, lint_issues)
|
|
print_lint_summary(lint_issues, current_script.resource_path)
|
|
|
|
return true
|
|
|
|
|
|
func update_shortcut() -> void:
|
|
for obj: Resource in connection_list:
|
|
obj.changed.disconnect(update_shortcut)
|
|
|
|
connection_list.clear()
|
|
|
|
var shortcut := get_editor_setting(SETTING_SHORTCUT) as Shortcut
|
|
if is_instance_valid(shortcut):
|
|
for event: InputEvent in shortcut.events:
|
|
if is_instance_valid(event):
|
|
event.changed.connect(update_shortcut)
|
|
connection_list.push_back(event)
|
|
|
|
remove_format_command()
|
|
add_format_command()
|
|
|
|
|
|
func _on_resource_saved(saved_resource: Resource) -> void:
|
|
if saved_resource is not GDScript:
|
|
return
|
|
var script := saved_resource as GDScript
|
|
var do_format_on_save := get_editor_setting(SETTING_FORMAT_ON_SAVE) as bool
|
|
|
|
# We should normally never hit this condition, I'm adding it as a
|
|
# safety check and to document issues with format on save and built-in
|
|
# scripts. We support formatting built-in scripts, but not formatting them
|
|
# on save:
|
|
# - There's no way to retrieve built-in scripts and when their enclosing
|
|
# scene is saved efficiently
|
|
# - You would have to re-save the scene after formatting
|
|
#
|
|
# That would add complexity and slowdowns so we don't support it at the
|
|
# moment. We may revisit this if multiple users depend on this feature (I
|
|
# generally don't recommend built-in scripts because of their limitations
|
|
# for debugging, error reporting, VCS... they have too many limitations for
|
|
# their benefit)
|
|
if script.is_built_in():
|
|
if do_format_on_save and not _already_warned_about_builtin_format_on_save:
|
|
push_warning(
|
|
"GDScript Formatter: Format on save is not supported for built-in scripts. "
|
|
+ "Format this script manually instead."
|
|
)
|
|
_already_warned_about_builtin_format_on_save = true
|
|
return
|
|
|
|
var editorconfig_format_on_save = get_editorconfig_format_on_save(script.resource_path)
|
|
if editorconfig_format_on_save != null:
|
|
do_format_on_save = editorconfig_format_on_save as bool
|
|
var lint_on_save := get_editor_setting(SETTING_LINT_ON_SAVE) as bool
|
|
|
|
if (
|
|
do_format_on_save and get_format_mode() == FormatMode.REORDER_CODE
|
|
and not _already_warned_about_reorder_on_save
|
|
):
|
|
push_warning(
|
|
"GDScript Formatter: Reorder code is enabled for format on save. It is usually better used manually."
|
|
)
|
|
_already_warned_about_reorder_on_save = true
|
|
|
|
if not do_format_on_save and not lint_on_save:
|
|
return
|
|
|
|
var ignored_directories_normalized: Array[String] = []
|
|
var ignored_directories_seen: Dictionary[String, String] = { }
|
|
for directory: String in get_editor_setting(SETTING_IGNORED_DIRECTORIES):
|
|
# We split paths on "/", we trim trailing slashes to avoid empty path
|
|
# segments that would never match when checking ignored directories.
|
|
var directory_normalized := directory.trim_prefix("res://").trim_suffix("/")
|
|
if directory_normalized.is_empty():
|
|
push_warning(
|
|
"GDScript Formatter: Format on Save Ignored Directories entry \"%s\" " % directory
|
|
+ "has no path after removing \"res://\" and trailing slashes, and will be skipped. "
|
|
+ "This may mean you're trying to ignore the entire project, which isn't supported here. "
|
|
+ "Please remove it from the list, enter a valid path, or turn off format on save instead."
|
|
)
|
|
continue
|
|
|
|
if ignored_directories_seen.has(directory_normalized):
|
|
push_warning(
|
|
"GDScript Formatter: Format on Save Ignored Directories entry \"%s\" " % directory
|
|
+ "refers to the same directory as entry \"%s\" "
|
|
% ignored_directories_seen[directory_normalized]
|
|
+ "and will be skipped. Please remove the duplicate entry from the list."
|
|
)
|
|
continue
|
|
|
|
ignored_directories_seen[directory_normalized] = directory
|
|
ignored_directories_normalized.push_back(directory_normalized)
|
|
|
|
var saved_script_path_segments := script.resource_path.trim_prefix("res://").split("/")
|
|
for ignored_directory_path: String in ignored_directories_normalized:
|
|
var ignored_directory_segments := ignored_directory_path.split("/")
|
|
if ignored_directory_segments.size() > saved_script_path_segments.size():
|
|
continue
|
|
|
|
var matches := true
|
|
for i in range(ignored_directory_segments.size()):
|
|
if ignored_directory_segments[i] != saved_script_path_segments[i]:
|
|
matches = false
|
|
break
|
|
|
|
if matches:
|
|
return
|
|
|
|
if not is_formatter_available() or not is_instance_valid(script):
|
|
return
|
|
|
|
if do_format_on_save:
|
|
var formatted_code := format_code(script, false)
|
|
if formatted_code.is_empty():
|
|
return
|
|
|
|
script.source_code = formatted_code
|
|
ResourceSaver.save(script)
|
|
# The argument (keep_state parameter) tells Godot to try to preserve the
|
|
# state of the script instance, like static variables. Without this,
|
|
# attempting to reload tool scripts will fail with an error because they
|
|
# are already instantiated in the editor and instantiated scripts are
|
|
# not allowed to force reload without unloading first.
|
|
script.reload(true)
|
|
|
|
var script_editor := EditorInterface.get_script_editor()
|
|
var open_script_editors := script_editor.get_open_script_editors()
|
|
var open_scripts := script_editor.get_open_scripts()
|
|
|
|
if not open_scripts.has(script):
|
|
return
|
|
|
|
if script_editor.get_current_script() == script:
|
|
reload_code_edit(
|
|
script_editor.get_current_editor().get_base_editor(),
|
|
formatted_code,
|
|
true,
|
|
)
|
|
elif open_scripts.size() == open_script_editors.size():
|
|
for i: int in range(open_scripts.size()):
|
|
if open_scripts[i] == script:
|
|
reload_code_edit(open_script_editors[i].get_base_editor(), formatted_code, true)
|
|
return
|
|
else:
|
|
push_error(
|
|
"GDScript Formatter error: Unknown situation, can't reload code editor in Editor. Please report this issue."
|
|
)
|
|
|
|
if lint_on_save:
|
|
var code_edit: CodeEdit = EditorInterface \
|
|
.get_script_editor() \
|
|
.get_current_editor() \
|
|
.get_base_editor()
|
|
var lint_issues := lint_code(script)
|
|
if lint_issues.is_empty():
|
|
clear_lint_highlights(code_edit)
|
|
else:
|
|
apply_lint_highlights(code_edit, lint_issues)
|
|
print_lint_summary(lint_issues, script.resource_path)
|
|
|
|
|
|
func add_format_command() -> void:
|
|
if _has_format_command:
|
|
return
|
|
var formatter_path := get_editor_setting(SETTING_FORMATTER_PATH) as String
|
|
if formatter_path.is_empty() or not _has_formatter_command:
|
|
if not formatter_path.is_empty():
|
|
push_error(
|
|
'GDScript Formatter: The command "%s" can\'t be found in your environment.\n'
|
|
% formatter_path
|
|
+ '\tIf you have not installed the formatter, use the install/update command from the command palette.\n'
|
|
+ '\tIf you have installed the formatter, change "formatter_path" to a valid command in the "GDScript Formatter" section in Editor Settings.',
|
|
)
|
|
return
|
|
var shortcut := get_editor_setting(SETTING_SHORTCUT) as Shortcut
|
|
EditorInterface.get_command_palette().add_command(
|
|
COMMAND_PALETTE_FORMAT_SCRIPT,
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_FORMAT_SCRIPT,
|
|
format_current_script,
|
|
shortcut.get_as_text() if is_instance_valid(shortcut) else "None",
|
|
)
|
|
_has_format_command = true
|
|
|
|
|
|
func remove_format_command() -> void:
|
|
if not _has_format_command:
|
|
return
|
|
EditorInterface.get_command_palette().remove_command(
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_FORMAT_SCRIPT
|
|
)
|
|
_has_format_command = false
|
|
|
|
|
|
func add_lint_command() -> void:
|
|
if not _has_formatter_command or _has_lint_command:
|
|
return
|
|
|
|
EditorInterface.get_command_palette().add_command(
|
|
COMMAND_PALETTE_LINT_SCRIPT,
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_LINT_SCRIPT,
|
|
lint_current_script,
|
|
)
|
|
_has_lint_command = true
|
|
|
|
|
|
func remove_lint_command() -> void:
|
|
if not _has_lint_command:
|
|
return
|
|
EditorInterface.get_command_palette().remove_command(
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_LINT_SCRIPT,
|
|
)
|
|
_has_lint_command = false
|
|
|
|
|
|
func add_install_update_command() -> void:
|
|
EditorInterface.get_command_palette().add_command(
|
|
COMMAND_PALETTE_INSTALL_UPDATE,
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_INSTALL_UPDATE,
|
|
installer.install_or_update_formatter,
|
|
)
|
|
|
|
|
|
func remove_install_update_command() -> void:
|
|
EditorInterface.get_command_palette().remove_command(
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_INSTALL_UPDATE
|
|
)
|
|
|
|
|
|
func add_uninstall_command() -> void:
|
|
if is_formatter_installed_locally():
|
|
EditorInterface.get_command_palette().add_command(
|
|
COMMAND_PALETTE_UNINSTALL,
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_UNINSTALL,
|
|
uninstall_formatter,
|
|
)
|
|
_has_uninstall_command = true
|
|
|
|
|
|
func remove_uninstall_command() -> void:
|
|
if not _has_uninstall_command:
|
|
return
|
|
EditorInterface.get_command_palette().remove_command(
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_UNINSTALL
|
|
)
|
|
_has_uninstall_command = false
|
|
|
|
|
|
func add_report_issue_command() -> void:
|
|
EditorInterface.get_command_palette().add_command(
|
|
COMMAND_PALETTE_REPORT_ISSUE,
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_REPORT_ISSUE,
|
|
report_issue,
|
|
)
|
|
|
|
|
|
func remove_report_issue_command() -> void:
|
|
EditorInterface.get_command_palette().remove_command(
|
|
COMMAND_PALETTE_CATEGORY + COMMAND_PALETTE_REPORT_ISSUE
|
|
)
|
|
|
|
|
|
func has_command(command: String) -> bool:
|
|
if command.is_empty():
|
|
return false
|
|
var output: Array = []
|
|
var exit_code := OS.execute(command, ["--version"], output, true)
|
|
return exit_code == OK
|
|
|
|
|
|
func is_formatter_available() -> bool:
|
|
if _has_formatter_command:
|
|
return true
|
|
_has_formatter_command = has_command(get_editor_setting(SETTING_FORMATTER_PATH))
|
|
return _has_formatter_command
|
|
|
|
|
|
func is_formatter_installed_locally() -> bool:
|
|
var binary_name := "gdscript-formatter"
|
|
if OS.get_name().to_lower().contains("windows"):
|
|
binary_name = "gdscript-formatter.exe"
|
|
var binary_path := formatter_cache_dir.path_join(binary_name)
|
|
return FileAccess.file_exists(binary_path)
|
|
|
|
|
|
func uninstall_formatter() -> void:
|
|
var binary_name := "gdscript-formatter"
|
|
if OS.get_name().to_lower().contains("windows"):
|
|
binary_name = "gdscript-formatter.exe"
|
|
var binary_path := formatter_cache_dir.path_join(binary_name)
|
|
|
|
if FileAccess.file_exists(binary_path):
|
|
DirAccess.remove_absolute(binary_path)
|
|
print("GDScript formatter uninstalled successfully from: ", binary_path)
|
|
set_editor_setting(SETTING_FORMATTER_PATH, DEFAULT_SETTINGS[SETTING_FORMATTER_PATH])
|
|
_has_formatter_command = false
|
|
|
|
remove_format_command()
|
|
remove_lint_command()
|
|
add_format_command()
|
|
remove_uninstall_command()
|
|
add_uninstall_command()
|
|
if is_instance_valid(menu):
|
|
menu.update_menu(false)
|
|
else:
|
|
push_error("GDScript formatter not found in cache directory: ", binary_path)
|
|
|
|
|
|
func reorder_code() -> bool:
|
|
if not is_formatter_available():
|
|
return false
|
|
if not EditorInterface.get_script_editor().is_visible_in_tree():
|
|
return false
|
|
var current_script := EditorInterface.get_script_editor().get_current_script()
|
|
if not is_instance_valid(current_script) or not current_script is GDScript:
|
|
return false
|
|
var code_edit: CodeEdit = EditorInterface \
|
|
.get_script_editor() \
|
|
.get_current_editor() \
|
|
.get_base_editor()
|
|
|
|
var formatted_code := format_code(current_script, true, code_edit.text)
|
|
if formatted_code.is_empty():
|
|
return false
|
|
|
|
reload_code_edit(code_edit, formatted_code)
|
|
return true
|
|
|
|
|
|
func report_issue() -> void:
|
|
OS.shell_open("https://github.com/GDQuest/GDScript-formatter/issues")
|
|
|
|
|
|
func show_help() -> void:
|
|
OS.shell_open("https://www.gdquest.com/library/gdscript_formatter/")
|
|
|
|
|
|
func _on_menu_item_selected(command: String) -> void:
|
|
match command:
|
|
"format_script":
|
|
format_current_script()
|
|
"lint_script":
|
|
lint_current_script()
|
|
"reorder_code":
|
|
reorder_code()
|
|
"install_update":
|
|
installer.install_or_update_formatter()
|
|
"uninstall":
|
|
uninstall_formatter()
|
|
"report_issue":
|
|
report_issue()
|
|
"help":
|
|
show_help()
|
|
_:
|
|
push_warning("Unsupported command sent from the menu: " + command)
|
|
|
|
|
|
## Reloads the code editor with new text while preserving editor state.
|
|
## This includes cursor position, scroll position, breakpoints, bookmarks, and folds.
|
|
func reload_code_edit(code_edit: CodeEdit, new_text: String, tag_saved := false) -> void:
|
|
var editor_state := CodeEditState.new(code_edit)
|
|
code_edit.text = new_text
|
|
if tag_saved:
|
|
code_edit.tag_saved_version()
|
|
editor_state.restore_to_editor(code_edit)
|
|
code_edit.update_minimum_size()
|
|
code_edit.text_changed.emit()
|
|
|
|
|
|
func get_editor_setting(setting_name: String) -> Variant:
|
|
var editor_settings := EditorInterface.get_editor_settings()
|
|
var full_setting_key := EDITOR_SETTINGS_CATEGORY + setting_name
|
|
if editor_settings.has_setting(full_setting_key):
|
|
return editor_settings.get_setting(full_setting_key)
|
|
return DEFAULT_SETTINGS[setting_name]
|
|
|
|
|
|
func set_editor_setting(setting_name: String, value: Variant) -> void:
|
|
var editor_settings := EditorInterface.get_editor_settings()
|
|
var full_setting_key := EDITOR_SETTINGS_CATEGORY + setting_name
|
|
editor_settings.set_setting(full_setting_key, value)
|
|
|
|
|
|
func has_editor_setting(setting_name: String) -> bool:
|
|
var editor_settings := EditorInterface.get_editor_settings()
|
|
var full_setting_key := EDITOR_SETTINGS_CATEGORY + setting_name
|
|
return editor_settings.has_setting(full_setting_key)
|
|
|
|
|
|
## Returns true if this script should be formatted automatically on save, based
|
|
## on the project's .editorconfig file. Returns false if the config says not to
|
|
## format on save. Returns null if no rule matches (then it's the user editor
|
|
## settings that take over).
|
|
func get_editorconfig_format_on_save(script_path: String) -> Variant:
|
|
var editorconfig_path := ProjectSettings.globalize_path("res://.editorconfig")
|
|
var modified_time := FileAccess.get_modified_time(editorconfig_path)
|
|
if modified_time != _editorconfig_last_modified_time:
|
|
_editorconfig_last_modified_time = modified_time
|
|
_editorconfig_format_on_save_rules.clear()
|
|
load_editorconfig_format_on_save_rules(editorconfig_path)
|
|
|
|
var relative_script_path := script_path.trim_prefix("res://")
|
|
var format_on_save = null
|
|
for rule: Dictionary in _editorconfig_format_on_save_rules:
|
|
var pattern := rule["pattern"] as String
|
|
if pattern.is_empty() or editorconfig_section_matches(pattern, relative_script_path):
|
|
format_on_save = rule["format_on_save"]
|
|
|
|
return format_on_save
|
|
|
|
|
|
## Loads the project editorconfig file and parses format on save rules.
|
|
func load_editorconfig_format_on_save_rules(editorconfig_path: String) -> void:
|
|
var editorconfig_file := FileAccess.open(editorconfig_path, FileAccess.READ)
|
|
if editorconfig_file == null:
|
|
return
|
|
|
|
var pattern := ""
|
|
|
|
while not editorconfig_file.eof_reached():
|
|
var line := editorconfig_file.get_line().strip_edges()
|
|
if line.is_empty() or line.begins_with("#") or line.begins_with(";"):
|
|
continue
|
|
|
|
if line.begins_with("[") and line.ends_with("]"):
|
|
pattern = line.trim_prefix("[").trim_suffix("]")
|
|
continue
|
|
|
|
if not line.contains("="):
|
|
continue
|
|
|
|
var key_and_value := line.split("=", true, 1)
|
|
if key_and_value[0].strip_edges().to_lower() != "gdscript_formatter_format_on_save":
|
|
continue
|
|
|
|
match key_and_value[1].strip_edges().to_lower():
|
|
"true":
|
|
_editorconfig_format_on_save_rules.append(
|
|
{ "pattern": pattern, "format_on_save": true }
|
|
)
|
|
"false":
|
|
_editorconfig_format_on_save_rules.append(
|
|
{ "pattern": pattern, "format_on_save": false }
|
|
)
|
|
|
|
editorconfig_file.close()
|
|
|
|
|
|
## Returns true if an EditorConfig section applies to a saved script.
|
|
## pattern: The EditorConfig pattern to match against.
|
|
## relative_script_path: The path of the script relative to the project root.
|
|
func editorconfig_section_matches(pattern: String, relative_script_path: String) -> bool:
|
|
if pattern.is_empty():
|
|
return false
|
|
|
|
var normalized_pattern := pattern.trim_prefix("/")
|
|
var matching_path := relative_script_path
|
|
# If there's no / in the pattern it means this pattern targets a filename.
|
|
# It's not a folder/path glob pattern.
|
|
if not normalized_pattern.contains("/"):
|
|
matching_path = relative_script_path.get_file()
|
|
|
|
return matching_path.match(normalized_pattern)
|
|
|
|
|
|
## Formats GDScript code using the GDScript Formatter and returns it as a string.
|
|
## When source_content is null, reads the code from the GDScript resource directly.
|
|
## Otherwise, formats source_content without reading from the file.
|
|
##
|
|
## Pass a string through source_content when the user is editing the script in
|
|
## the editor and requests formatting without having saved their changes (in
|
|
## that case, the code they're editing only exists in the script editor's open
|
|
## tab).
|
|
func format_code(
|
|
script: GDScript,
|
|
force_reorder := false,
|
|
source_content: Variant = null,
|
|
) -> String:
|
|
var script_path := script.resource_path
|
|
if source_content == null and script_path.is_empty():
|
|
push_error("GDScript Formatter Error: Can't format an unsaved script.")
|
|
return ""
|
|
|
|
# Source content is not set, read from the GDScript resource instead.
|
|
#
|
|
# This is a bit of a hack to avoid two issues:
|
|
#
|
|
# 1. Running GDScript formatter on stdin/stdout through Godot with
|
|
# OS.execute() has encoding issues with UTF-8 characters and we don't have
|
|
# control over the output encoding (it might be assuming ASCII characters)
|
|
#
|
|
# 2. If we modify a file in place using the external formatter from Godot,
|
|
# it will bring up a pop-up that warns users that the file has been changed
|
|
# outside Godot.
|
|
#
|
|
# To work around that, I save a copy of the script as a temporary file,
|
|
# format the file, and read it specifically as a UTF-8 string.
|
|
if source_content == null:
|
|
var source_file := FileAccess.open(
|
|
ProjectSettings.globalize_path(script_path),
|
|
FileAccess.READ,
|
|
)
|
|
if not source_file:
|
|
push_error("GDScript Formatter Error: Cannot read source file: " + script_path)
|
|
return ""
|
|
|
|
# FileAccess.get_as_text() reads the file as UTF-8. We use it here and after
|
|
# formatting the temporary file.
|
|
source_content = source_file.get_as_text()
|
|
source_file.close()
|
|
|
|
var path_temporary_file := OS.get_temp_dir().path_join(
|
|
"gdscript_formatter_%d.gd" % Time.get_ticks_msec()
|
|
)
|
|
var temporary_file := FileAccess.open(path_temporary_file, FileAccess.WRITE)
|
|
if temporary_file == null:
|
|
push_error("GDScript Formatter Error: Cannot create temporary file: " + path_temporary_file)
|
|
return ""
|
|
temporary_file.store_string(source_content as String)
|
|
temporary_file.close()
|
|
|
|
var formatter_arguments := PackedStringArray()
|
|
if get_editor_setting(SETTING_USE_SPACES):
|
|
formatter_arguments.push_back("--use-spaces")
|
|
formatter_arguments.push_back("--indent-size=%d" % get_editor_setting(SETTING_INDENT_SIZE))
|
|
|
|
var format_mode := get_format_mode()
|
|
var should_reorder := force_reorder or format_mode == FormatMode.REORDER_CODE
|
|
|
|
if should_reorder:
|
|
formatter_arguments.push_back("--reorder-code")
|
|
|
|
if not force_reorder and format_mode == FormatMode.VERIFY_STRUCTURE:
|
|
# NB: this is a deprecated flag, replaced with --verify-structure, but
|
|
# we keep it here for users that have a previous version of the
|
|
# formatter installed.
|
|
formatter_arguments.push_back("--safe")
|
|
|
|
formatter_arguments.push_back(path_temporary_file)
|
|
|
|
var output: Array = []
|
|
var exit_code := OS.execute(
|
|
get_editor_setting(SETTING_FORMATTER_PATH),
|
|
formatter_arguments,
|
|
output,
|
|
true,
|
|
)
|
|
|
|
var formatted_content := ""
|
|
if exit_code == OK:
|
|
var result_file := FileAccess.open(path_temporary_file, FileAccess.READ)
|
|
if result_file:
|
|
formatted_content = result_file.get_as_text()
|
|
result_file.close()
|
|
else:
|
|
push_error("Format GDScript: Cannot read formatted output from temp file")
|
|
else:
|
|
push_error(
|
|
"Format GDScript failed: "
|
|
+ (script_path if not script_path.is_empty() else "unsaved script")
|
|
)
|
|
push_error(
|
|
"\tExit code: " + str(exit_code) + " Output: "
|
|
+ (output[0].strip_edges() if output.size() > 0 else "No output"),
|
|
)
|
|
push_error(
|
|
'\tIf your script does not have any syntax errors, this might be a formatter bug.'
|
|
)
|
|
|
|
if FileAccess.file_exists(path_temporary_file):
|
|
DirAccess.remove_absolute(path_temporary_file)
|
|
|
|
return formatted_content
|
|
|
|
|
|
func get_format_mode() -> int:
|
|
return get_editor_setting(SETTING_FORMAT_MODE) as int
|
|
|
|
|
|
## Lints a GDScript file using the GDScript Formatter's linter,
|
|
## and returns an array of lint issues.
|
|
func lint_code(script: GDScript) -> Array:
|
|
var script_path := script.resource_path
|
|
var output: Array = []
|
|
var formatter_arguments: Array = ["lint", ProjectSettings.globalize_path(script_path)]
|
|
|
|
var max_line_length := get_editor_setting(SETTING_LINT_LINE_LENGTH) as int
|
|
formatter_arguments.append("--max-line-length")
|
|
formatter_arguments.append(str(max_line_length))
|
|
|
|
var ignored_rules := get_editor_setting(SETTING_LINT_IGNORED_RULES) as String
|
|
if not ignored_rules.is_empty():
|
|
formatter_arguments.append("--disable")
|
|
formatter_arguments.append(ignored_rules)
|
|
|
|
var exit_code := OS.execute(
|
|
get_editor_setting(SETTING_FORMATTER_PATH),
|
|
formatter_arguments,
|
|
output,
|
|
)
|
|
if exit_code == OK:
|
|
return [] # No issues found
|
|
|
|
if exit_code == 1:
|
|
# Parse lint output - the output is a single string with multiple lines
|
|
var issues = []
|
|
for output_item in output:
|
|
var lines = output_item.split("\n")
|
|
for line in lines:
|
|
var trimmed_line = line.strip_edges()
|
|
if trimmed_line.is_empty():
|
|
continue
|
|
var issue = parse_lint_issue(trimmed_line)
|
|
if issue != null and not issue.is_empty():
|
|
issues.push_back(issue)
|
|
return issues
|
|
|
|
push_error("Lint GDScript failed: " + script_path)
|
|
push_error(
|
|
"\tExit code: " + str(exit_code) + " Output: "
|
|
+ (output.front().strip_edges() if output.size() > 0 else "No output")
|
|
)
|
|
return []
|
|
|
|
|
|
## Parses a lint issue line and returns a dictionary with issue information
|
|
func parse_lint_issue(line: String) -> Dictionary:
|
|
# Expected format: filename:line:rule:severity: message
|
|
var regex = RegEx.new()
|
|
regex.compile(r"^(.*\.gd):(\d+):([^:]+):([^:]+):([\s\S]*)$")
|
|
var result = regex.search(line)
|
|
if result:
|
|
return {
|
|
"line": int(result.get_string(2)) - 1,
|
|
"rule": result.get_string(3),
|
|
"severity": result.get_string(4),
|
|
"message": result.get_string(5).strip_edges(),
|
|
}
|
|
return { }
|
|
|
|
|
|
## Applies lint highlighting to the code editor
|
|
func apply_lint_highlights(code_edit: CodeEdit, issues: Array) -> void:
|
|
clear_lint_highlights(code_edit)
|
|
|
|
# Add and set up gutter for lint icons if not already present.
|
|
# We check by name to avoid conflicts with gutters added by other addons.
|
|
# Once added, the gutter is never removed so the layout doesn't shift on clear.
|
|
var has_lint_gutter := false
|
|
for i: int in code_edit.get_gutter_count():
|
|
if code_edit.get_gutter_name(i) == GUTTER_LINT_ICONS_NAME:
|
|
has_lint_gutter = true
|
|
break
|
|
if not has_lint_gutter:
|
|
code_edit.add_gutter(GUTTER_LINT_ICON_INDEX)
|
|
code_edit.set_gutter_name(GUTTER_LINT_ICON_INDEX, GUTTER_LINT_ICONS_NAME)
|
|
code_edit.set_gutter_type(GUTTER_LINT_ICON_INDEX, CodeEdit.GutterType.GUTTER_TYPE_ICON)
|
|
const EDITOR_ICON_DEFAULT_WIDTH = 16.0
|
|
code_edit.set_gutter_width(
|
|
GUTTER_LINT_ICON_INDEX,
|
|
EDITOR_ICON_DEFAULT_WIDTH * EditorInterface.get_editor_scale(),
|
|
)
|
|
|
|
for issue in issues:
|
|
var line_number: int = issue.line
|
|
var severity: String = issue.severity
|
|
|
|
var color := Color(1, 0, 0, 0.1) if severity == "error" else Color(1, 1, 0, 0.1)
|
|
code_edit.set_line_background_color(line_number, color)
|
|
var icon_name := "StatusError" if severity == "error" else "StatusWarning"
|
|
var icon := EditorInterface.get_editor_theme().get_icon(icon_name, "EditorIcons")
|
|
code_edit.set_line_gutter_icon(line_number, GUTTER_LINT_ICON_INDEX, icon)
|
|
|
|
|
|
## Prints a detailed summary of lint issues to the output
|
|
func print_lint_summary(issues: Array, script_path: String) -> void:
|
|
print_rich("\n[b]=== Linting Results for %s ===[/b]\n" % script_path)
|
|
print_rich("[b]Found [i]%s[/i] issue(s)\n[/b]" % issues.size())
|
|
|
|
for issue in issues:
|
|
var line_display = str(issue.line + 1) # Convert back to 1-based for display
|
|
var severity_label = issue.severity.to_upper()
|
|
print_rich(
|
|
"[color=%s]%s[/color] on line [color=cyan]%s[/color] ([i]%s[/i])"
|
|
% [
|
|
"red" if severity_label == "ERROR" else "yellow",
|
|
severity_label,
|
|
line_display,
|
|
issue.rule,
|
|
],
|
|
)
|
|
print_rich("[i]%s[/i]\n" % [issue.message])
|
|
|
|
print_rich("[b]=== End Linting Results ===[/b]\n")
|
|
|
|
|
|
## Clears all lint highlighting from the code editor.
|
|
## The lint gutter is intentionally kept so the layout does not shift.
|
|
func clear_lint_highlights(code_edit: CodeEdit) -> void:
|
|
var lint_gutter_index := -1
|
|
for i: int in code_edit.get_gutter_count():
|
|
if code_edit.get_gutter_name(i) == GUTTER_LINT_ICONS_NAME:
|
|
lint_gutter_index = i
|
|
break
|
|
|
|
for line in range(code_edit.get_line_count()):
|
|
code_edit.set_line_background_color(line, Color(0, 0, 0, 0))
|
|
if lint_gutter_index != -1:
|
|
code_edit.set_line_gutter_icon(line, lint_gutter_index, null)
|
|
|
|
|
|
## Data structure to hold code editor state information
|
|
class CodeEditState:
|
|
var caret_line: int
|
|
var caret_column: int
|
|
var horizontal_scroll: int
|
|
var vertical_scroll: int
|
|
var breakpoints: Dictionary[int, String] = { }
|
|
var bookmarks: Dictionary[int, String] = { }
|
|
var folds: Dictionary[int, String] = { }
|
|
var code_edit: CodeEdit
|
|
|
|
|
|
func _init(code_edit: CodeEdit) -> void:
|
|
self.code_edit = code_edit
|
|
caret_line = code_edit.get_caret_line()
|
|
caret_column = code_edit.get_caret_column()
|
|
horizontal_scroll = code_edit.scroll_horizontal
|
|
vertical_scroll = code_edit.scroll_vertical
|
|
|
|
for line in code_edit.get_breakpointed_lines():
|
|
breakpoints[line] = code_edit.get_line(line)
|
|
for line in code_edit.get_bookmarked_lines():
|
|
bookmarks[line] = code_edit.get_line(line)
|
|
for line in code_edit.get_folded_lines():
|
|
folds[line] = code_edit.get_line(line)
|
|
|
|
|
|
func restore_to_editor(code_edit: CodeEdit) -> void:
|
|
var new_line_count := code_edit.get_line_count()
|
|
|
|
_restore_line_features(breakpoints, code_edit.set_line_as_breakpoint, new_line_count)
|
|
_restore_line_features(bookmarks, code_edit.set_line_as_bookmarked, new_line_count)
|
|
_restore_line_features(
|
|
folds,
|
|
func(line: int, _is_folded: bool) -> void:
|
|
code_edit.fold_line(line),
|
|
new_line_count,
|
|
)
|
|
|
|
code_edit.set_caret_line(caret_line)
|
|
code_edit.set_caret_column(caret_column)
|
|
|
|
code_edit.scroll_horizontal = horizontal_scroll
|
|
code_edit.scroll_vertical = vertical_scroll
|
|
|
|
|
|
## Restores line-based features (breakpoints, bookmarks, folds) by finding the best matching lines
|
|
## in the new text based on similarity to the original line text.
|
|
##
|
|
## Big thanks to https://github.com/Daylily-Zeleen/GDScript-Formatter for this approach.
|
|
func _restore_line_features(
|
|
stored_features: Dictionary,
|
|
set_line_func: Callable,
|
|
new_line_count: int,
|
|
) -> void:
|
|
var stored_lines := PackedInt64Array(stored_features.keys())
|
|
for line_index in range(stored_lines.size()):
|
|
var original_line := stored_lines[line_index] as int
|
|
var original_text := stored_features[original_line] as String
|
|
|
|
# After formatting lines can move, so we need to find the best match for the original line
|
|
# to restore the breakpoints, bookmarks, and folds.
|
|
# We first check the same line, then we expand our search outwards until we find a match.
|
|
# We use a similarity threshold of 0.9 to account for minor changes in the line text.
|
|
# This should be sufficient for most cases, but might need adjustment for edge cases.
|
|
# If no match is found, we skip restoring this feature
|
|
if (
|
|
original_line < new_line_count
|
|
and code_edit.get_line(original_line).similarity(original_text) > 0.9
|
|
):
|
|
set_line_func.call(original_line, true)
|
|
continue
|
|
|
|
var line_above := original_line - 1
|
|
var line_below := original_line + 1
|
|
while line_above >= 0 or line_below < new_line_count:
|
|
if line_below < new_line_count and code_edit.get_line(line_below).similarity(
|
|
original_text
|
|
) > 0.9:
|
|
set_line_func.call(line_below, true)
|
|
break
|
|
if line_above >= 0 and code_edit.get_line(line_above).similarity(original_text) > 0.9:
|
|
set_line_func.call(line_above, true)
|
|
break
|
|
|
|
line_above -= 1
|
|
line_below += 1
|