diff --git a/tools/check_orphan_assets.gd b/tools/check_orphan_assets.gd index f20530091..2fb5c53c0 100644 --- a/tools/check_orphan_assets.gd +++ b/tools/check_orphan_assets.gd @@ -17,6 +17,8 @@ extends EditorScript ## [code]load()[/code] calls are not recorded as dependencies. A path assembled ## at run time cannot be detected either way. +const Util = preload("./util.gd") + ## Where to look for assets. Everything under it is checked. const ROOT_FOLDER := "res://scenes/quests/story_quests/stella" @@ -42,7 +44,7 @@ func _run() -> void: var orphans: Dictionary[String, bool] = {} var stale: Array[String] = [] - for path: String in _all_files(ROOT_FOLDER): + for path: String in Util.all_files(ROOT_FOLDER, SKIP_FOLDERS): if path.ends_with(IMPORT_SUFFIX): # A .import without its source is a leftover, not an unused asset. if not FileAccess.file_exists(path.trim_suffix(IMPORT_SUFFIX)): @@ -91,7 +93,7 @@ func _collect_referenced() -> Dictionary[String, bool]: var uid_or_path := RegEx.create_from_string("(?:uid|res)://[^\"')\\s]+") - for path: String in _all_files("res://"): + for path: String in Util.all_files("res://", SKIP_FOLDERS): var extension := path.get_extension().to_lower() if extension in RESOURCE_EXTENSIONS: @@ -110,30 +112,6 @@ func _collect_referenced() -> Dictionary[String, bool]: return referenced -## Returns every file under [param folder], recursively. -func _all_files(folder: String) -> PackedStringArray: - var files := PackedStringArray() - var pending := PackedStringArray([folder]) - - while not pending.is_empty(): - var current := pending[-1] - pending.remove_at(pending.size() - 1) - - if current in SKIP_FOLDERS: - continue - - for directory: String in DirAccess.get_directories_at(current): - # Godot's own cache holds copies of everything and would count as - # references to assets that are otherwise unused. - if directory != ".godot": - pending.append(current.path_join(directory)) - - for file: String in DirAccess.get_files_at(current): - files.append(current.path_join(file)) - - return files - - ## Computes, for [param folder] and every folder recursively inside it, whether every ## asset file it (recursively) contains is in [param orphans], storing the result for ## each folder in [param fully_orphaned] as a set. A folder with no asset files anywhere diff --git a/tools/remove_csharp_related_files.gd b/tools/remove_csharp_related_files.gd new file mode 100644 index 000000000..b6e1f2b27 --- /dev/null +++ b/tools/remove_csharp_related_files.gd @@ -0,0 +1,118 @@ +# SPDX-FileCopyrightText: The Threadbare Authors +# SPDX-License-Identifier: MPL-2.0 +@tool +extends EditorScript +## Removes the C# scripts, and the scenes and resources that use them +## +## Threadbare is written in GDScript only, so the C# variants that some addons +## ship are never loaded, and are unneeded weight. Run this in the editor after +## updating an addon, since the update brings them back. The report goes to the +## Output panel. +## [br][br] +## A scene or resource that uses a removed file is removed too, and so is anything +## that uses that scene, until nothing more is affected. Otherwise it would be +## left pointing at a file that is gone. + +const Util = preload("./util.gd") + +## Where to look for files. Everything under it is checked. +const ROOT_FOLDER := "res://" + +const CSHARP_EXTENSION := "cs" + +## Extensions of the files whose references to other files are followed. +const RESOURCE_EXTENSIONS: PackedStringArray = ["tscn", "tres"] + + +func _run() -> void: + var files := Util.all_files(ROOT_FOLDER) + + var to_remove: Dictionary[String, bool] = {} + for path: String in files: + if path.get_extension() == CSHARP_EXTENSION: + to_remove[path] = true + + var references: Dictionary[String, PackedStringArray] = {} + for path: String in files: + if path.get_extension() in RESOURCE_EXTENSIONS: + references[path] = _external_references(path) + + # Each pass may mark for removal a scene that other scenes use, so this + # repeats until a pass adds nothing. + var grew := true + while grew: + grew = false + var gone := _identifiers_of(to_remove.keys()) + for path: String in references: + if to_remove.has(path): + continue + for referenced: String in references[path]: + if gone.has(referenced): + to_remove[path] = true + grew = true + break + + var removed: Array[String] = [] + var paths: Array = to_remove.keys() + paths.sort() + for path: String in paths: + for target: String in [path, path + ".uid"]: + if not FileAccess.file_exists(target): + continue + var error := DirAccess.remove_absolute(ProjectSettings.globalize_path(target)) + if error == OK: + removed.append(target) + else: + push_error("Could not remove %s: %s" % [target, error_string(error)]) + + var csharp := paths.filter( + func(path: String) -> bool: return path.get_extension() == CSHARP_EXTENSION + ) + print( + ( + "Removed %d C# script(s) and %d scene(s) or resource(s) using them, %d file(s) in total:" + % [csharp.size(), paths.size() - csharp.size(), removed.size()] + ) + ) + for path: String in removed: + print(" ", path) + + if not removed.is_empty(): + # The files changed behind the editor's back, so let it notice. + EditorInterface.get_resource_filesystem().scan() + + +## Returns the path and UID of every [code]ext_resource[/code] in [param path]. +func _external_references(path: String) -> PackedStringArray: + var found_references := PackedStringArray() + var content := FileAccess.get_file_as_string(path) + + var ext_resource := RegEx.create_from_string("(?m)^\\[ext_resource ([^\\]]*)\\]") + # The boundary keeps the id attribute from being read as part of uid. + var attribute := RegEx.create_from_string('\\b(path|uid)="([^"]+)"') + + for line: RegExMatch in ext_resource.search_all(content): + for value: RegExMatch in attribute.search_all(line.get_string(1)): + found_references.append(value.get_string(2)) + + return found_references + + +## Returns the set of ways [param paths] can be referred to, which is their paths +## and their UIDs. +func _identifiers_of(paths: Array) -> Dictionary[String, bool]: + var identifiers: Dictionary[String, bool] = {} + + for path: String in paths: + identifiers[path] = true + + # A script keeps its UID next to it, in a .uid file. + var sidecar := path + ".uid" + if FileAccess.file_exists(sidecar): + identifiers[FileAccess.get_file_as_string(sidecar).strip_edges()] = true + + var uid := ResourceUID.path_to_uid(path) + if uid != path: + identifiers[uid] = true + + return identifiers diff --git a/tools/remove_csharp_related_files.gd.uid b/tools/remove_csharp_related_files.gd.uid new file mode 100644 index 000000000..f52330df5 --- /dev/null +++ b/tools/remove_csharp_related_files.gd.uid @@ -0,0 +1 @@ +uid://bo5f7pq5bvc8p diff --git a/tools/util.gd b/tools/util.gd index 34097acb7..a31fda24e 100644 --- a/tools/util.gd +++ b/tools/util.gd @@ -3,8 +3,6 @@ @tool extends Object -const DEBUG := true - ## Return a list of all scenes in the project that match the filter. ## filter accepts one argument, the path to a scene, and returns a boolean, @@ -49,3 +47,29 @@ static func find_scenes( dir.list_dir_end() return packed_scenes + + +## Return every file under [param folder], recursively. The folders in +## [param skip_folders], and everything under them, are left out. So is Godot's +## own .godot cache, which holds a copy of everything. +static func all_files( + folder: String, skip_folders: PackedStringArray = PackedStringArray() +) -> PackedStringArray: + var files := PackedStringArray() + var pending := PackedStringArray([folder]) + + while not pending.is_empty(): + var current := pending[-1] + pending.remove_at(pending.size() - 1) + + if current in skip_folders: + continue + + for directory: String in DirAccess.get_directories_at(current): + if directory != ".godot": + pending.append(current.path_join(directory)) + + for file: String in DirAccess.get_files_at(current): + files.append(current.path_join(file)) + + return files