Work on clangd-query

This commit is contained in:
2026-03-09 06:47:43 -04:00
parent 642e3aca0a
commit 8f9f87aa8a
5 changed files with 158 additions and 28 deletions

1
.gitignore vendored
View File

@@ -50,5 +50,4 @@ luprex/ext/eris-master/test/unpersist
GPF-output/**
__pycache__/
.clangd-query.pid
.clangd-query/

View File

@@ -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;

View File

@@ -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()

View File

@@ -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

73
tools/trim-handler.py Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
Trim unnecessary #include lines from a C++ header file.
Usage: python3 tools/trim-handler.py <file>
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 <file>")
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()