diff --git a/.gitignore b/.gitignore index d2db1ec4..fd4eb291 100644 --- a/.gitignore +++ b/.gitignore @@ -50,5 +50,4 @@ luprex/ext/eris-master/test/unpersist GPF-output/** __pycache__/ -.clangd-query.pid .clangd-query/ diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/BlueprintExportSubsystem.cpp similarity index 84% rename from Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp rename to Plugins/BlueprintMCP/Source/BlueprintMCP/Private/BlueprintExportSubsystem.cpp index 8f4c5c9b..cada9e43 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/MCPEditorSubsystem.cpp +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Private/BlueprintExportSubsystem.cpp @@ -1,4 +1,4 @@ -#include "MCPEditorSubsystem.h" +#include "BlueprintExportSubsystem.h" #include "BlueprintExporter.h" #include "Engine/Blueprint.h" #include "EdGraph/EdGraph.h" @@ -6,21 +6,21 @@ #include "UnrealExporter.h" #include "Misc/FileHelper.h" -void UBlueprintMCPEditorSubsystem::Initialize(FSubsystemCollectionBase& Collection) +void UBlueprintExportSubsystem::Initialize(FSubsystemCollectionBase& Collection) { Super::Initialize(Collection); OnAssetSavedHandle = UPackage::PackageSavedWithContextEvent.AddUObject( - this, &UBlueprintMCPEditorSubsystem::OnAssetSaved); + this, &UBlueprintExportSubsystem::OnAssetSaved); } -void UBlueprintMCPEditorSubsystem::Deinitialize() +void UBlueprintExportSubsystem::Deinitialize() { UPackage::PackageSavedWithContextEvent.Remove(OnAssetSavedHandle); Super::Deinitialize(); } -void UBlueprintMCPEditorSubsystem::OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context) +void UBlueprintExportSubsystem::OnAssetSaved(const FString& PackageFilename, UPackage* Package, FObjectPostSaveContext Context) { if (!Package) return; diff --git a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/BlueprintExportSubsystem.h similarity index 82% rename from Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h rename to Plugins/BlueprintMCP/Source/BlueprintMCP/Public/BlueprintExportSubsystem.h index 03dccdf8..1216638e 100644 --- a/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/MCPEditorSubsystem.h +++ b/Plugins/BlueprintMCP/Source/BlueprintMCP/Public/BlueprintExportSubsystem.h @@ -3,13 +3,13 @@ #include "CoreMinimal.h" #include "EditorSubsystem.h" #include "UObject/ObjectSaveContext.h" -#include "MCPEditorSubsystem.generated.h" +#include "BlueprintExportSubsystem.generated.h" /** * Editor subsystem that exports blueprint text files whenever an asset is saved. */ UCLASS() -class UBlueprintMCPEditorSubsystem : public UEditorSubsystem +class UBlueprintExportSubsystem : public UEditorSubsystem { GENERATED_BODY() diff --git a/tools/clangd-query.py b/tools/clangd-query.py index c1f02829..456eb091 100755 --- a/tools/clangd-query.py +++ b/tools/clangd-query.py @@ -47,11 +47,11 @@ import time SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_DIR = os.path.dirname(SCRIPT_DIR) WORKSPACE_FILE = os.path.join(PROJECT_DIR, "Integration.code-workspace") -SOCKET_PATH = os.path.join(PROJECT_DIR, ".clangd-query.sock") -PID_FILE = os.path.join(PROJECT_DIR, ".clangd-query.pid") +DAEMON_DIR = os.path.join(PROJECT_DIR, ".clangd-query") +SOCKET_PATH = os.path.join(DAEMON_DIR, "sock") +PID_FILE = os.path.join(DAEMON_DIR, "pid") +LOG_FILE = os.path.join(DAEMON_DIR, "log") -# A small file to open on startup to trigger background index loading -TRIGGER_FILE = os.path.join(PROJECT_DIR, "luprex", "cpp", "core", "util.hpp") def read_workspace_config(): @@ -144,6 +144,23 @@ def format_uri(uri): # Daemon process # ============================================================ +def truncate_text(obj): + """Deep-copy obj, truncating any 'text' string values to 80 chars.""" + if isinstance(obj, dict): + return {k: (v[:80] + "..." if k == "text" and isinstance(v, str) and len(v) > 80 + else truncate_text(v)) + for k, v in obj.items()} + if isinstance(obj, list): + return [truncate_text(v) for v in obj] + return obj + + +def lsp_log(direction, msg): + """Log an LSP message to the log file.""" + with open(LOG_FILE, "a") as f: + f.write(f"{direction} {json.dumps(truncate_text(msg))}\n") + + class ClangdDaemon: """Manages a clangd subprocess and serves queries over a Unix socket.""" @@ -180,8 +197,9 @@ class ClangdDaemon: self.events = {} self.index_loaded = threading.Event() self.opened_files = set() - self.diagnostics = {} # uri -> list of diagnostics - self.diag_events = {} # uri -> threading.Event + self.file_versions = {} + self.cached_diagnostics = {} # uri -> list of diagnostics + self.file_status_events = {} # uri -> threading.Event (set when idle) threading.Thread(target=self._stdout_reader, daemon=True).start() threading.Thread(target=self._stderr_reader, daemon=True).start() @@ -191,13 +209,18 @@ class ClangdDaemon: msg = read_lsp_message(self.proc.stdout) if msg is None: break - # Handle server-initiated diagnostics notifications + lsp_log("<<<", msg) if msg.get("method") == "textDocument/publishDiagnostics": uri = msg["params"]["uri"] with self.lock: - self.diagnostics[uri] = msg["params"]["diagnostics"] - if uri in self.diag_events: - self.diag_events[uri].set() + self.cached_diagnostics[uri] = msg["params"]["diagnostics"] + continue + if msg.get("method") == "textDocument/clangd.fileStatus": + uri = msg["params"]["uri"] + state = msg["params"]["state"] + with self.lock: + if state == "idle" and uri in self.file_status_events: + self.file_status_events[uri].set() continue msg_id = msg.get("id") if msg_id is not None and msg_id in self.pending: @@ -219,6 +242,7 @@ class ClangdDaemon: self.pending[rid] = None self.events[rid] = event msg = {"jsonrpc": "2.0", "id": rid, "method": method, "params": params} + lsp_log(">>>", msg) self.proc.stdin.write(make_lsp_message(msg)) self.proc.stdin.flush() event.wait(timeout=30) @@ -229,6 +253,7 @@ class ClangdDaemon: def send_notification(self, method, params): msg = {"jsonrpc": "2.0", "method": method, "params": params} + lsp_log(">>>", msg) self.proc.stdin.write(make_lsp_message(msg)) self.proc.stdin.flush() @@ -237,17 +262,36 @@ class ClangdDaemon: "processId": os.getpid(), "rootUri": f"file://{PROJECT_DIR}", "capabilities": {}, + "initializationOptions": { + "clangdFileStatus": True, + }, }) self.send_notification("initialized", {}) - self.open_file(TRIGGER_FILE) + self.send_notification("textDocument/didOpen", { + "textDocument": { + "uri": f"file://{PROJECT_DIR}/fake.cpp", + "languageId": "cpp", + "version": 1, + "text": "", + } + }) def open_file(self, filepath): abs_path = os.path.abspath(filepath) - if abs_path in self.opened_files: - return - self.opened_files.add(abs_path) with open(abs_path) as f: text = f.read() + if abs_path in self.opened_files: + self.file_versions[abs_path] = self.file_versions.get(abs_path, 1) + 1 + self.send_notification("textDocument/didChange", { + "textDocument": { + "uri": f"file://{abs_path}", + "version": self.file_versions[abs_path], + }, + "contentChanges": [{"text": text}], + }) + return + self.opened_files.add(abs_path) + self.file_versions[abs_path] = 1 self.send_notification("textDocument/didOpen", { "textDocument": { "uri": f"file://{abs_path}", @@ -261,6 +305,8 @@ class ClangdDaemon: """Handle a query dict and return a result dict.""" cmd = query["command"] args = query.get("args", []) + with open(LOG_FILE, "a") as f: + f.write(f"\n\n\n------------------------ QUERY: {cmd} {' '.join(args)} ----------------\n\n\n") if cmd == "symbol": self.index_loaded.wait(timeout=15) @@ -271,14 +317,25 @@ class ClangdDaemon: filepath = args[0] abs_path = os.path.abspath(filepath) uri = f"file://{abs_path}" - # Set up event before opening so we don't miss the notification + with open(abs_path) as f: + text = f.read() + event = threading.Event() with self.lock: - self.diag_events[uri] = threading.Event() - self.open_file(abs_path) - self.diag_events[uri].wait(timeout=30) + self.cached_diagnostics.pop(uri, None) + self.file_status_events[uri] = event + self.send_notification("textDocument/didOpen", { + "textDocument": {"uri": uri, "languageId": "cpp", + "version": 1, "text": text} + }) + event.wait(timeout=30) with self.lock: - diags = self.diagnostics.get(uri, []) - self.diag_events.pop(uri, None) + self.file_status_events.pop(uri, None) + diags = self.cached_diagnostics.pop(uri, None) + self.send_notification("textDocument/didClose", { + "textDocument": {"uri": uri} + }) + if diags is None: + return {"error": "Timed out waiting for diagnostics"} return {"result": diags} if cmd == "definition": @@ -343,6 +400,7 @@ def kill_existing_daemon(): def run_daemon(): """Run the daemon process: start clangd, listen on Unix socket.""" + os.makedirs(DAEMON_DIR, exist_ok=True) kill_existing_daemon() # Write our PID diff --git a/tools/trim-handler.py b/tools/trim-handler.py new file mode 100644 index 00000000..5e39e20a --- /dev/null +++ b/tools/trim-handler.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +Trim unnecessary #include lines from a C++ header file. + +Usage: python3 tools/trim-handler.py + +For each #include line, temporarily removes it and runs clangd diagnostics. +If no errors result, the include is marked unnecessary. At the end, rewrites +the file with all unnecessary includes removed. +""" + +import sys +import subprocess +import os + +def read_lines(path): + with open(path, 'r') as f: + return f.readlines() + +def write_lines(path, lines): + with open(path, 'w') as f: + f.writelines(lines) + +def has_errors(path): + result = subprocess.run( + ['python3', 'tools/clangd-query.py', 'diagnostics', path], + capture_output=True, text=True + ) + output = result.stdout.strip() + return output != "No problems found." + +def main(): + if len(sys.argv) != 2: + print("Usage: python3 tools/trim-handler.py ") + sys.exit(1) + + path = os.path.abspath(sys.argv[1]) + original_lines = read_lines(path) + + # First, verify the file starts with no errors. + if has_errors(path): + print(f"ERROR: {path} already has diagnostics errors. Fix those first.") + sys.exit(1) + + unnecessary = set() + + for i, line in enumerate(original_lines): + if not line.strip().startswith('#include'): + continue + # Skip the .generated.h include — always required by UHT. + if '.generated.h' in line: + continue + + # Rewrite the file without this line. + test_lines = original_lines[:i] + original_lines[i+1:] + write_lines(path, test_lines) + + if has_errors(path): + print(f" KEEP: {line.strip()}") + else: + print(f" DROP: {line.strip()}") + unnecessary.add(i) + + # Restore the original file before testing the next include. + write_lines(path, original_lines) + + # Final rewrite with unnecessary includes removed. + final_lines = [line for i, line in enumerate(original_lines) if i not in unnecessary] + write_lines(path, final_lines) + print(f"\nRemoved {len(unnecessary)} unnecessary include(s) from {path}") + +if __name__ == '__main__': + main()