From e0dd90726828bbc39d9a385ae539bcf11c0e7b97 Mon Sep 17 00:00:00 2001 From: Marvin Ewald Date: Sun, 14 Jul 2024 15:34:44 +0200 Subject: [PATCH] Housekeeping - Move examples into rope_examples/ directory - Add diagnostic list addon - Remove unrelated files from final build package - Update build workflow --- .github/workflows/build.yml | 145 ++++----- .github/workflows/ci.yml | 14 + demo/addons/diagnosticlist/.diagnostic_ignore | 0 demo/addons/diagnosticlist/Diagnostic.gd | 34 ++ .../diagnosticlist/DiagnosticProvider.gd | 280 ++++++++++++++++ demo/addons/diagnosticlist/LSPClient.gd | 307 ++++++++++++++++++ demo/addons/diagnosticlist/Panel.gd | 229 +++++++++++++ demo/addons/diagnosticlist/Panel.tscn | 112 +++++++ demo/addons/diagnosticlist/Settings.gd | 20 ++ demo/addons/diagnosticlist/plugin.cfg | 7 + demo/addons/diagnosticlist/plugin.gd | 29 ++ demo/project.godot | 4 +- demo/{ => rope_examples}/icon.svg | 0 demo/{ => rope_examples}/icon.svg.import | 6 +- demo/{ => rope_examples}/ropesim_demo.tscn | 4 +- 15 files changed, 1097 insertions(+), 94 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 demo/addons/diagnosticlist/.diagnostic_ignore create mode 100644 demo/addons/diagnosticlist/Diagnostic.gd create mode 100644 demo/addons/diagnosticlist/DiagnosticProvider.gd create mode 100644 demo/addons/diagnosticlist/LSPClient.gd create mode 100644 demo/addons/diagnosticlist/Panel.gd create mode 100644 demo/addons/diagnosticlist/Panel.tscn create mode 100644 demo/addons/diagnosticlist/Settings.gd create mode 100644 demo/addons/diagnosticlist/plugin.cfg create mode 100644 demo/addons/diagnosticlist/plugin.gd rename demo/{ => rope_examples}/icon.svg (100%) rename demo/{ => rope_examples}/icon.svg.import (76%) rename demo/{ => rope_examples}/ropesim_demo.tscn (83%) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0e5476..ee6fad2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,97 +1,67 @@ # Adapted from https://github.com/nathanfranke/gdextension/blob/main/.github/workflows/build.yml name: Builds on: - push: - branches: [ "master" ] - pull_request: - branches: [ "master" ] + workflow_dispatch: + inputs: + git-ref: + description: A commit, branch or tag to build. + type: string + required: true + workflow_call: + inputs: + git-ref: + description: A commit, branch or tag to build. + type: string + required: true jobs: build: runs-on: ${{ matrix.runner }} - name: ${{ matrix.name }} + name: ${{ matrix.platform }} ${{ matrix.target }} ${{ matrix.arch }} ${{ matrix.optimize }} strategy: fail-fast: false matrix: + target: [ template_debug, template_release ] + identifier: [ windows, linux, macos, android, android_arm64 ] + include: - # Linux - - identifier: linux-debug - name: Linux Debug - runner: ubuntu-20.04 - target: template_debug - platform: linux - arch: x86_64 + # Defaults + - runner: ubuntu-latest + - optimize: speed + - arch: x86_64 - - identifier: linux-release - name: Linux Release - runner: ubuntu-20.04 - target: template_release - platform: linux - arch: x86_64 + # Debug build settings + - target: template_debug + optimize: speed_trace - # Windows - - identifier: windows-debug - name: Windows Debug - runner: ubuntu-20.04 - target: template_debug + # Map identifiers to platforms + special settings + - identifier: windows platform: windows - arch: x86_64 - - identifier: windows-release - name: Windows Release - runner: ubuntu-20.04 - target: template_release - platform: windows - arch: x86_64 - - # Android Arm64 - - identifier: android-release - name: Android Release Arm64 - runner: ubuntu-20.04 - target: template_release - platform: android - arch: arm64 + - identifier: macos + platform: macos + runner: macos-latest + arch: universal - - identifier: android-debug - name: Android Debug Arm64 - runner: ubuntu-20.04 - target: template_debug - platform: android - arch: arm64 + - identifier: linux + platform: linux - # Android x86_64 - - identifier: android-release - name: Android Release x86_64 - runner: ubuntu-20.04 - target: template_release + - identifier: android platform: android - arch: x86_64 - - identifier: android-debug - name: Android Debug x86_64 - runner: ubuntu-20.04 - target: template_debug + - identifier: android_arm64 platform: android - arch: x86_64 - - # Mac - - identifier: macos-debug - name: macOS (universal) Debug - runner: macos-latest - target: template_debug - platform: macos - arch: universal - - - identifier: macos-release - name: macOS (universal) Release - runner: macos-latest - target: template_release - platform: macos - arch: universal + arch: arm64 steps: + - name: Check settings + if: ${{ matrix.platform == '' || matrix.target == '' || matrix.runner == '' || matrix.optimize == '' || matrix.arch == ''}} + run: | + echo "One of the matrix values is not set." + exit 1 + - name: (Windows) Install mingw64 - if: ${{ startsWith(matrix.identifier, 'windows-') }} + if: ${{ matrix.platform == 'windows' }} shell: sh run: | sudo apt-get install mingw-w64 @@ -99,19 +69,19 @@ jobs: sudo update-alternatives --set x86_64-w64-mingw32-g++ /usr/bin/x86_64-w64-mingw32-g++-posix - name: (Android) Install JDK 17 - if: ${{ startsWith(matrix.identifier, 'android-') }} + if: ${{ matrix.platform == 'android' }} uses: actions/setup-java@v3 with: java-version: 17 distribution: temurin - name: (Android) Install Android SDK - if: ${{ startsWith(matrix.identifier, 'android-') }} + if: ${{ matrix.platform == 'android' }} uses: android-actions/setup-android@v3 # From Godot docs, might not be necessary. #- name: (Android) Install Android Tools - # if: ${{ startsWith(matrix.identifier, 'android-') }} + # if: ${{ matrix.platform == 'android' }} # shell: sh # run: | # "$ANDROID_SDK_ROOT"/cmdline-tools/latest/bin/sdkmanager --sdk_root="$ANDROID_SDK_ROOT" "platform-tools" "build-tools;30.0.3" "platforms;android-29" "cmdline-tools;latest" "cmake;3.10.2.4988404" @@ -124,19 +94,20 @@ jobs: link-to-sdk: true - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 - name: Set up SCons shell: bash run: | python -c "import sys; print(sys.version)" - python -m pip install scons + python -m pip install scons==4.7.0 scons --version - name: Checkout project - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive + ref: ${{ inputs.git-ref }} # TODO: Cache doesn't work yet. SCons rebuilds the objects even if they already exist. Could be caused by modification dates or extension_api.json. # fetch-depth: 0 May be needed for cache. See: . @@ -147,11 +118,11 @@ jobs: # ${{ github.workspace }}/.scons-cache/ # ${{ github.workspace }}/**/.sconsign.dblite # ${{ github.workspace }}/godot-cpp/gen/ -# key: ${{ matrix.identifier }}-${{ github.ref }}-${{ github.sha }} +# key: ${{ matrix.platform }}-${{ github.ref }}-${{ github.sha }} # restore-keys: | -# ${{ matrix.identifier }}-${{ github.ref }}-${{ github.sha }} -# ${{ matrix.identifier }}-${{ github.ref }} -# ${{ matrix.identifier }} +# ${{ matrix.platform }}-${{ github.ref }}-${{ github.sha }} +# ${{ matrix.platform }}-${{ github.ref }} +# ${{ matrix.platform }} - name: Compile extension shell: sh @@ -159,18 +130,18 @@ jobs: # SCONS_CACHE: '${{ github.workspace }}/.scons-cache/' # SCONS_CACHE_LIMIT: 8192 run: | - scons target='${{ matrix.target }}' platform='${{ matrix.platform }}' arch='${{ matrix.arch }}' -j2 + scons target='${{ matrix.target }}' platform='${{ matrix.platform }}' arch='${{ matrix.arch }}' optimize=${{ matrix.optimize }} -j2 ls -l demo/addons/*/bin/ - - name: Copy extra files to addon + - name: Prepare files for publish shell: sh run: | - for addon in ${{ github.workspace }}/demo/addons/*/; do - cp -n '${{ github.workspace }}/README.md' '${{ github.workspace }}/LICENSE' "$addon" - done + cp -n '${{ github.workspace }}/README.md' '${{ github.workspace }}/LICENSE' '${{ github.workspace }}/demo/addons/ropesim/' + rm -rf '${{ github.workspace }}/demo/addons/diagnosticlist' + rm '${{ github.workspace }}/demo/project.godot' - name: Upload artifact - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: ${{ github.event.repository.name }} path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c4487fa --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,14 @@ +name: CI +on: + push: + # branches: [ master ] + pull_request: + # branches: [ master ] + +jobs: + ci: + name: "CI" + uses: ./.github/workflows/build.yml + with: + git-ref: ${{ github.ref }} + diff --git a/demo/addons/diagnosticlist/.diagnostic_ignore b/demo/addons/diagnosticlist/.diagnostic_ignore new file mode 100644 index 0000000..e69de29 diff --git a/demo/addons/diagnosticlist/Diagnostic.gd b/demo/addons/diagnosticlist/Diagnostic.gd new file mode 100644 index 0000000..140b239 --- /dev/null +++ b/demo/addons/diagnosticlist/Diagnostic.gd @@ -0,0 +1,34 @@ +extends RefCounted +class_name DiagnosticList_Diagnostic + + +class Pack extends RefCounted: + var res_uri: StringName + var diagnostics: Array[DiagnosticList_Diagnostic] + + +enum Severity { + Error, + Warning, + Info, + Hint, +} + + +## Represents the file path as res:// path +@export var res_uri: StringName +@export var line_start: int # zero-based +@export var column_start: int # zero-based +@export var severity: Severity +@export var message: String + +var _filename: StringName + + +## Returns the filename, i.e. the last component of the path including the extension. +func get_filename() -> StringName: + if _filename.is_empty(): + _filename = StringName(res_uri.get_file()) + return _filename + + diff --git a/demo/addons/diagnosticlist/DiagnosticProvider.gd b/demo/addons/diagnosticlist/DiagnosticProvider.gd new file mode 100644 index 0000000..00bb2d3 --- /dev/null +++ b/demo/addons/diagnosticlist/DiagnosticProvider.gd @@ -0,0 +1,280 @@ +extends RefCounted +class_name DiagnosticList_DiagnosticProvider + +const IGNORE_FILES: Array[String] = [ + ".gdignore", + ".diagnostic_ignore", +] + +## Triggered when new diagnostics for a file arrived. +signal on_publish_diagnostics(diagnostics: DiagnosticList_Diagnostic.Pack) + +## Triggered when all outstanding diagnostics have been received. +signal on_diagnostics_finished + +## Triggered when sources have changed and a diagnostic update is available. +signal on_diagnostics_available + +## Triggered at the same time as on_publish_diagnostics but provides status information +signal on_update_progress(num_remaining: int, num_all: int) + + +class FileCache extends RefCounted: + var content: String = "" + var last_modified: int = -1 + + +var _diagnostics: Array[DiagnosticList_Diagnostic] = [] +var _client: DiagnosticList_LSPClient +var _script_paths: Array[String] = [] +var _counts: Array[int] = [ 0, 0, 0, 0 ] +var _num_outstanding: int = 0 +var _dirty: bool = true +var _refresh_time: int = 0 +var _file_cache := {} # Dict[String, FileCache] +var _additional_ignore_dirs: Array[String] = [] + + +func _init(client: DiagnosticList_LSPClient) -> void: + _client = client + _client.on_publish_diagnostics.connect(_on_publish_diagnostics) + _client.on_jsonrpc_error.connect(_on_jsonrpc_error) + + if Engine.is_editor_hint(): + var fs := EditorInterface.get_resource_filesystem() + + # Triggered when saving, removing and moving files. + # Also triggers whenever the user is typing or saving in an external editor using LSP. + fs.script_classes_updated.connect(_on_script_classes_updated) + + # Triggered when the Godot window receives focus and when moving or deleting files + fs.sources_changed.connect(_on_sources_changed) + + +func is_updating() -> bool: + return _num_outstanding > 0 + + +func set_additional_ignore_dirs(dirs: Array[String]) -> void: + _additional_ignore_dirs = dirs + + +## Refresh diagnostics for all scripts. +## Returns true on success or false when there are no updates available or when another update is +## still in progress. +func refresh_diagnostics(force: bool = false) -> bool: + # NOTE: We always have to do a full update, because a change in one file can cause errors in + # other files, e.g. renaming an identifier. + + # Still waiting for results from the last call + if _num_outstanding > 0: + _dirty = false # Dirty will be reset anyway after update has been finished + return false + + # Nothing changed -> Nothing to do + if not force and not _dirty: + return false + + var files_modified := refresh_file_list() + + # No files have actually been modified -> Nothing to do + if not force and not files_modified: + _dirty = false + return false + + _diagnostics.clear() + _counts = [ 0, 0, 0, 0 ] + _num_outstanding = len(_script_paths) + _refresh_time = Time.get_ticks_usec() + + if _num_outstanding > 0: + for file in _script_paths: + _client.update_diagnostics(file, _file_cache[file].content) + else: + call_deferred("_finish_update") + + # NOTE: Do not reset _dirty here, because it will be resetted anyway in _finish_update() after + # all diagnostics have been received. + return true + + +## Rescan the project for script files +## Returns true when there have been changes, otherwise false. +func refresh_file_list() -> bool: + var ignore_dirs: Array[String] = [] + ignore_dirs.assign(_additional_ignore_dirs.duplicate()) + + if ProjectSettings.get("debug/gdscript/warnings/exclude_addons"): + ignore_dirs.push_back("res://addons" ) + + _script_paths = _gather_scripts("res://", ignore_dirs) + + var modified: bool = false + + # Update cache + for path in _script_paths: + var cache: FileCache = _file_cache.get(path) + var last_modified: int = FileAccess.get_modified_time(path) + + if not cache: + cache = FileCache.new() + _file_cache[path] = cache + # The next condition will also inevitably be true + + if cache.last_modified != last_modified: + cache.last_modified = last_modified + cache.content = FileAccess.get_file_as_string(path) + modified = true + + # One or more files were deleted + if _file_cache.size() > _script_paths.size(): + modified = true + + # TODO: Could be more efficient, but happens not so often + for path: String in _file_cache.keys(): + if not _script_paths.has(path): + _file_cache.erase(path) + + return modified + + +## Get the amount of diagnostics of a given severity. +func get_diagnostic_count(severity: DiagnosticList_Diagnostic.Severity) -> int: + return _counts[severity] + + +## Returns all diagnostics of the project +func get_diagnostics() -> Array[DiagnosticList_Diagnostic]: + return _diagnostics.duplicate() + + +## Returns the amount of microseconds between requesting the last diagnostic update and the last +## diagnostic being delivered. +func get_refresh_time_usec() -> int: + return _refresh_time + + +func are_diagnostics_available() -> bool: + return _dirty + + +func get_lsp_client() -> DiagnosticList_LSPClient: + return _client + + +func _finish_update() -> void: + # NOTE: When parsing scripts using LSP, the script_classes_updated signal will be fired multiple + # times by the engine without any actual changes. + # Hence, to prevent false positive dirty flags, reset _dirty back to false when the diagnsotic + # update is finished. + # FIXME: It might happen that the user makes a change while diagnostics are still refreshing, + # In this case, the dirty flag would still be resetted, even though it shouldn't. + # This is essentially a tradeoff between efficiency and accuracy. + # As I find this exact scenario unlikely to occur regularily, I prefer the more efficient + # implementation of updating less often. + _dirty = false + + _refresh_time = Time.get_ticks_usec() - _refresh_time + on_diagnostics_finished.emit() + + +func _mark_dirty() -> void: + if not _dirty: + # If an update is currently in progress, don't do anything. _dirty will be reset anyway in + # _finish_update(). + if _num_outstanding > 0: + return + + _dirty = true + on_diagnostics_available.emit() + + +func _on_sources_changed(_exist: bool) -> void: + _mark_dirty() + + +func _on_script_classes_updated() -> void: + # NOTE: When using an external editor over LSP, the engine will constantly emit the + # script_classes_updated signal whenever the user is typing. + # In those cases it is useless to perform an update, as nothing actually changed. + # We also cannot safely determine when the user has saved a file except by comparing file + # modification timestamps. + # + # However, whenever the Godot window receives focus, a sources_changed signal is fired. + # + # Hence, to prevent unnecessary amounts of updates, check whether the Godot window has focus and + # if it doesn't, ignore the signal, as the user is likely typing in an external editor. + # + # When using the internal editor, script_classes_updated will only be fired upon saving. + # Hence, when the signal arrives and the Godot window has focus, an update should be performed. + if EditorInterface.get_base_control().get_window().has_focus(): + _mark_dirty() + + +func _on_publish_diagnostics(diagnostics: DiagnosticList_Diagnostic.Pack) -> void: + # Ignore unexpected diagnostic updates + if _num_outstanding == 0: + _client.log_error("Received diagnostics without having them requested before") + return + + _diagnostics.append_array(diagnostics.diagnostics) + + # Increase new diagnostic counts + for diag in diagnostics.diagnostics: + _counts[diag.severity] += 1 + + on_publish_diagnostics.emit(diagnostics) + + _update_outstanding_counter() + + +func _on_jsonrpc_error(_error: Dictionary) -> void: + # In case of error, it is likely something failed for a specific file. + # To prevent the plugin from effectively freezing by waiting forever for results that will never + # arrive, just update the counter as if diagnostics arrived. + if _num_outstanding > 0: + _update_outstanding_counter() + + +func _update_outstanding_counter() -> void: + _num_outstanding -= 1 + on_update_progress.emit(_num_outstanding, len(_script_paths)) + + if _num_outstanding == 0: + _finish_update() + + +# TODO: Consider making ignore_dirs a set if there will ever be more than one entry +func _gather_scripts(searchpath: String, ignore_dirs: Array[String]) -> Array[String]: + var root := DirAccess.open(searchpath) + + if not root: + push_error("Failed to open directory: ", searchpath) + + var paths: Array[String] = [] + + for ignore_file in IGNORE_FILES: + if root.file_exists(ignore_file): + return paths + + root.include_navigational = false + root.list_dir_begin() + + var fname := root.get_next() + + var root_path := root.get_current_dir() + + while not fname.is_empty(): + var path := root_path.path_join(fname) + + if root.current_is_dir(): + if not ignore_dirs.has(path): + paths.append_array(_gather_scripts(path, ignore_dirs)) + elif fname.ends_with(".gd"): + paths.append(path) + + fname = root.get_next() + + root.list_dir_end() + + return paths diff --git a/demo/addons/diagnosticlist/LSPClient.gd b/demo/addons/diagnosticlist/LSPClient.gd new file mode 100644 index 0000000..c199542 --- /dev/null +++ b/demo/addons/diagnosticlist/LSPClient.gd @@ -0,0 +1,307 @@ +extends RefCounted +class_name DiagnosticList_LSPClient + +## Triggered when connected to the LS. +signal on_connected + +## Triggered when LSP has been initialized +signal on_initialized + +## Triggered when new diagnostics for a file arrived. +signal on_publish_diagnostics(diagnostics: DiagnosticList_Diagnostic.Pack) + +## Triggered when the LSP server returns an unexpected JSON-RPC error +signal on_jsonrpc_error(error: Dictionary) + + +const ENABLE_DEBUG_LOG: bool = false +const TICK_INTERVAL_SECONDS_MIN: float = 0.05 +const TICK_INTERVAL_SECONDS_MAX: float = 30.0 + +# Godot LS expects a leading "/" in URIs. +# On Windows, where absolute paths start with C:, it must be added manually. +var URI_PREFIX := "file:///" if OS.get_name() == "Windows" else "file://" + +var _jsonrpc := JSONRPC.new() +var _client := StreamPeerTCP.new() +var _id: int = 0 +var _timer: Timer +var _lsp_project_path: String = "" # Absolute project path reported by LS + + +func _init(root: Node) -> void: + # NOTE: Since this is a RefCounted, it does not have access to the tree, hence plugin.gd passes + # the plugin root node. + _timer = Timer.new() + _timer.wait_time = TICK_INTERVAL_SECONDS_MIN + _timer.autostart = false + _timer.one_shot = false + _timer.timeout.connect(_on_tick) + root.add_child(_timer) + + +func disconnect_lsp() -> void: + log_debug("Disconnecting from LSP") + _timer.stop() + _client.disconnect_from_host() + + +## Connect to the LSP server using host and port specified in the editor config. +func connect_lsp() -> bool: + var settings := EditorInterface.get_editor_settings() + var port: int = settings.get("network/language_server/remote_port") + var host: String = settings.get("network/language_server/remote_host") + return connect_lsp_at(host, port) + + +## Connect to the LSP server at the given host and port. +func connect_lsp_at(host: String, port: int) -> bool: + var err := _client.connect_to_host(host, port) + + if err != OK: + log_error("Failed to connect to LSP server: %s" % err) + return false + + # Enable processing + _id = 0 + _timer.start() + _reset_tick_interval() + + return true + + +func is_lsp_connected() -> bool: + return _client.get_status() == StreamPeerTCP.STATUS_CONNECTED + + +## Sends a didOpen/didClose notification request, which results in publishDiagnostics reply being sent. +## Expects "res_path" to be proper res:// uri. +func update_diagnostics(res_path: String, content: String) -> void: + var uri := _res_path_to_lsp_uri(res_path) + + _send_notification("textDocument/didOpen", { + "textDocument": { + "uri": uri, + "text": content, + "languageId": "gdscript", # Unused by Godot LSP + "version": 0, # Unused by Godot LSP + } + }) + + # Technically, the Godot LS does nothing on didClose, but send it anyway in case it changes in the future. + _send_notification("textDocument/didClose", { + "textDocument": { + "uri": uri + } + }) + + +## Returns the absolute project path as reported by the LS. +func get_project_path() -> String: + return _lsp_project_path + + +func _reset_tick_interval() -> void: + _timer.start(TICK_INTERVAL_SECONDS_MIN) + + +func _update_tick_interval() -> void: + # Double the tick interval to gradiually reduce computation time when not in use. + _timer.wait_time = minf(_timer.wait_time * 2, TICK_INTERVAL_SECONDS_MAX) + + +func _on_tick() -> void: + if not _update_status(): + disconnect_lsp() + return + + _update_tick_interval() + + while _client.get_available_bytes(): + var json := _read_data() + + if json: + log_debug("Received message:\n%s" % json) + + _handle_response(json) + _reset_tick_interval() # Reset timer interval whenever data arrived as there will likely be more data coming + + +## Updates the current socket status and returns true when the main loop should continue. +func _update_status() -> bool: + var last_status := _client.get_status() + + _client.poll() + + var status := _client.get_status() + + match status: + StreamPeerTCP.STATUS_NONE: + return false + StreamPeerTCP.STATUS_ERROR: + log_error("StreamPeerTCP error") + return false + StreamPeerTCP.STATUS_CONNECTING: + pass + StreamPeerTCP.STATUS_CONNECTED: + # First time connected -> run initialization + if last_status != status: + log_debug("Connected to LSP") + on_connected.emit() + _initialize() + + return true + + +func _read_data() -> Dictionary: + # NOTE: + # At the moment, Godot only ever transmits headers with a single Content-Length field and + # likewise expects headers with only one field (see gdscript_language_protocol.cpp, line 61). + # Hence, the following also assumes there is only the Content-Length field in the header. + # If Godot ever starts sending additional fields, this will break. + + var header := _read_header().strip_edges() + var content_length := int(header.substr(len("Content-Length"))) + var content := _read_content(content_length) + var json: Dictionary = JSON.parse_string(content) + + if not json: + log_error("Failed to parse JSON: %s" % content) + return {} + + return json + + +func _read_content(length: int) -> String: + var data := _client.get_data(length) + + if data[0] != OK: + log_error("Failed to read content: %s" % error_string(data[0])) + return "" + else: + var buf: PackedByteArray = data[1] + return buf.get_string_from_utf8() + + +func _read_header() -> String: + var buf := PackedByteArray() + var char_r := "\r".unicode_at(0) + var char_n := "\n".unicode_at(0) + + while true: + var data := _client.get_data(1) + + if data[0] != OK: + log_error("Failed to read header: %s" % error_string(data[0])) + return "" + else: + buf.push_back(data[1][0]) + + var bufsize := buf.size() + + if bufsize >= 4 \ + and buf[bufsize - 1] == char_n \ + and buf[bufsize - 2] == char_r \ + and buf[bufsize - 3] == char_n \ + and buf[bufsize - 4] == char_r: + return buf.get_string_from_ascii() + + # This should never happen but the GDScript compiler complains "not all code paths return a value" + return "" + + +func _handle_response(json: Dictionary) -> void: + var method: String = json.get("method", "") + + match method: + # Diagnostics received + "textDocument/publishDiagnostics": + on_publish_diagnostics.emit(_parse_diagnostics(json["params"])) + return + + # Project path + "gdscript_client/changeWorkspace": + _lsp_project_path = str(json["params"]["path"]).simplify_path() + return + + # Initialization response + if json.get("id") == 0: + _send_notification("initialized", {}) + on_initialized.emit() + return + + # JSON-RPC error + if json.has("error"): + var error: Dictionary = json["error"] + log_error("JSON-RPC Error: %s" % error) + log_error("This is likely a bug in the plugin. Consider submitting a bug report on GitHub.") + on_jsonrpc_error.emit(error) + + +## Parses the diagnostic information according to the LSP specification. +## https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#publishDiagnosticsParams +func _parse_diagnostics(params: Dictionary) -> DiagnosticList_Diagnostic.Pack: + var result := DiagnosticList_Diagnostic.Pack.new() + result.res_uri = StringName(_lsp_uri_to_res_path(str(params["uri"]))) + + var diagnostics: Array[Dictionary] = [] + diagnostics.assign(params["diagnostics"]) + + for diag in diagnostics: + var range_start: Dictionary = diag["range"]["start"] + var entry := DiagnosticList_Diagnostic.new() + entry.res_uri = result.res_uri + entry.message = diag["message"] + entry.severity = (int(diag["severity"]) - 1) as DiagnosticList_Diagnostic.Severity # One-based in LSP, hence convert to the zero-based enum value + entry.line_start = int(range_start["line"]) + entry.column_start = int(range_start["character"]) + result.diagnostics.append(entry) + + return result + + +func _send_request(method: String, params: Dictionary) -> int: + _send(_jsonrpc.make_request(method, params, _id)) + _id += 1 + return _id - 1 + + +func _send_notification(method: String, params: Dictionary) -> void: + _send(_jsonrpc.make_notification(method, params)) + + +func _send(json: Dictionary) -> void: + var content := JSON.stringify(json, "", false) + var content_bytes := content.to_utf8_buffer() + var header := "Content-Length: %s\r\n\r\n" % len(content_bytes) + var header_bytes := header.to_ascii_buffer() + log_debug("Sending message (length: %s): %s" % [ len(content_bytes), content ]) + _client.put_data(header_bytes + content_bytes) + _reset_tick_interval() # Reset the timer interval because we are expecting a response + + +func _initialize() -> void: + _send_request("initialize", { + "processId": null, + "capabilities": { + "textDocument": { + "publishDiagnostics": {}, + }, + }, + }) + + +func _res_path_to_lsp_uri(res_path: String) -> String: + return URI_PREFIX + ProjectSettings.globalize_path(res_path).simplify_path() + + +func _lsp_uri_to_res_path(lsp_uri: String) -> String: + return ProjectSettings.localize_path(lsp_uri.replace(URI_PREFIX, "")) + + +func log_debug(text: String) -> void: + if ENABLE_DEBUG_LOG: + print("[DiagnosticList] ", text) + +func log_error(text: String) -> void: + push_error("[DiagnosticList] ", text) diff --git a/demo/addons/diagnosticlist/Panel.gd b/demo/addons/diagnosticlist/Panel.gd new file mode 100644 index 0000000..ca58824 --- /dev/null +++ b/demo/addons/diagnosticlist/Panel.gd @@ -0,0 +1,229 @@ +@tool +extends Control +class_name DiagnosticList_Panel + +class DiagnosticSeveritySettings extends RefCounted: + var text: String + var icon: Texture2D + var color: Color + + func _init(text_: String, icon_id: StringName, color_id: StringName) -> void: + self.text = text_ + self.icon = EditorInterface.get_editor_theme().get_icon(icon_id, &"EditorIcons") + self.color = EditorInterface.get_editor_theme().get_color(color_id, &"Editor") + + +@onready var _btn_refresh_errors: Button = %"btn_refresh_errors" +@onready var _error_list_tree: Tree = %"error_tree_list" +@onready var _cb_auto_refresh: CheckBox = %"cb_auto_refresh" +@onready var _cb_group_by_file: CheckBox = %"cb_group_by_file" +@onready var _label_refresh_time: Label = %"label_refresh_time" +@onready var _multiple_instances_alert: AcceptDialog = %"multiple_instances_alert" + +# This array will be filled according to each severity type to allow direct indexing +@onready var _filter_buttons: Array[Button] = [ + %"btn_filter_errors", + %"btn_filter_warnings", + %"btn_filter_infos", + %"btn_filter_hints", +] + +# This array will be filled according to each severity type to allow direct indexing +@onready var _severity_settings: Array[DiagnosticSeveritySettings] = [ + DiagnosticSeveritySettings.new("Error", &"StatusError", &"error_color"), + DiagnosticSeveritySettings.new("Warning", &"StatusWarning", &"warning_color"), + DiagnosticSeveritySettings.new("Info", &"Popup", &"font_color"), + DiagnosticSeveritySettings.new("Hint", &"Info", &"font_color"), +] + +@onready var _script_icon: Texture2D = get_theme_icon(&"Script", &"EditorIcons") + +var _provider: DiagnosticList_DiagnosticProvider + + +## Alternative to _ready(). This will be called by plugin.gd to ensure the code in here only runs +## when this script is loaded as part of the plugin and not while editing the scene. +func _plugin_ready() -> void: + for i in len(_filter_buttons): + var btn: Button = _filter_buttons[i] + var severity := _severity_settings[i] + btn.icon = severity.icon + + # These kinds of severities do not exist yet in Godot LSP, so hide them for now. + _filter_buttons[DiagnosticList_Diagnostic.Severity.Info].hide() + _filter_buttons[DiagnosticList_Diagnostic.Severity.Hint].hide() + + _cb_auto_refresh.button_pressed = DiagnosticList_Settings.get_auto_refresh() + + _error_list_tree.columns = 3 + _error_list_tree.set_column_title(0, "Message") + _error_list_tree.set_column_title(1, "File") + _error_list_tree.set_column_title(2, "Line") + _error_list_tree.set_column_title_alignment(0, HORIZONTAL_ALIGNMENT_LEFT) + _error_list_tree.set_column_title_alignment(1, HORIZONTAL_ALIGNMENT_LEFT) + _error_list_tree.set_column_title_alignment(2, HORIZONTAL_ALIGNMENT_LEFT) + + var line_column_size := _error_list_tree.get_theme_font("font").get_string_size( + "Line 0000", HORIZONTAL_ALIGNMENT_LEFT, -1, _error_list_tree.get_theme_font_size("font_size")) + + _error_list_tree.set_column_custom_minimum_width(0, 0) + _error_list_tree.set_column_custom_minimum_width(1, 0) + _error_list_tree.set_column_custom_minimum_width(2, int(line_column_size.x)) + + _error_list_tree.set_column_expand(0, true) + _error_list_tree.set_column_expand(1, true) + _error_list_tree.set_column_expand(2, false) + _error_list_tree.set_column_clip_content(0, true) + _error_list_tree.set_column_clip_content(1, true) + _error_list_tree.set_column_clip_content(2, false) + _error_list_tree.set_column_expand_ratio(0, 4) + + _multiple_instances_alert.add_button("More Information", true, "https://github.com/mphe/godot-diagnostic-list#does-not-work-correctly-with-multiple-godot-instances") + _multiple_instances_alert.custom_action.connect(func(action: StringName) -> void: OS.shell_open(action)) + _multiple_instances_alert.visible = false + + +## Called by plugin.gd when the LSPClient is ready +func start(provider: DiagnosticList_DiagnosticProvider) -> void: + _provider = provider + + # Now that it is safe to do stuff, connect all the signals + _provider.on_diagnostics_finished.connect(_on_diagnostics_finished) + _provider.on_update_progress.connect(_on_update_progress) + + _btn_refresh_errors.pressed.connect(_on_force_refresh) + _cb_group_by_file.toggled.connect(_on_group_by_file_toggled) + _cb_auto_refresh.toggled.connect(_on_auto_refresh_toggled) + _error_list_tree.item_activated.connect(_on_item_activated) + + for btn in _filter_buttons: + btn.toggled.connect(_on_filter_toggled) + + # Start checking + _set_status_string("", false) + _start_stop_auto_refresh() + + # If connected to a LS of a different Godot instance, show a warning + if provider.get_lsp_client().get_project_path() != ProjectSettings.globalize_path("res://").simplify_path(): + _multiple_instances_alert.popup_centered() + + +func refresh() -> void: + # NOTE: This list is sorted by file name as LSP publishes diagnostics per file + # This is important as the group-by-file implementation relies on it. + var diagnostics := _provider.get_diagnostics() + var group_by_file := _cb_group_by_file.button_pressed + + if not group_by_file: + diagnostics.sort_custom(_sort_by_severity) + + # Show refresh time + _set_status_string("Up-to-date", true) + + # Clear tree + _error_list_tree.clear() + _error_list_tree.create_item() + + # Create diagnostics + var last_uri: StringName + var parent: TreeItem = null + + for diag in diagnostics: + if not _filter_buttons[diag.severity].button_pressed: + continue + + # If grouping by file, create header entries if necessary + if group_by_file and diag.res_uri != last_uri: + last_uri = diag.res_uri + parent = _error_list_tree.create_item() + parent.set_text(0, diag.res_uri) + parent.set_icon(0, _script_icon) + parent.set_metadata(0, diag) + + _create_entry(diag, parent) + + # Update diagnostic counts + for i in len(_filter_buttons): + _filter_buttons[i].text = str(_provider.get_diagnostic_count(i)) + + +func _set_status_string(text: String, with_last_time: bool) -> void: + if with_last_time: + _label_refresh_time.text = "%s\n%.2f s" % [ text, _provider.get_refresh_time_usec() / 1000000.0 ] + else: + _label_refresh_time.text = text + + +func _sort_by_severity(a: DiagnosticList_Diagnostic, b: DiagnosticList_Diagnostic) -> bool: + if a.severity == b.severity: + return a.res_uri < b.res_uri + return a.severity < b.severity + + +func _create_entry(diag: DiagnosticList_Diagnostic, parent: TreeItem) -> void: + var entry: TreeItem = _error_list_tree.create_item(parent) + var severity_setting := _severity_settings[diag.severity] + # entry.set_custom_color(0, theme.color) + entry.set_text(0, diag.message) + entry.set_icon(0, severity_setting.icon) + entry.set_text(1, diag.get_filename()) + entry.set_tooltip_text(1, diag.res_uri) + # entry.set_text(2, "Line " + str(diag.line_start)) + entry.set_text(2, str(diag.line_start + 1)) + entry.set_metadata(0, diag) # Meta data is used in _on_item_activated to open the respective script + + +func _update_diagnostics(force: bool) -> void: + if _provider.is_updating() or _provider.refresh_diagnostics(force): + _set_status_string("Updating...", false) + else: + _set_status_string("Up-to-date", true) + + +func _start_stop_auto_refresh() -> void: + if _cb_auto_refresh.button_pressed: + visibility_changed.connect(_on_auto_update) + _provider.on_diagnostics_available.connect(_on_auto_update) + _on_auto_update() # Also trigger an update immediately + else: + visibility_changed.disconnect(_on_auto_update) + _provider.on_diagnostics_available.disconnect(_on_auto_update) + + +func _on_item_activated() -> void: + var selected: TreeItem = _error_list_tree.get_selected() + var diagnostic: DiagnosticList_Diagnostic = selected.get_metadata(0) + + # NOTE: Lines and columns are zero-based in LSP, but Godot expects one-based values + EditorInterface.edit_script(load(str(diagnostic.res_uri)), diagnostic.line_start + 1, diagnostic.column_start + 1) + + if not EditorInterface.get_editor_settings().get("text_editor/external/use_external_editor"): + EditorInterface.set_main_screen_editor("Script") + + +func _on_force_refresh() -> void: + _update_diagnostics(true) + + +func _on_auto_refresh_toggled(toggled_on: bool) -> void: + DiagnosticList_Settings.set_auto_refresh(toggled_on) + _start_stop_auto_refresh() + + +func _on_auto_update() -> void: + if is_visible_in_tree(): + _update_diagnostics(false) + + +func _on_update_progress(num_remaining: int, num_all: int) -> void: + _set_status_string("Updating...\n(%d/%d)" % [ num_all - num_remaining, num_all ], false) + + +func _on_diagnostics_finished() -> void: + refresh() + +func _on_filter_toggled(_toggled_on: bool) -> void: + refresh() + +func _on_group_by_file_toggled(_toggled_on: bool) -> void: + refresh() diff --git a/demo/addons/diagnosticlist/Panel.tscn b/demo/addons/diagnosticlist/Panel.tscn new file mode 100644 index 0000000..6ceb05f --- /dev/null +++ b/demo/addons/diagnosticlist/Panel.tscn @@ -0,0 +1,112 @@ +[gd_scene load_steps=2 format=3 uid="uid://tsfsnxbfcax6"] + +[ext_resource type="Script" path="res://addons/diagnosticlist/Panel.gd" id="1_fewy8"] + +[node name="DiagnosticsPanel" type="Control"] +custom_minimum_size = Vector2(250, 225) +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_fewy8") + +[node name="HBoxContainer" type="HBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="error_tree_list" type="Tree" parent="HBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +columns = 3 +column_titles_visible = true +hide_root = true +select_mode = 1 +scroll_horizontal_enabled = false + +[node name="VBoxContainer" type="VBoxContainer" parent="HBoxContainer"] +layout_mode = 2 + +[node name="cb_auto_refresh" type="CheckBox" parent="HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +button_pressed = true +text = "Auto-Refresh" + +[node name="btn_refresh_errors" type="Button" parent="HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +auto_translate = false +text = "Refresh" + +[node name="HSeparator" type="HSeparator" parent="HBoxContainer/VBoxContainer"] +layout_mode = 2 + +[node name="cb_group_by_file" type="CheckBox" parent="HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +text = "Group by file" + +[node name="btn_filter_errors" type="Button" parent="HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +focus_mode = 0 +theme_type_variation = &"EditorLogFilterButton" +toggle_mode = true +button_pressed = true +text = "0" + +[node name="btn_filter_warnings" type="Button" parent="HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +layout_mode = 2 +focus_mode = 0 +theme_type_variation = &"EditorLogFilterButton" +toggle_mode = true +button_pressed = true +text = "0" + +[node name="btn_filter_infos" type="Button" parent="HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +focus_mode = 0 +theme_type_variation = &"EditorLogFilterButton" +toggle_mode = true +button_pressed = true +text = "0" + +[node name="btn_filter_hints" type="Button" parent="HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +visible = false +layout_mode = 2 +focus_mode = 0 +theme_type_variation = &"EditorLogFilterButton" +toggle_mode = true +button_pressed = true +text = "0" + +[node name="label_refresh_time" type="Label" parent="HBoxContainer/VBoxContainer"] +unique_name_in_owner = true +self_modulate = Color(1, 1, 1, 0.541176) +layout_mode = 2 +size_flags_vertical = 10 +text = "Connecting..." +horizontal_alignment = 2 +autowrap_mode = 2 + +[node name="multiple_instances_alert" type="AcceptDialog" parent="."] +unique_name_in_owner = true +title = "Diagnostic List - Warning!" +position = Vector2i(0, 36) +size = Vector2i(640, 158) +dialog_text = "There seems to be another Godot instance running. + +The Diagnostic List plugin does not work correctly with multiple Godot instances. +It will only be able to connect to the first instance, causing incorrect diagnostics." diff --git a/demo/addons/diagnosticlist/Settings.gd b/demo/addons/diagnosticlist/Settings.gd new file mode 100644 index 0000000..2e84a9f --- /dev/null +++ b/demo/addons/diagnosticlist/Settings.gd @@ -0,0 +1,20 @@ +extends RefCounted +class_name DiagnosticList_Settings + + +const BASE_SETTING_PATH = "addons/diagnostic_list/" +const SETTING_AUTO_REFRESH = BASE_SETTING_PATH + "auto_refresh" + + +static func set_auto_refresh(on: bool) -> void: + _set_setting(SETTING_AUTO_REFRESH, on) + + +static func get_auto_refresh() -> bool: + return ProjectSettings.get_setting(SETTING_AUTO_REFRESH, true) as bool + + +static func _set_setting(name: String, value: Variant) -> void: + ProjectSettings.set_setting(name, value) + ProjectSettings.set_as_internal(name, true) + ProjectSettings.save() diff --git a/demo/addons/diagnosticlist/plugin.cfg b/demo/addons/diagnosticlist/plugin.cfg new file mode 100644 index 0000000..3ebd98f --- /dev/null +++ b/demo/addons/diagnosticlist/plugin.cfg @@ -0,0 +1,7 @@ +[plugin] + +name="Diagnostic List" +description="Provides a project-wide list of GDScript diagnostics." +author="Marvin Ewald" +version="1.0.3" +script="plugin.gd" diff --git a/demo/addons/diagnosticlist/plugin.gd b/demo/addons/diagnosticlist/plugin.gd new file mode 100644 index 0000000..1c9f8c3 --- /dev/null +++ b/demo/addons/diagnosticlist/plugin.gd @@ -0,0 +1,29 @@ +@tool +extends EditorPlugin + +const panel_scene = preload("res://addons/diagnosticlist/Panel.tscn") + +var _dock: DiagnosticList_Panel +var _client: DiagnosticList_LSPClient +var _provider: DiagnosticList_DiagnosticProvider + + +func _enter_tree() -> void: + _client = DiagnosticList_LSPClient.new(self) + _client.on_initialized.connect(_on_lsp_initialized) + _client.connect_lsp() + + _dock = panel_scene.instantiate() + _dock.ready.connect(func() -> void: _dock._plugin_ready()) + add_control_to_bottom_panel(_dock, "Diagnostics") + + +func _exit_tree() -> void: + remove_control_from_bottom_panel(_dock) + _dock.free() + _client.disconnect_lsp() + + +func _on_lsp_initialized() -> void: + _provider = DiagnosticList_DiagnosticProvider.new(_client) + _dock.start(_provider) diff --git a/demo/project.godot b/demo/project.godot index 1063384..33534bc 100644 --- a/demo/project.godot +++ b/demo/project.godot @@ -12,7 +12,7 @@ config_version=5 config/name="ropesim example" run/main_scene="res://ropesim_demo.tscn" -config/features=PackedStringArray("4.2") +config/features=PackedStringArray("4.3") config/icon="res://icon.png" [debug] @@ -25,7 +25,7 @@ gdscript/warnings/unsafe_call_argument=1 [editor_plugins] -enabled=PackedStringArray("res://addons/ropesim/plugin.cfg") +enabled=PackedStringArray("res://addons/diagnosticlist/plugin.cfg", "res://addons/ropesim/plugin.cfg") [gui] diff --git a/demo/icon.svg b/demo/rope_examples/icon.svg similarity index 100% rename from demo/icon.svg rename to demo/rope_examples/icon.svg diff --git a/demo/icon.svg.import b/demo/rope_examples/icon.svg.import similarity index 76% rename from demo/icon.svg.import rename to demo/rope_examples/icon.svg.import index b22e550..b760018 100644 --- a/demo/icon.svg.import +++ b/demo/rope_examples/icon.svg.import @@ -3,15 +3,15 @@ importer="texture" type="CompressedTexture2D" uid="uid://criwv6nuivcxy" -path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +path="res://.godot/imported/icon.svg-4a363dd8a0910a06d0eee7b7bad8c85b.ctex" metadata={ "vram_texture": false } [deps] -source_file="res://icon.svg" -dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] +source_file="res://rope_examples/icon.svg" +dest_files=["res://.godot/imported/icon.svg-4a363dd8a0910a06d0eee7b7bad8c85b.ctex"] [params] diff --git a/demo/ropesim_demo.tscn b/demo/rope_examples/ropesim_demo.tscn similarity index 83% rename from demo/ropesim_demo.tscn rename to demo/rope_examples/ropesim_demo.tscn index 0fe9b02..173e50a 100644 --- a/demo/ropesim_demo.tscn +++ b/demo/rope_examples/ropesim_demo.tscn @@ -2,7 +2,7 @@ [ext_resource type="Script" path="res://addons/ropesim/RopeHandle.gd" id="1_3v13b"] [ext_resource type="Script" path="res://addons/ropesim/RopeAnchor.gd" id="2_60osf"] -[ext_resource type="Texture2D" uid="uid://criwv6nuivcxy" path="res://icon.svg" id="3_t3x7v"] +[ext_resource type="Texture2D" uid="uid://criwv6nuivcxy" path="res://rope_examples/icon.svg" id="3_t3x7v"] [ext_resource type="Script" path="res://addons/ropesim/Rope.gd" id="4_ytdbs"] [ext_resource type="Script" path="res://addons/ropesim/RopeRendererLine2D.gd" id="5_8a6rj"] @@ -66,7 +66,7 @@ metadata/_edit_group_ = true [node name="RopeRendererLine2D" type="Line2D" parent="Rope"] show_behind_parent = true position = Vector2(25, 8) -points = PackedVector2Array(-75, -56, -71.2279, -44.3514, -67.4925, -33.9256, -63.2293, -23.7484, -58.2605, -13.9436, -52.426, -4.67346, -45.5752, 3.8371, -37.591, 11.2812, -28.4462, 17.2774, -18.27, 21.4325, -7.36705, 23.4593, 3.85805, 23.2815, 15.0281, 21.0386, 25.8775, 17.0005, 36.2603, 11.4706, 46.1188, 4.73132, 55.4535, -2.96701, 64.3095, -11.3978, 72.771, -20.3478, 80.9552, -29.613, 89, -39.0001) +points = PackedVector2Array(-25, -8, -25, 2, -25, 12, -25, 22, -25, 32, -25, 42, -25, 52, -25, 62, -25, 72, -25, 82, -25, 92, -25, 102, -25, 112, -25, 122, -25, 132, -25, 142, -25, 152, -25, 162, -25, 172, -25, 182, -25, 192) texture = ExtResource("3_t3x7v") texture_mode = 1 script = ExtResource("5_8a6rj")