summaryrefslogtreecommitdiff
path: root/addons/escoria-core/tools
diff options
context:
space:
mode:
authorRadio <radiohotline@disroot.org>2026-06-04 15:15:11 +0300
committerRadio <radiohotline@disroot.org>2026-06-04 15:15:11 +0300
commit9d74b49ab62908d4acf53dde444413886b8b28e5 (patch)
tree0cb785267ea7e239b19d88684c0d64b9bd5d52f0 /addons/escoria-core/tools
parent2c85d452ad02a5f89cd43bad7f9de31d0e4fa0c1 (diff)
escoria setup and old branch archives
Diffstat (limited to 'addons/escoria-core/tools')
-rw-r--r--addons/escoria-core/tools/logging/esc_log_level.gd35
-rw-r--r--addons/escoria-core/tools/logging/esc_log_level.gd.uid1
-rw-r--r--addons/escoria-core/tools/logging/esc_logger.gd462
-rw-r--r--addons/escoria-core/tools/logging/esc_logger.gd.uid1
-rw-r--r--addons/escoria-core/tools/logging/esc_safe_logging.gd60
-rw-r--r--addons/escoria-core/tools/logging/esc_safe_logging.gd.uid1
-rw-r--r--addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd151
-rw-r--r--addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd.uid1
-rw-r--r--addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd387
-rw-r--r--addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd.uid1
-rw-r--r--addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd52
-rw-r--r--addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd.uid1
-rw-r--r--addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd19
-rw-r--r--addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd.uid1
14 files changed, 1173 insertions, 0 deletions
diff --git a/addons/escoria-core/tools/logging/esc_log_level.gd b/addons/escoria-core/tools/logging/esc_log_level.gd
new file mode 100644
index 0000000..517cbf5
--- /dev/null
+++ b/addons/escoria-core/tools/logging/esc_log_level.gd
@@ -0,0 +1,35 @@
+class_name ESCLogLevel
+## Log levels for ESCLogger.
+
+## Valid log levels by order of granularity (E, W, I, D, T)
+enum {
+ LOG_ERROR,
+ LOG_WARNING,
+ LOG_INFO,
+ LOG_DEBUG,
+ LOG_TRACE
+}
+
+
+## A map of log level names to log level ints
+const LEVEL_MAP: Dictionary = {
+ "ERROR": LOG_ERROR,
+ "WARNING": LOG_WARNING,
+ "INFO": LOG_INFO,
+ "DEBUG": LOG_DEBUG,
+ "TRACE": LOG_TRACE,
+}
+
+## Static function to determine the int log level value defined in Project Settings (Escoria>Debug>Log Level)[br]
+## [br]
+## #### Parameters[br]
+## [br]
+## None.
+## [br]
+## #### Returns[br]
+## [br]
+## Returns a `int` value. (`int`)
+static func determine_escoria_log_level() -> int:
+ return LEVEL_MAP[ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.LOG_LEVEL
+ ).to_upper()]
diff --git a/addons/escoria-core/tools/logging/esc_log_level.gd.uid b/addons/escoria-core/tools/logging/esc_log_level.gd.uid
new file mode 100644
index 0000000..d3fee64
--- /dev/null
+++ b/addons/escoria-core/tools/logging/esc_log_level.gd.uid
@@ -0,0 +1 @@
+uid://cfnhsqcchmqbd
diff --git a/addons/escoria-core/tools/logging/esc_logger.gd b/addons/escoria-core/tools/logging/esc_logger.gd
new file mode 100644
index 0000000..26d8d86
--- /dev/null
+++ b/addons/escoria-core/tools/logging/esc_logger.gd
@@ -0,0 +1,462 @@
+## Base class of all logger types.
+class ESCLoggerBase:
+ ## Signal sent when Escoria requires performing an emergency savegame.[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## None.
+ ## [br]
+ signal perform_emergency_savegame
+
+ ## Signal sent when an error or warning happened.[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |message|`Variant`|Error or warning message emitted through the logger.|yes|[br]
+ ## [br]
+ signal error_message_signal(message)
+
+ ## Log filename format
+ const LOG_FILE_FORMAT: String = "log_%s_%s.log"
+
+ # Configured log level
+ var _log_level: int
+
+ ## If true, assert() functions will not be called, thus the program won't
+ ## exit or error. Resets to false after an assert() call was ignored once.
+ ## Useful for console calls.
+ var dont_assert: bool = false
+
+
+ # Constructor
+ func _init():
+ _log_level = ESCLogLevel.determine_escoria_log_level()
+
+ ## Formats a message depending on context, message and letter, then returns it. The formatted string[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |context|`String`|usually, the escoria file that sent the message.|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## |letter|`String`|letter to add to the formatted log (I for Info, W for Warning...)|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns a `String` value. (`String`)
+ func formatted_message(context: String, msg: String, letter: String) -> String:
+ return "ESC ({0}) {1} {2}: {3}".format([_formatted_date(), letter, context, msg])
+
+ ## Trace log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func trace(owner: Object, msg: String):
+ var context: String = owner.get_script().resource_path.get_file()
+ _trace_message(context, msg)
+
+ # Direct message trace log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _trace_message(context: String, msg: String):
+ print(formatted_message(context, msg, "T"))
+
+ ## Debug log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func debug(owner: Object, msg: String):
+ var context: String = owner.get_script().resource_path.get_file()
+ _debug_message(context, msg)
+
+ # Static debug log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _debug_message(context: String, msg: String):
+ print(formatted_message(context, msg, "D"))
+
+ ## Info log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func info(owner: Object, msg: String):
+ var context: String = owner.get_script().resource_path.get_file()
+ _info_message(context, msg)
+
+ # Static info log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _info_message(context: String, msg: String):
+ print(formatted_message(context, msg, "I"))
+
+ ## Warning log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func warn(owner: Object, msg: String):
+ var context: String = owner.get_script().resource_path.get_file()
+ _warn_message(context, msg)
+
+ # Static warning log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _warn_message(context: String, msg: String):
+ print(formatted_message(context, msg, "W"))
+ push_warning(formatted_message(context, msg, "W"))
+ if ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_WARNINGS
+ ):
+ if not dont_assert:
+ assert(false)
+ escoria.get_tree().quit()
+ dont_assert = false
+ error_message_signal.emit(msg)
+
+ ## Error log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func error(owner: Object, msg: String):
+ var context = owner.get_script().resource_path.get_file()
+ _error_message(context, msg)
+
+ # Static error log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _error_message(context: String, msg: String):
+ printerr(formatted_message(context, msg, "E"))
+ push_error(formatted_message(context, msg, "E"))
+ if ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS
+ ):
+ if not dont_assert:
+ assert(false)
+ escoria.get_tree().quit()
+ dont_assert = false
+ error_message_signal.emit(msg)
+
+ # Formats the current system's datetime as 'YYYY-mm-ddTHH:MM:SS'.
+ func _formatted_date() -> String:
+ var info = Time.get_datetime_dict_from_system()
+ info["year"] = "%04d" % info["year"]
+ info["month"] = "%02d" % info["month"]
+ info["day"] = "%02d" % info["day"]
+ info["hour"] = "%02d" % info["hour"]
+ info["minute"] = "%02d" % info["minute"]
+ info["second"] = "%02d" % info["second"]
+ return "{year}-{month}-{day}T{hour}:{minute}:{second}".format(info)
+
+
+## A logger that logs to the terminal and to a log file.
+class ESCLoggerFile extends ESCLoggerBase:
+ ## Log file handler
+ var log_file: FileAccess
+
+ # Constructor
+ func _init():
+ super()
+ # This is left alone as this constructor is called from escoria.gd's own
+ # constructor
+ var log_file_path = ProjectSettings.get_setting(
+ ESCProjectSettingsManager.LOG_FILE_PATH
+ )
+ var date = Time.get_datetime_dict_from_system()
+ log_file_path = log_file_path.path_join(LOG_FILE_FORMAT % [
+ str(date["year"]) + str(date["month"]) + str(date["day"]),
+ str(date["hour"]) + str(date["minute"]) + str(date["second"])
+ ])
+ log_file = FileAccess.open(
+ log_file_path,
+ FileAccess.WRITE
+ )
+
+ ## Trace log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func trace(owner: Object, msg: String):
+ if _log_level >= ESCLogLevel.LOG_TRACE:
+ _log_to_file(owner, msg, "T")
+ super.trace(owner, msg)
+
+ # Direct message trace log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _trace_message(context: String, msg: String):
+ if _log_level >= ESCLogLevel.LOG_TRACE:
+ _log_to_file_message(context, msg, "T")
+ super._trace_message(context, msg)
+
+ ## Debug log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func debug(owner: Object, msg: String):
+ if _log_level >= ESCLogLevel.LOG_DEBUG:
+ _log_to_file(owner, msg, "D")
+ super.debug(owner, msg)
+
+ # Static debug log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _debug_message(context: String, msg: String):
+ if _log_level >= ESCLogLevel.LOG_DEBUG:
+ _log_to_file_message(context, msg, "D")
+ super._debug_message(context, msg)
+
+ ## Info log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func info(owner: Object, msg: String):
+ if _log_level >= ESCLogLevel.LOG_INFO:
+ _log_to_file(owner, msg, "I")
+ super.info(owner, msg)
+
+ # Static info log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _info_message(context: String, msg: String):
+ if _log_level >= ESCLogLevel.LOG_INFO:
+ _log_to_file_message(context, msg, "I")
+ super._info_message(context, msg)
+
+ ## Warning log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func warn(owner: Object, msg: String):
+ if _log_level >= ESCLogLevel.LOG_WARNING:
+ _log_to_file(owner, msg, "W")
+ if ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_WARNINGS
+ ):
+ _log_stack_trace_to_file()
+ print_stack()
+ close_logs()
+ super.warn(owner, msg)
+
+ # Static warning log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _warn_message(context: String, msg: String):
+ if _log_level >= ESCLogLevel.LOG_WARNING:
+ _log_to_file_message(context, msg, "W")
+ if ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_WARNINGS
+ ):
+ _log_stack_trace_to_file()
+ print_stack()
+ close_logs()
+ super._warn_message(context, msg)
+
+ ## Error log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func error(owner: Object, msg: String):
+ if _log_level >= ESCLogLevel.LOG_ERROR:
+ _log_to_file(owner, msg, "E")
+ if ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS
+ ):
+ _log_stack_trace_to_file()
+ print_stack()
+ close_logs()
+ super.error(owner, msg)
+
+ # Static error log (requiring a string for the context)
+ #
+ # #### PARAMETERS
+ #
+ # - owner: caller as a string value
+ # - msg: logged message.
+ func _error_message(context: String, msg: String):
+ if _log_level >= ESCLogLevel.LOG_ERROR:
+ _log_to_file_message(context, msg, "E")
+ if ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.TERMINATE_ON_ERRORS
+ ):
+ _log_stack_trace_to_file()
+ print_stack()
+ close_logs()
+ super._error_message(context, msg)
+
+
+ ## Close the log file cleanly[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## None.
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func close_logs():
+ print("Closing logs peacefully.")
+ _log_line_to_file("Closing logs peacefully.")
+ log_file.close()
+
+ # Log the log message and context to file
+ func _log_to_file(owner: Object, msg: String, letter: String):
+ var context: String
+ if owner != null:
+ context = owner.get_script().resource_path.get_file()
+ _log_to_file_message(context, msg, letter)
+
+ # Log the log message and context as string to file
+ func _log_to_file_message(context: String, msg: String, letter: String):
+ if log_file.is_open():
+ log_file.store_string(formatted_message(context, msg, letter) + "\n")
+
+ # Log the message line to file
+ func _log_line_to_file(msg: String):
+ if log_file.is_open():
+ log_file.store_string(msg + "\n")
+
+ # Log the stack trace to file
+ func _log_stack_trace_to_file():
+ var frame_number = 0
+ for stack in get_stack().slice(2, get_stack().size()):
+ _log_line_to_file(
+ "Frame %s - %s:%s in function '%s'" % [
+ str(frame_number),
+ stack["source"],
+ stack["line"],
+ stack["function"],
+ ]
+ )
+ frame_number += 1
+
+
+## A simple logger that logs to terminal using debug() function
+class ESCLoggerVerbose extends ESCLoggerBase:
+ # Constructor
+ func _init():
+ pass
+
+ ## Debug log[br]
+ ## [br]
+ ## #### Parameters[br]
+ ## [br]
+ ## | Name | Type | Description | Required? |[br]
+ ## |:-----|:-----|:------------|:----------|[br]
+ ## |owner|`Object`|caller object (usually, `self`)|yes|[br]
+ ## |msg|`String`|logged message.|yes|[br]
+ ## [br]
+ ## #### Returns[br]
+ ## [br]
+ ## Returns nothing.
+ func debug(owner: Object, msg: String):
+ var context = owner.get_script().resource_path.get_file()
+ print(context, ": ", msg)
diff --git a/addons/escoria-core/tools/logging/esc_logger.gd.uid b/addons/escoria-core/tools/logging/esc_logger.gd.uid
new file mode 100644
index 0000000..be8df74
--- /dev/null
+++ b/addons/escoria-core/tools/logging/esc_logger.gd.uid
@@ -0,0 +1 @@
+uid://3rkr84aiwrdt
diff --git a/addons/escoria-core/tools/logging/esc_safe_logging.gd b/addons/escoria-core/tools/logging/esc_safe_logging.gd
new file mode 100644
index 0000000..392d71b
--- /dev/null
+++ b/addons/escoria-core/tools/logging/esc_safe_logging.gd
@@ -0,0 +1,60 @@
+# This static class is primarily for situations where there's a possibility that logging to the
+# console may be done by a tool script, and so won't have access to the autoloader/singleton that is
+# `escoria`.
+class_name ESCSafeLogging
+
+
+const COLOUR_RED = "red"
+const COLOUR_GREEN = "green"
+const COLOUR_YELLOW = "yellow"
+
+
+static func log_level() -> String:
+ return ESCProjectSettingsManager.get_setting(
+ ESCProjectSettingsManager.LOG_LEVEL
+ ).to_upper()
+
+
+static func log_error(owner: Object, message: String) -> void:
+ if Engine.is_editor_hint():
+ print_rich("[color=%s]%s[/color]" % [COLOUR_RED, message])
+ else:
+ escoria.logger.error(owner, message)
+
+
+static func log_warn(owner: Object, message: String) -> void:
+ if Engine.is_editor_hint():
+ print_rich("[color=%s]%s[/color]" % [COLOUR_YELLOW, message])
+ else:
+ escoria.logger.warn(owner, message)
+
+
+static func log_info(owner: Object, message: String) -> void:
+ if Engine.is_editor_hint():
+ print(message)
+ else:
+ escoria.logger.info(owner, message)
+
+
+static func log_debug(owner: Object, message: String) -> void:
+ if Engine.is_editor_hint():
+ print(message)
+ else:
+ escoria.logger.debug(owner, message)
+
+
+static func log_trace(owner: Object, message: String) -> void:
+ if Engine.is_editor_hint():
+ print(message)
+ else:
+ escoria.logger.trace(owner, message)
+
+
+# Doesn't correpond to a logging level for offline logging; meant more for messages conveying a result
+static func log_result(owner: Object, message: String, is_successful_result: bool) -> void:
+ var colour: String = COLOUR_GREEN if is_successful_result else COLOUR_RED
+
+ if Engine.is_editor_hint():
+ print_rich("[color=%s]%s[/color]" % [colour, message])
+ else:
+ escoria.logger.info(owner, message)
diff --git a/addons/escoria-core/tools/logging/esc_safe_logging.gd.uid b/addons/escoria-core/tools/logging/esc_safe_logging.gd.uid
new file mode 100644
index 0000000..4e08a9a
--- /dev/null
+++ b/addons/escoria-core/tools/logging/esc_safe_logging.gd.uid
@@ -0,0 +1 @@
+uid://cqexq6xpcccac
diff --git a/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd b/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd
new file mode 100644
index 0000000..3ed0025
--- /dev/null
+++ b/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd
@@ -0,0 +1,151 @@
+extends ESCScriptAnalyzer
+class_name ESCExitSceneScriptAnalyzer
+
+
+const EXIT_SCENE_EVENT_NAME = "exit_scene"
+const ACCEPT_INPUT_DISABLE_ARGS = ["none", "skip"]
+
+const CHANGE_SCENE_MISSING_MESSAGE = "Event ':exit_scene' is missing 'change_scene' command. Scene might not change as expected. Ignore this warning if the command was omitted on purpose."
+
+const COMMANDS_AFTER_CHANGE_SCENE_MESSAGE = "Event ':exit_scene' may have commands that are expected to be run after a call to 'change_scene'. Such commands will not be executed."
+
+const MISSING_ACCEPT_INPUT_MESSAGE = \
+ "Event ':exit_scene' may allow for the player character to move while exiting and changing the scene." \
+ + " To prevent this, ensure a call to 'accept_input' with an argument evaluating to 'NONE' or 'SKIP' is" \
+ + " made before any calls to 'transition' and/or 'change_scene'."
+
+const BULLET_CHARACTER = "- "
+
+
+# These are vars because they have to be, but should be treated as consts
+var TRANSITION_COMMAND_NAME = TransitionCommand.new().get_command_name()
+var CHANGE_SCENE_COMMAND_NAME = ChangeSceneCommand.new().get_command_name()
+var ACCEPT_INPUT_COMMAND_NAME = AcceptInputCommand.new().get_command_name()
+
+var _has_change_scene_command: bool = false
+
+var _has_commands_after_change_scene_command: bool = false
+
+var _change_scene_token: ESCToken = null
+
+var _accept_input_disable_missing: bool = false
+
+var _accept_input_token: ESCToken = null
+
+
+func analyze(statements: Array) -> void:
+ if not statements is Array:
+ statements = [statements]
+
+ for statement in statements:
+ if statement.get_event_name().to_lower() == EXIT_SCENE_EVENT_NAME:
+ _execute(statement)
+
+ _add_warning_messages()
+
+ break
+
+
+func _add_warning_messages() -> void:
+ var messages: Array[String] = []
+
+ if not _has_change_scene_command:
+ messages.append(BULLET_CHARACTER + CHANGE_SCENE_MISSING_MESSAGE)
+
+ if _accept_input_disable_missing:
+ messages.append(BULLET_CHARACTER + MISSING_ACCEPT_INPUT_MESSAGE)
+
+ if _has_commands_after_change_scene_command:
+ messages.append(BULLET_CHARACTER + COMMANDS_AFTER_CHANGE_SCENE_MESSAGE)
+
+ if not messages.is_empty():
+ _rich_messages.append(ESCSafeLogging.log_warn.bind(self, "\n".join(messages)))
+
+
+func _execute_block(statements: Array, env: ESCEnvironment):
+ var previous_env = _environment
+ var ret = null
+
+ _environment = env
+
+ for stmt in statements:
+ ret = _execute(stmt)
+
+ _environment = previous_env
+
+ return ret
+
+
+# Visitor overriding
+func visit_call_expr(expr: ESCGrammarExprs.Call):
+ var callee = _evaluate(expr.get_callee())
+
+ var args: Array = []
+
+ for arg in expr.get_arguments():
+ arg = _evaluate(arg)
+
+ # "Adapter" for current ESC commands since they don't currently take
+ # ESCObject's (or ESCRoom's) as arguments.
+ if arg is ESCObject or arg is ESCRoom:
+ arg = arg.global_id
+
+ args.append(arg)
+
+ var command_name: String = _get_command_name(callee)
+
+ _has_commands_after_change_scene_command = _has_change_scene_command_in_scope()
+
+ if command_name == CHANGE_SCENE_COMMAND_NAME:
+ _has_change_scene_command = true
+
+ _change_scene_token = expr.get_paren_token()
+ _environment.define(_change_scene_token.get_lexeme(), true)
+
+ # First arg is always the path of the scene to transition to.
+ # Second arg determines whether the transition is automatic, and defaults to true
+ if args.size() > 1:
+ var is_auto_transition = args[1] as bool
+
+ if is_auto_transition != null and not is_auto_transition:
+ _check_for_missing_accept_input_disable()
+ elif command_name == TRANSITION_COMMAND_NAME:
+ _check_for_missing_accept_input_disable()
+ elif command_name == ACCEPT_INPUT_COMMAND_NAME:
+ var first_arg = args[0] as String
+
+ if first_arg != null and first_arg.to_lower() in ACCEPT_INPUT_DISABLE_ARGS:
+ _accept_input_token = expr.get_paren_token()
+ _environment.define(_accept_input_token.get_lexeme(), true)
+
+ return ESCExecution.RC_OK
+
+
+# as a key in the environment/scope to serve as a marker to check concerning whether `accept_input`
+# has previously been called with the appropriate argument
+func _check_for_missing_accept_input_disable() -> void:
+ if _accept_input_token == null:
+ _accept_input_disable_missing = true
+ return
+
+ _accept_input_disable_missing = \
+ not _environment.is_valid_key(_accept_input_token) \
+ or not _environment.get_value(_accept_input_token)
+
+
+func _has_change_scene_command_in_scope() -> bool:
+ if _change_scene_token == null:
+ return false
+
+ return \
+ _environment.is_valid_key(_change_scene_token) \
+ and _environment.get_value(_change_scene_token)
+
+
+func _get_command_name(callee) -> String:
+ # if we don't have a command to run, check against any built-in functions and,
+ # if one is found and is executed, we're done
+ if callee is ESCBaseCommand:
+ return callee.get_command_name()
+
+ return callee
diff --git a/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd.uid b/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd.uid
new file mode 100644
index 0000000..902e168
--- /dev/null
+++ b/addons/escoria-core/tools/script_analysis/analyzers/esc_exit_scene_script_analyzer.gd.uid
@@ -0,0 +1 @@
+uid://0jmaf8d0mjpe
diff --git a/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd b/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd
new file mode 100644
index 0000000..874ffa1
--- /dev/null
+++ b/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd
@@ -0,0 +1,387 @@
+extends RefCounted
+class_name ESCScriptAnalyzer
+
+
+const CURRENT_PLAYER_KEYWORD = "CURRENT_PLAYER"
+
+
+var _rich_messages: Array[Callable] = []
+
+var _globals: ESCEnvironment
+var _environment: ESCEnvironment = _globals
+
+var _locals: Dictionary = {}
+
+var _builtin_functions: Array = [
+ "print"
+]
+
+
+# This must be implemented in child class.
+func analyze(statements: Array) -> void:
+ pass
+
+
+func print_messages() -> void:
+ for print_method in _rich_messages:
+ print_method.call()
+
+
+func _init():
+ var globals: Dictionary = ESCCompiler.load_globals()
+
+ _globals = ESCEnvironment.new()
+
+ for callable in ESCCompiler.load_commands():
+ _globals.define(callable.get_command_name(), callable)
+
+ for key in ESCCompiler.load_globals().keys():
+ _globals.define(key, globals[key])
+
+
+# Visitor implementations
+func visit_block_stmt(stmt: ESCGrammarStmts.Block):
+ var env: ESCEnvironment = ESCEnvironment.new()
+ env.init(_environment)
+
+ return _execute_block(stmt.get_statements(), env)
+
+
+func visit_event_stmt(stmt: ESCGrammarStmts.Event):
+ _execute(stmt.get_body())
+
+
+func visit_expression_stmt(stmt: ESCGrammarStmts.ESCExpression):
+ return _evaluate(stmt.get_expression())
+
+
+func visit_call_expr(expr: ESCGrammarExprs.Call):
+ var callee = _evaluate(expr.get_callee())
+
+ var args: Array = []
+
+ for arg in expr.get_arguments():
+ arg = _evaluate(arg)
+
+ # "Adapter" for current ESC commands since they don't currently take
+ # ESCObject's (or ESCRoom's) as arguments.
+ if arg is ESCObject or arg is ESCRoom:
+ arg = arg.global_id
+
+ args.append(arg)
+
+ # if we don't have a command to run, check against any built-in functions and,
+ # if one is found and is executed, we're done
+ if not callee is ESCBaseCommand:
+ if not callee in _builtin_functions:
+ return 0
+ else:
+ return _handle_builtin_function(callee, args)
+
+ return ESCExecution.RC_OK
+
+
+func _handle_builtin_function(fn_name: String, args: Array) -> int:
+ var rc = ESCExecution.RC_ERROR
+
+ match fn_name:
+ 'print':
+ if args.size() > 1:
+ ESCSafeLogging.log_warn(
+ self,
+ "'print' only takes one argument"
+ )
+
+ rc = ESCExecution.RC_OK
+
+ return rc
+
+
+func visit_if_stmt(stmt: ESCGrammarStmts.If):
+ _execute(stmt.get_then_branch())
+
+ for branch in stmt.get_elif_branches():
+ _execute(branch)
+
+ if stmt.get_else_branch():
+ _execute(stmt.get_else_branch())
+
+ return null
+
+
+func visit_while_stmt(stmt: ESCGrammarStmts.While):
+ _execute(stmt.get_body())
+
+ return null
+
+
+func visit_pass_stmt(stmt: ESCGrammarStmts.Pass):
+ pass
+
+
+func visit_stop_stmt(stmt: ESCGrammarStmts.Stop):
+ return stmt
+
+
+func visit_var_stmt(stmt: ESCGrammarStmts.Var):
+ var value = null
+
+ if stmt.get_initializer():
+ value = _evaluate(stmt.get_initializer())
+
+ _environment.define(stmt.get_name().get_lexeme(), value)
+ return null
+
+
+func visit_global_stmt(stmt: ESCGrammarStmts.Global):
+ var value = null
+
+ if stmt.get_initializer():
+ value = _evaluate(stmt.get_initializer())
+
+ # Only define the global if we haven't already done so; otherwise, just
+ # ignore it
+ if not _globals.get_values().has(stmt.get_name().get_lexeme()):
+ _globals.define(stmt.get_name().get_lexeme(), value)
+
+ return null
+
+
+func visit_dialog_stmt(stmt: ESCGrammarStmts.Dialog):
+ return null
+
+
+func visit_dialog_option_stmt(stmt: ESCGrammarStmts.DialogOption):
+ pass
+
+
+func visit_break_stmt(stmt: ESCGrammarStmts.Break):
+ return stmt
+
+
+func visit_done_stmt(stmt: ESCGrammarStmts.Done):
+ return stmt
+
+
+func visit_assign_expr(expr: ESCGrammarExprs.Assign):
+ var value = _evaluate(expr.get_value())
+
+ var distance: int = _locals.get(expr, -1)
+
+ return value
+
+
+func visit_in_inventory_expr(expr: ESCGrammarExprs.InInventory):
+ var arg = _evaluate(expr.get_identifier())
+
+ if arg is ESCObject:
+ arg = arg.global_id
+
+ return null
+
+
+func visit_is_expr(expr: ESCGrammarExprs.Is):
+ var arg = _evaluate(expr.get_identifier())
+
+ return true
+
+
+func visit_binary_expr(expr: ESCGrammarExprs.Binary):
+ var left_part = _evaluate(expr.get_left())
+ var right_part = _evaluate(expr.get_right())
+
+ match expr.get_operator().get_type():
+ ESCTokenType.TokenType.EQUAL_EQUAL:
+ return _is_equal(left_part, right_part)
+ ESCTokenType.TokenType.BANG_EQUAL:
+ return not _is_equal(left_part, right_part)
+ ESCTokenType.TokenType.GREATER:
+ var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ return left_part > right_part
+ ESCTokenType.TokenType.GREATER_EQUAL:
+ var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ return left_part >= right_part
+ ESCTokenType.TokenType.LESS:
+ var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ return left_part < right_part
+ ESCTokenType.TokenType.LESS_EQUAL:
+ var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ return left_part <= right_part
+ ESCTokenType.TokenType.MINUS:
+ var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ return left_part - right_part
+ ESCTokenType.TokenType.PLUS:
+ var check = _check_are_numbers(left_part, expr.get_operator(), right_part, false)
+
+ if check:
+ return left_part + right_part
+
+ check = _check_at_least_one_string(left_part, right_part)
+
+ if check:
+ return str(left_part) + str(right_part)
+
+ ESCSafeLogging.log_warn(
+ self,
+ "%s: Operands must be numbers or strings." % expr.get_operator().get_lexeme()
+ )
+ ESCTokenType.TokenType.SLASH:
+ var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ return left_part / right_part
+ ESCTokenType.TokenType.STAR:
+ var check = _check_are_numbers(left_part, expr.get_operator(), right_part)
+ return left_part * right_part
+
+ return null
+
+
+func visit_unary_expr(expr: ESCGrammarExprs.Unary):
+ var right_part = _evaluate(expr.get_right())
+
+ match expr.get_operator().get_type():
+ ESCTokenType.TokenType.BANG, ESCTokenType.TokenType.NOT:
+ return not _is_truthy(right_part)
+ ESCTokenType.TokenType.MINUS:
+ var check = _check_is_number(right_part, expr.get_operator())
+ return -right_part
+
+ return null
+
+
+func visit_variable_expr(expr: ESCGrammarExprs.Variable):
+ if expr.get_name().get_lexeme().begins_with("$"):
+ return _look_up_object(expr.get_name())
+
+ if expr.get_name().get_lexeme() in _builtin_functions:
+ return expr.get_name().get_lexeme()
+
+ return look_up_variable(expr.get_name(), expr)
+
+
+func visit_literal_expr(expr: ESCGrammarExprs.Literal):
+ return expr.get_value()
+
+
+func visit_logical_expr(expr: ESCGrammarExprs.Logical):
+ var left = _evaluate(expr.get_left())
+
+ if expr.get_operator().get_type() == ESCTokenType.TokenType.OR:
+ if _is_truthy(left):
+ return left
+ else:
+ if not _is_truthy(left):
+ return left
+
+ return _evaluate(expr.get_right())
+
+
+func visit_grouping_expr(expr: ESCGrammarExprs.Grouping):
+ return _evaluate(expr.get_expression())
+
+
+func resolve(expr: ESCGrammarExpr, depth: int):
+ _locals[expr] = depth
+
+
+# Private methods
+func _look_up_object(name: ESCToken):
+ var global_id: String = name.get_lexeme().substr(1)
+
+ if global_id.to_upper() == CURRENT_PLAYER_KEYWORD:
+ pass
+
+ return _look_up_object_by_global_id(global_id)
+
+
+# We have no way of knowing what the value will actually be since this is called during static
+# analysis. As such, we'll just return null.
+func _look_up_object_by_global_id(global_id: String):
+ return null
+
+
+# We don't care about the values of variables for static analysis purposes.
+func look_up_variable(name: ESCToken, expr: ESCGrammarExpr):
+ var distance: int = _locals[expr] if _locals.has(expr) else -1
+
+ if distance == -1:
+ return _globals.get_value(name)
+ else:
+ return _environment.get_at(distance, name.get_lexeme())
+
+
+func _evaluate(expr: ESCGrammarExpr):
+ var ret = expr.accept(self)
+
+ # TODO: Error handling
+ return ret
+
+
+func _execute(stmt: ESCGrammarStmt):
+ var ret = stmt.accept(self)
+
+ # TODO: Error handling
+ return ret
+
+
+func _execute_block(statements: Array, env: ESCEnvironment):
+ var previous_env = _environment
+ var ret = null
+
+ _environment = env
+
+ for stmt in statements:
+ ret = _execute(stmt)
+
+ _environment = previous_env
+
+ return ret
+
+
+func _is_truthy(value) -> bool:
+ if value == null:
+ return false
+
+ if typeof(value) in [TYPE_INT, TYPE_FLOAT, TYPE_STRING, TYPE_BOOL]:
+ return bool(value) == true
+
+ return false
+
+
+func _is_equal(left_part, right_part) -> bool:
+ if not left_part and not right_part:
+ return true
+
+ if not left_part:
+ return false
+
+ return left_part == right_part
+
+
+func _check_are_numbers(value_1, operator: ESCToken, value_2, strict: bool = true):
+ if typeof(value_1) in [TYPE_INT, TYPE_FLOAT] and typeof(value_2) in [TYPE_INT, TYPE_FLOAT]:
+ return true
+
+ if strict:
+ ESCSafeLogging.log_warn(
+ self,
+ "%s: Operands must be numbers." % operator.get_lexeme()
+ )
+
+ return false
+
+
+func _check_is_number(value, operator: ESCToken, strict: bool = true):
+ if typeof(value) in [TYPE_INT, TYPE_FLOAT]:
+ return true
+
+ if strict:
+ ESCSafeLogging.log_warn(
+ self,
+ "%s: Operand must be number." % operator.get_lexeme()
+ )
+
+ return false
+
+
+func _check_at_least_one_string(value_1, value_2):
+ return typeof(value_1) == TYPE_STRING || typeof(value_2) == TYPE_STRING
diff --git a/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd.uid b/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd.uid
new file mode 100644
index 0000000..4ee126f
--- /dev/null
+++ b/addons/escoria-core/tools/script_analysis/analyzers/esc_script_analyzer.gd.uid
@@ -0,0 +1 @@
+uid://cv8ptpeiod6g2
diff --git a/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd b/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd
new file mode 100644
index 0000000..2447a87
--- /dev/null
+++ b/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd
@@ -0,0 +1,52 @@
+@tool
+extends Node
+class_name ESCAshesAnalyzer
+
+
+# TODO: Update this when we move from the .esc extension for Escoria scripts to .ash
+const FILE_EXTENSION_ASHES = "esc"
+const DIRECTORIES_TO_EXCLUDE = ["addons"]
+const BASE_PROJECT_DIR = "res://"
+
+
+var _compiler: ESCCompiler = ESCCompiler.new()
+
+
+func analyze() -> void:
+ var files: Array[String] = _get_script_files_recursive(BASE_PROJECT_DIR)
+
+ files.sort()
+
+ for file in files:
+ _compiler.load_esc_file(file)
+
+
+func _get_script_files_recursive(path: String, files: Array[String] = []) -> Array[String]:
+ var dir = DirAccess.open(path)
+
+ if dir:
+ dir.list_dir_begin()
+ else:
+ ESCSafeLogging.log_warn(self, "Unable to open '%s'." % path)
+ return files
+
+ var filename: String = dir.get_next()
+
+ while not filename.is_empty():
+ var filename_with_path: String = _make_full_path(dir.get_current_dir(), filename)
+
+ if dir.current_is_dir() and not filename in DIRECTORIES_TO_EXCLUDE:
+ files = _get_script_files_recursive(filename_with_path, files)
+ else:
+ if filename.get_extension() == FILE_EXTENSION_ASHES:
+ files.append(filename_with_path)
+
+ filename = dir.get_next()
+
+ return files
+
+
+func _make_full_path(directory: String, filename: String) -> String:
+ var separator: String = "" if directory.ends_with("/") else "/"
+
+ return directory + separator + filename
diff --git a/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd.uid b/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd.uid
new file mode 100644
index 0000000..207977b
--- /dev/null
+++ b/addons/escoria-core/tools/script_analysis/esc_ashes_analyzer.gd.uid
@@ -0,0 +1 @@
+uid://cs8hmitl4q8ec
diff --git a/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd b/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd
new file mode 100644
index 0000000..53aa7a9
--- /dev/null
+++ b/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd
@@ -0,0 +1,19 @@
+extends RefCounted
+class_name ESCStaticAnalyzers
+
+
+var _analyzers = [
+ ESCExitSceneScriptAnalyzer.new()
+]
+
+var _parsed_statements = []
+
+
+func _init(parsed_statements: Array) -> void:
+ _parsed_statements = parsed_statements
+
+
+func run() -> void:
+ for analyzer in _analyzers:
+ analyzer.analyze(_parsed_statements)
+ analyzer.print_messages()
diff --git a/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd.uid b/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd.uid
new file mode 100644
index 0000000..edbcd9e
--- /dev/null
+++ b/addons/escoria-core/tools/script_analysis/esc_static_analyzers.gd.uid
@@ -0,0 +1 @@
+uid://djdivn8o6k6aq