Work on clangd-query
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -50,5 +50,4 @@ luprex/ext/eris-master/test/unpersist
|
||||
GPF-output/**
|
||||
|
||||
__pycache__/
|
||||
.clangd-query.pid
|
||||
.clangd-query/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
73
tools/trim-handler.py
Normal 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()
|
||||
Reference in New Issue
Block a user