Add Unreal Engine 5 specific ignores, raises, docs and tests for C/C++ (#1564)

This commit is contained in:
PETRO YAKOVYSHYN authored and GitHub committed 2026-06-25 11:23:25 +02:00
1 parent 5d44d017c8
commit 3975a91bfe
54 files changed
+1193 -12

No files matched your search

+8
View File
@@ -24,6 +24,14 @@ Status of the `main` branch. Changes prior to the next official version change w
`activate_project`/project lookup, raising `FileNotFoundError` in `RegisteredProject.matches_root_path`
* Language Servers:
- C/C++ (clangd): add Unreal Engine 5 fixture and tests verifying that reflection-macro
code (`UCLASS`, `UFUNCTION`, `UPROPERTY`, `GENERATED_BODY`) yields correct symbols,
references, definitions, and rename edits in hand-written sources (never in
UHT-generated files); add an Unreal Engine setup guide.
- C/C++ (clangd, ccls): detect Unreal Engine projects by a `.uproject` file at the project
root and skip the engine build/cache directories (`Binaries`, `DerivedDataCache`,
`Intermediate`, `Saved`) during indexing. clangd fails with a pointer to the Unreal Engine
setup guide when started in such a project without a `compile_commands.json`. #1566
- `typescript_vts`: Add `initialization_options` setting in `ls_specific_settings.typescript_vts`.
The dict is forwarded to vtsls via `initializationOptions`, `workspace/didChangeConfiguration`,
and `workspace/configuration` pulls. Enables Yarn PnP setups with `typescript.tsdk` pointing
+2 -1
View File
@@ -55,7 +55,8 @@ Some languages require additional installations or setup steps, as noted.
* **C/C++**
(by default, uses the clangd language server (language `cpp`) but we also support ccls (language `cpp_ccls`);
for best results, provide a `compile_commands.json` at the repository root;
see the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details.)
see the [C/C++ Setup Guide](../03-special-guides/cpp_setup) for details;
for Unreal Engine 5 projects, see the [Unreal Engine Setup Guide](../03-special-guides/unreal_engine_setup_guide_for_serena).)
* **Clojure**
* **Crystal**
(requires [Crystalline](https://github.com/elbywan/crystalline) language server to be installed and available on PATH;
+5
View File
@@ -100,3 +100,8 @@ the language server is restarted.
- Clangd official documentation: https://clangd.llvm.org/
- Clangd project setup: https://clangd.llvm.org/installation#project-setup
- CCLS repository: https://github.com/MaskRay/ccls
## Unreal Engine projects
For Unreal Engine 5 projects (reflection macros, UnrealBuildTool), see the
[Unreal Engine Setup Guide](unreal_engine_setup_guide_for_serena.md).
@@ -0,0 +1,103 @@
# Unreal Engine Setup Guide
This guide explains how to prepare an Unreal Engine 5 C++ project so that Serena's
clangd-based C/C++ support can provide full code intelligence: symbol search,
cross-file references, and symbol-level editing in your hand-written sources.
UE game code uses a macro-based reflection layer (`UCLASS`, `UFUNCTION`, `UPROPERTY`,
`GENERATED_BODY`) and engine types (`TArray`, `TMap`). clangd handles all of this,
provided it receives the compiler flags for your project via a `compile_commands.json`
at the project root. Unreal's build system (UnrealBuildTool) does not produce this
file by default; this guide shows how to obtain it.
---
## Prerequisites
- An Unreal Engine 5 C++ project that has been **built at least once** (the build
generates the `*.generated.h` headers that your sources include).
- No additional language server: Serena downloads clangd automatically.
- clangd never compiles your code. The compilation database is only a list of flags.
---
## Getting a compilation database
If clangd starts in a project that has a `.uproject` file but no `compile_commands.json`,
Serena fails the language server startup with an error that points back to this guide,
because clangd cannot resolve engine headers or reflection macros without the database.
Pick one of the following routes to create it.
### Route 1: VSCode project files (no extra installs)
UnrealBuildTool's VSCode project generator emits per-project compile commands.
Run it from your engine installation (VSCode itself is not required):
<Engine>\Build\BatchFiles\Build.bat -projectfiles -project="<YourProject>.uproject" -game -VSCode
This produces `.vscode/compileCommands_<YourProject>.json` inside your project.
Copy or symlink it to the project root as `compile_commands.json`.
If you already use VSCode with UE, the file likely exists; the editor's
"Tools > Refresh Visual Studio Code Project" action maintains it.
### Route 2: UnrealBuildTool's clang database mode (requires LLVM installed)
<Engine>\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.exe -mode=GenerateClangDatabase -project="<YourProject>.uproject" <YourProject>Editor Win64 Development -OutputDir="<YourProject's directory>"
This emits clang-native commands (cleanest flags for clangd) but requires a Clang
toolchain installed on Windows.
---
## Build artifact directories
Generated reflection code (`*.gen.cpp`, `*.generated.h`) legitimately references your
functions, so symbol results could otherwise include hits inside `Intermediate/`. When a
`.uproject` file is present at the project root, the clangd and ccls language servers skip
UE's build and cache directories (`Binaries`, `DerivedDataCache`, `Intermediate`, `Saved`)
during indexing. This is automatic and needs no configuration.
To exclude further paths, add them to `ignored_paths` in your project's
`.serena/project.yml`.
---
## Known behavior
- **`GENERATED_BODY()` and `__LINE__`:** the macro expands using its line number.
After editing lines above it, clangd may report stale-macro diagnostics until the
next build regenerates headers. Symbol operations keep working, since clangd is
designed to operate on code with errors.
- **First index:** large projects take a few minutes to index once; afterwards
results are incremental. The index cache is kept under `.serena/.cache` inside
the project.
- **New `UFUNCTION`/`UCLASS` declarations** need a build before their generated
headers exist.
- **Symbol searches on large projects:** prefer passing `relative_path` to
`find_symbol`. An unscoped search visits every translation unit, and UE
files are expensive to parse because each pulls in large engine headers.
---
## Troubleshooting
Extra flags are easiest to add via a `.clangd` file at the project root, e.g.:
CompileFlags:
Add: [-D_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH, -ferror-limit=200]
- **`STL1000: Unexpected compiler version` errors:** recent MSVC STL headers
assert a minimum Clang version that may be newer than Serena's bundled clangd.
Defining `_ALLOW_COMPILER_AND_STL_VERSION_MISMATCH` (see above) silences the
check; clangd only parses, so the mismatch is harmless.
- **Truncated symbol trees / symbols missing below a certain line:** clangd
aborts a file's parse after ~20 errors by default, which discards everything
declared after that point. Raising the limit with `-ferror-limit=200` keeps
the symbol tree intact even when diagnostics are noisy (common right after
edits, before the next UE build regenerates headers).
- **Stale results after changing the compilation database:** clangd's index
shards in `.serena/.cache` were built with the old flags. Delete that cache
directory and let the project re-index.
---
## Verifying the setup
After activating the project in Serena, a symbol overview of any `UCLASS` header
should list the class with its `UFUNCTION` methods and `UPROPERTY` fields, and
references to a method should resolve to your `Source/` files only.
+1 -1
View File
@@ -368,4 +368,4 @@ markers = [
skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py'
check-hidden = true
ignore-regex = '\.\w+'
ignore-words-list = 'paket,EDN,als'
ignore-words-list = 'paket,EDN,als,ue'
@@ -36,6 +36,7 @@ from typing import Any, cast
from overrides import override
from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES, is_unreal_engine_project
from solidlsp.ls import (
LanguageServerDependencyProvider,
LanguageServerDependencyProviderSinglePath,
@@ -71,10 +72,11 @@ class CCLS(SolidLanguageServer):
@override
def is_ignored_dirname(self, dirname: str) -> bool:
ignored_dirs = [
".ccls-cache",
]
return super().is_ignored_dirname(dirname) or dirname in ignored_dirs
return (
super().is_ignored_dirname(dirname)
or dirname == ".ccls-cache"
or (is_unreal_engine_project(self.repository_root_path) and dirname in UE_IGNORED_DIRNAMES)
)
class DependencyProvider(LanguageServerDependencyProviderSinglePath):
def _get_or_install_core_dependency(self) -> str:
@@ -9,12 +9,14 @@ from typing import Any, cast
from overrides import override
from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES
from solidlsp.ls import LanguageServerDependencyProvider, LanguageServerDependencyProviderSinglePath, ProcessLaunchInfo, SolidLanguageServer
from solidlsp.ls_config import LanguageServerConfig
from solidlsp.ls_exceptions import SolidLSPException
from solidlsp.lsp_protocol_handler.lsp_types import InitializeParams
from solidlsp.settings import SolidLSPSettings
from .common import RuntimeDependency, RuntimeDependencyCollection
from .common import RuntimeDependency, RuntimeDependencyCollection, is_unreal_engine_project
log = logging.getLogger(__name__)
@@ -103,10 +105,11 @@ class ClangdLanguageServer(SolidLanguageServer):
@override
def is_ignored_dirname(self, dirname: str) -> bool:
ignored_dirs = [
".ccls-cache",
]
return super().is_ignored_dirname(dirname) or dirname in ignored_dirs
return (
super().is_ignored_dirname(dirname)
or dirname == ".ccls-cache"
or (is_unreal_engine_project(self.repository_root_path) and dirname in UE_IGNORED_DIRNAMES)
)
def _prepare_compile_commands(self) -> str | None:
"""
@@ -128,7 +131,14 @@ class ClangdLanguageServer(SolidLanguageServer):
compile_db_path = os.path.join(self.repository_root_path, "compile_commands.json")
if not os.path.exists(compile_db_path):
# No compile_commands.json, nothing to do
# clangd can't resolve UE engine headers without the database; fail with guidance.
if is_unreal_engine_project(self.repository_root_path):
raise SolidLSPException(
f"No compile_commands.json found at {self.repository_root_path}, but this looks like an "
"Unreal Engine project (.uproject present). clangd needs a compilation database to resolve "
"engine headers and macros. See docs/03-special-guides/"
"unreal_engine_setup_guide_for_serena.md to generate one."
)
return None
try:
+11
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import logging
import os
import pathlib
import platform
import shutil
import subprocess
@@ -216,3 +217,13 @@ def quote_windows_path(path: str) -> str:
return path
return f'"{path}"'
return path
UE_IGNORED_DIRNAMES = frozenset({"Binaries", "DerivedDataCache", "Intermediate", "Saved"})
"""Unreal Engine build and cache directories. Matched per directory name, so per-plugin and
per-module copies (e.g. ``Plugins/Foo/Intermediate``) are covered too."""
def is_unreal_engine_project(repository_root_path: str) -> bool:
""":return: whether the repository root contains an Unreal Engine ``.uproject`` file."""
return any(pathlib.Path(repository_root_path).glob("*.uproject"))
@@ -0,0 +1,9 @@
/*===========================================================================
Generated code exported from UnrealHeaderTool.
DO NOT modify this manually! Edit the corresponding .h files instead!
===========================================================================*/
// Test stub: trimmed to what the fixture needs.
// DECLARE_FUNCTION(execOnAbilityInput)
#pragma once
class AAbilityActor;
@@ -0,0 +1,22 @@
/*===========================================================================
Generated code exported from UnrealHeaderTool.
DO NOT modify this manually! Edit the corresponding .h files instead!
===========================================================================*/
// Test stub: repeats the hand-written identifiers (as real thunks do) so a
// text search would surface this file; symbol-level tools must not.
//
// void UAbilityComponent::execTriggerAbility(...) { TriggerAbility(AbilityName); }
// void UAbilityComponent::execGetRemainingCooldown(...) { GetRemainingCooldown(AbilityName); }
// void AAbilityActor::execOnAbilityInput(...) { OnAbilityInput(AbilityName); }
namespace UnrealReflectionStub
{
const char* GeneratedSymbols[] = {
"UAbilityComponent",
"TriggerAbility",
"GetRemainingCooldown",
"AAbilityActor",
"OnAbilityInput",
"FAbilityInfo",
};
}
@@ -0,0 +1,10 @@
/*===========================================================================
Generated code exported from UnrealHeaderTool.
DO NOT modify this manually! Edit the corresponding .h files instead!
===========================================================================*/
// Test stub: trimmed to what the fixture needs.
// DECLARE_FUNCTION(execTriggerAbility)
// DECLARE_FUNCTION(execGetRemainingCooldown)
#pragma once
class UAbilityComponent;
@@ -0,0 +1,8 @@
/*===========================================================================
Generated code exported from UnrealHeaderTool.
DO NOT modify this manually! Edit the corresponding .h files instead!
===========================================================================*/
// Test stub: trimmed to what the fixture needs.
#pragma once
struct FAbilityInfo;
@@ -0,0 +1,10 @@
/*===========================================================================
Generated code exported from UnrealHeaderTool.
DO NOT modify this manually! Edit the corresponding .h files instead!
===========================================================================*/
// Test stub: trimmed to what the fixture needs.
// DECLARE_FUNCTION(execReceiveDamage)
#pragma once
class UDamageable;
class IDamageable;
@@ -0,0 +1,10 @@
/*===========================================================================
Generated code exported from UnrealHeaderTool.
DO NOT modify this manually! Edit the corresponding .h files instead!
===========================================================================*/
// Test stub: trimmed to what the fixture needs.
// DECLARE_FUNCTION(execHeal)
// DECLARE_FUNCTION(execReceiveDamage)
#pragma once
class AGameCharacter;
@@ -0,0 +1,4 @@
{
"FileVersion": 3,
"EngineAssociation": "5.7"
}
@@ -0,0 +1,18 @@
#include "AbilityActor.h"
void AAbilityActor::BeginPlay()
{
AActor::BeginPlay();
if (AbilityComponent)
{
AbilityComponent->TriggerAbility(FName("Dash"));
}
}
void AAbilityActor::OnAbilityInput(const FName& AbilityName)
{
if (AbilityComponent)
{
AbilityComponent->TriggerAbility(AbilityName);
}
}
@@ -0,0 +1,24 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "AbilityComponent.h"
#include "AbilityActor.generated.h"
UCLASS(Blueprintable)
class TESTGAME_API AAbilityActor : public AActor
{
GENERATED_BODY()
public:
virtual void BeginPlay() override;
UFUNCTION(BlueprintCallable, Category = "Input")
void OnAbilityInput(const FName& AbilityName);
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Components")
TObjectPtr<UAbilityComponent> AbilityComponent = nullptr;
UPROPERTY(EditDefaultsOnly, Category = "Abilities")
TSubclassOf<UAbilityComponent> ComponentClass;
};
@@ -0,0 +1,16 @@
#include "AbilityComponent.h"
void UAbilityComponent::TriggerAbility(const FName& AbilityName)
{
if (!ActiveCooldowns.Contains(AbilityName))
{
ActiveCooldowns.Add(AbilityName, 1.0f);
}
State = EAbilityState::Active;
OnAbilityTriggered.Broadcast(AbilityName);
}
float UAbilityComponent::GetRemainingCooldown(const FName& AbilityName) const
{
return 0.0f;
}
@@ -0,0 +1,40 @@
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "AbilityTypes.h"
#include "AbilityComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAbilityTriggered, const FName&, AbilityName);
DECLARE_MULTICAST_DELEGATE_OneParam(FOnCooldownExpired, const FName&);
UCLASS(ClassGroup = (Custom), meta = (BlueprintSpawnableComponent))
class TESTGAME_API UAbilityComponent : public UActorComponent
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "Abilities")
void TriggerAbility(const FName& AbilityName);
UFUNCTION(BlueprintPure, Category = "Abilities")
float GetRemainingCooldown(const FName& AbilityName) const;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Abilities")
TArray<FAbilityInfo> Abilities;
UPROPERTY(VisibleAnywhere, Category = "Abilities")
TMap<FName, float> ActiveCooldowns;
UPROPERTY(BlueprintAssignable, Category = "Abilities")
FOnAbilityTriggered OnAbilityTriggered;
UPROPERTY(EditAnywhere, Category = "Abilities")
EAbilityState State = EAbilityState::Idle;
UPROPERTY(EditAnywhere, Category = "Abilities")
TSet<FName> UnlockedAbilities;
// Non-dynamic delegates are not reflected and cannot be UPROPERTYs.
FOnCooldownExpired OnCooldownExpired;
};
@@ -0,0 +1,24 @@
#pragma once
#include "CoreMinimal.h"
#include "AbilityTypes.generated.h"
UENUM(BlueprintType)
enum class EAbilityState : uint8
{
Idle,
Active UMETA(DisplayName = "Active (in use)"),
Cooldown,
};
USTRUCT(BlueprintType)
struct TESTGAME_API FAbilityInfo
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite)
FName AbilityName;
UPROPERTY(EditAnywhere, BlueprintReadWrite)
float CooldownSeconds = 1.0f;
};
@@ -0,0 +1,20 @@
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "Damageable.generated.h"
UINTERFACE(MinimalAPI, Blueprintable)
class UDamageable : public UInterface
{
GENERATED_BODY()
};
class TESTGAME_API IDamageable
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, BlueprintNativeEvent, Category = "Damage")
virtual void ReceiveDamage(float Amount) = 0;
};
@@ -0,0 +1,29 @@
#include "GameCharacter.h"
#include "TestGameLog.h"
AGameCharacter::AGameCharacter()
{
Abilities = CreateDefaultSubobject<UAbilityComponent>(FName(TEXT("Abilities")));
}
void AGameCharacter::ReceiveDamage(float Amount)
{
check(Amount >= 0.0f);
Health -= Amount;
LastDamageAmount = Amount;
UE_LOG(LogTestGame, Warning, TEXT("Received %f damage"), Amount);
if (AActor* Target = CurrentTarget.Get())
{
if (AGameCharacter* OtherCharacter = Cast<AGameCharacter>(Target))
{
OtherCharacter->Heal(Amount);
}
}
}
void AGameCharacter::Heal(float& Amount)
{
Health += Amount;
Amount = 0.0f;
}
@@ -0,0 +1,39 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Character.h"
#include "AbilityComponent.h"
#include "Damageable.h"
#include "GameCharacter.generated.h"
UCLASS(Blueprintable)
class TESTGAME_API AGameCharacter : public ACharacter, public IDamageable
{
GENERATED_BODY()
public:
AGameCharacter();
virtual void ReceiveDamage(float Amount) override;
UFUNCTION(BlueprintCallable, Category = "Health")
void Heal(UPARAM(ref) float& Amount);
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawn")
FVector SpawnOffset = FVector(0.0, 0.0, 100.0);
UPROPERTY(EditAnywhere, Category = "Abilities")
TObjectPtr<UAbilityComponent> Abilities;
UPROPERTY(VisibleAnywhere, Category = "Targeting")
TWeakObjectPtr<AActor> CurrentTarget;
UPROPERTY(EditAnywhere, Category = "Loadout")
TSoftObjectPtr<UAbilityComponent> FallbackLoadout;
private:
// Non-reflected runtime state: smart pointers and optionals are not UPROPERTYs.
TSharedPtr<FAbilityInfo> PendingAbility;
TOptional<float> LastDamageAmount;
float Health = 100.0f;
};
@@ -0,0 +1,5 @@
#pragma once
#include "CoreMinimal.h"
DECLARE_LOG_CATEGORY_EXTERN(LogTestGame, Log, All);
@@ -0,0 +1,6 @@
#include "TestGameLog.h"
#include "Modules/ModuleManager.h"
DEFINE_LOG_CATEGORY(LogTestGame)
IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, TestGame, "TestGame");
@@ -0,0 +1,10 @@
#pragma once
#include "UObject/Object.h"
class UActorComponent : public UObject
{
public:
virtual void BeginPlay() {}
virtual void TickComponent(float DeltaTime) {}
};
@@ -0,0 +1,11 @@
// Minimal TArray stand-in: just enough surface for the fixture sources to parse.
#pragma once
template <typename ElementType>
class TArray
{
public:
void Add(const ElementType& Item) {}
int Num() const { return 0; }
ElementType* GetData() { return nullptr; }
};
@@ -0,0 +1,12 @@
// Minimal TMap stand-in: just enough surface for the fixture sources to parse.
#pragma once
template <typename KeyType, typename ValueType>
class TMap
{
public:
ValueType& Add(const KeyType& Key, const ValueType& Value) { static ValueType V; return V; }
ValueType* Find(const KeyType& Key) { return nullptr; }
bool Contains(const KeyType& Key) const { return false; }
int Num() const { return 0; }
};
@@ -0,0 +1,11 @@
// Minimal TSet stand-in: just enough surface for the fixture sources to parse.
#pragma once
template <typename ElementType>
class TSet
{
public:
void Add(const ElementType& Item) {}
bool Contains(const ElementType& Item) const { return false; }
int Num() const { return 0; }
};
@@ -0,0 +1,20 @@
// Stand-in for Unreal's umbrella header.
#pragma once
#include "CoreTypes.h"
#include "Containers/Array.h"
#include "Containers/Map.h"
#include "Containers/Set.h"
#include "Delegates/DelegateCombinations.h"
#include "Logging/LogMacros.h"
#include "Math/MathFwd.h"
#include "Misc/Optional.h"
#include "Templates/SharedPointer.h"
#include "Templates/SubclassOf.h"
#include "Templates/UniquePtr.h"
#include "UObject/Object.h"
#include "UObject/ObjectMacros.h"
#include "UObject/ObjectPtr.h"
#include "UObject/SoftObjectPtr.h"
#include "UObject/UObjectGlobals.h"
#include "UObject/WeakObjectPtr.h"
@@ -0,0 +1,41 @@
// Minimal stand-ins for core Unreal types and ubiquitous utility macros.
// Real homes in UE: TEXT/FORCEINLINE in Misc/, assertions in Misc/AssertionMacros.h,
// UE_DEPRECATED in Misc/CoreMiscDefines.h; folded together here for stub brevity.
#pragma once
class FName
{
public:
FName() = default;
FName(const char* InName) {}
bool operator==(const FName& Other) const { return true; }
bool operator<(const FName& Other) const { return false; }
};
class FString
{
public:
FString() = default;
FString(const char* InStr) {}
};
class FText
{
public:
FText() = default;
static FText FromString(const FString& InString) { return FText(); }
};
using int32 = int;
using uint32 = unsigned int;
using uint8 = unsigned char;
#define TEXT(x) x
#define FORCEINLINE inline
#define UE_DEPRECATED(Version, Message)
#define check(expr)
#define checkf(expr, format, ...)
#define verify(expr)
#define ensure(expr) (!!(expr))
#define ensureMsgf(expr, format, ...) (!!(expr))
@@ -0,0 +1,81 @@
// Stub of UE's delegate declaration macros (Delegates/DelegateCombinations.h).
// Unlike the empty annotation macros, these manufacture a class at the expansion
// site. The stubs preserve exactly that property for every delegate family:
// single-cast, multicast, events, and the dynamic (reflection-visible) variants.
#pragma once
#define DECLARE_DELEGATE(DelegateName) \
class DelegateName \
{ \
public: \
void Execute() {} \
bool IsBound() const { return false; } \
};
#define DECLARE_DELEGATE_OneParam(DelegateName, Param1Type) \
class DelegateName \
{ \
public: \
void Execute(Param1Type) {} \
bool IsBound() const { return false; } \
};
#define DECLARE_DELEGATE_RetVal(ReturnType, DelegateName) \
class DelegateName \
{ \
public: \
ReturnType Execute() { return ReturnType(); } \
bool IsBound() const { return false; } \
};
#define DECLARE_MULTICAST_DELEGATE(DelegateName) \
class DelegateName \
{ \
public: \
void Broadcast() {} \
};
#define DECLARE_MULTICAST_DELEGATE_OneParam(DelegateName, Param1Type) \
class DelegateName \
{ \
public: \
void Broadcast(Param1Type) {} \
};
#define DECLARE_EVENT(OwningType, EventName) \
class EventName \
{ \
public: \
void Broadcast() {} \
};
#define DECLARE_DYNAMIC_DELEGATE(DelegateName) \
class DelegateName \
{ \
public: \
void ExecuteIfBound() {} \
};
#define DECLARE_DYNAMIC_MULTICAST_DELEGATE(DelegateName) \
class DelegateName \
{ \
public: \
void Broadcast() {} \
void AddDynamic(void* Object, void* Func) {} \
};
#define DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(DelegateName, Param1Type, Param1Name) \
class DelegateName \
{ \
public: \
void Broadcast(Param1Type Param1Name) {} \
void AddDynamic(void* Object, void* Func) {} \
};
#define DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(DelegateName, Param1Type, Param1Name, Param2Type, Param2Name) \
class DelegateName \
{ \
public: \
void Broadcast(Param1Type Param1Name, Param2Type Param2Name) {} \
void AddDynamic(void* Object, void* Func) {} \
};
@@ -0,0 +1,9 @@
#pragma once
#include "UObject/Object.h"
class UWorld : public UObject
{
public:
float GetTimeSeconds() const { return 0.0f; }
};
@@ -0,0 +1,13 @@
#pragma once
#include "UObject/Object.h"
class UWorld;
class AActor : public UObject
{
public:
virtual void BeginPlay() {}
virtual void Tick(float DeltaSeconds) {}
UWorld* GetWorld() const { return nullptr; }
};
@@ -0,0 +1,9 @@
#pragma once
#include "GameFramework/Pawn.h"
class ACharacter : public APawn
{
public:
virtual void Jump() {}
};
@@ -0,0 +1,9 @@
#pragma once
#include "GameFramework/Actor.h"
class APawn : public AActor
{
public:
virtual void PossessedBy(AActor* NewController) {}
};
@@ -0,0 +1,13 @@
// Stub of UE's log category macros (Logging/LogMacros.h). The DECLARE/DEFINE pair
// manufactures a category object symbol; UE_LOG itself expands to nothing relevant.
#pragma once
#define DECLARE_LOG_CATEGORY_EXTERN(CategoryName, DefaultVerbosity, CompileTimeVerbosity) \
struct FLogCategory##CategoryName \
{ \
}; \
extern FLogCategory##CategoryName CategoryName;
#define DEFINE_LOG_CATEGORY(CategoryName) FLogCategory##CategoryName CategoryName;
#define UE_LOG(CategoryName, Verbosity, Format, ...)
@@ -0,0 +1,25 @@
// Minimal stand-ins for UE math types.
#pragma once
struct FVector
{
double X = 0.0;
double Y = 0.0;
double Z = 0.0;
FVector() = default;
FVector(double InX, double InY, double InZ) : X(InX), Y(InY), Z(InZ) {}
};
struct FRotator
{
double Pitch = 0.0;
double Yaw = 0.0;
double Roll = 0.0;
};
struct FTransform
{
FVector GetLocation() const { return FVector(); }
FRotator Rotator() const { return FRotator(); }
};
@@ -0,0 +1,12 @@
// Minimal TOptional stand-in.
#pragma once
template <typename T>
class TOptional
{
public:
TOptional() = default;
TOptional(const T& InValue) {}
bool IsSet() const { return false; }
const T& GetValue() const { static T Value; return Value; }
};
@@ -0,0 +1,9 @@
// Stub of UE's module boilerplate macros (Modules/ModuleManager.h).
#pragma once
class FDefaultGameModuleImpl
{
};
#define IMPLEMENT_PRIMARY_GAME_MODULE(ModuleImplClass, ModuleName, GameName)
#define IMPLEMENT_MODULE(ModuleImplClass, ModuleName)
@@ -0,0 +1,31 @@
// Minimal stand-ins for UE's non-UObject smart pointers.
#pragma once
template <typename T>
class TSharedPtr
{
public:
TSharedPtr() = default;
T* Get() const { return nullptr; }
bool IsValid() const { return false; }
};
template <typename T>
class TSharedRef
{
public:
T& Get() const { static T Instance; return Instance; }
};
template <typename T>
class TWeakPtr
{
public:
TSharedPtr<T> Pin() const { return TSharedPtr<T>(); }
};
template <typename T, typename... ArgTypes>
TSharedRef<T> MakeShared(ArgTypes&&... Args)
{
return TSharedRef<T>();
}
@@ -0,0 +1,9 @@
// Minimal TSubclassOf stand-in.
#pragma once
template <typename T>
class TSubclassOf
{
public:
TSubclassOf() = default;
};
@@ -0,0 +1,17 @@
// Minimal TUniquePtr stand-in.
#pragma once
template <typename T>
class TUniquePtr
{
public:
TUniquePtr() = default;
T* Get() const { return nullptr; }
bool IsValid() const { return false; }
};
template <typename T, typename... ArgTypes>
TUniquePtr<T> MakeUnique(ArgTypes&&... Args)
{
return TUniquePtr<T>();
}
@@ -0,0 +1,8 @@
#pragma once
#include "UObject/Object.h"
// Base class for the U-side of UE's interface pattern (UINTERFACE).
class UInterface : public UObject
{
};
@@ -0,0 +1,15 @@
#pragma once
#include "CoreTypes.h"
class UObject
{
public:
virtual ~UObject() = default;
template <typename T>
T* CreateDefaultSubobject(const FName& SubobjectName)
{
return nullptr;
}
};
@@ -0,0 +1,20 @@
// Stub mirroring Unreal Engine 5.7 ObjectMacros.h (Engine/Source/Runtime/CoreUObject/
// Public/UObject/ObjectMacros.h, lines 744-776). The annotation macros below are
// faithful: in real UE compilation they always expand to nothing (only UnrealHeaderTool
// parses their arguments). GENERATED_BODY is simplified: the real macro pastes
// declarations from the per-class *.generated.h via __LINE__ token concatenation.
#pragma once
#define UPROPERTY(...)
#define UFUNCTION(...)
#define UPARAM(...)
#define USTRUCT(...)
#define UMETA(...)
#define UENUM(...)
#define UCLASS(...)
#define UINTERFACE(...)
#define UDELEGATE(...)
#define GENERATED_BODY(...) public:
#define GENERATED_USTRUCT_BODY(...) public:
#define GENERATED_UCLASS_BODY(...) public:
@@ -0,0 +1,13 @@
// Minimal TObjectPtr stand-in (UE5's idiomatic UPROPERTY pointer wrapper).
#pragma once
template <typename T>
class TObjectPtr
{
public:
TObjectPtr() = default;
TObjectPtr(T* InPtr) {}
T* Get() const { return nullptr; }
T* operator->() const { return nullptr; }
explicit operator bool() const { return false; }
};
@@ -0,0 +1,18 @@
// Minimal stand-ins for UE's soft (lazy-loadable) object references.
#pragma once
template <typename T>
class TSoftObjectPtr
{
public:
TSoftObjectPtr() = default;
T* LoadSynchronous() const { return nullptr; }
bool IsValid() const { return false; }
};
template <typename T>
class TSoftClassPtr
{
public:
TSoftClassPtr() = default;
};
@@ -0,0 +1,16 @@
// Minimal stand-ins for UObject global helpers (Cast, NewObject).
#pragma once
class UObject;
template <typename T>
T* Cast(UObject* Object)
{
return nullptr;
}
template <typename T>
T* NewObject(UObject* Outer = nullptr)
{
return nullptr;
}
@@ -0,0 +1,12 @@
// Minimal TWeakObjectPtr stand-in.
#pragma once
template <typename T>
class TWeakObjectPtr
{
public:
TWeakObjectPtr() = default;
TWeakObjectPtr(T* InPtr) {}
T* Get() const { return nullptr; }
bool IsValid() const { return false; }
};
@@ -0,0 +1,22 @@
[
{
"directory": ".",
"command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/AbilityComponent.cpp",
"file": "Source/TestGame/AbilityComponent.cpp"
},
{
"directory": ".",
"command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/AbilityActor.cpp",
"file": "Source/TestGame/AbilityActor.cpp"
},
{
"directory": ".",
"command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/GameCharacter.cpp",
"file": "Source/TestGame/GameCharacter.cpp"
},
{
"directory": ".",
"command": "clang++ -std=c++20 -DTESTGAME_API= -I Stubs -I Source/TestGame -I Intermediate/TestGame/UHT -c Source/TestGame/TestGameModule.cpp",
"file": "Source/TestGame/TestGameModule.cpp"
}
]
+34
View File
@@ -23,6 +23,40 @@ def is_diagnostics_test_file(relative_path: str) -> bool:
return filename.startswith(("diagnosticssample.", "diagnostics_sample."))
def document_symbol_names(language_server: SolidLanguageServer, relative_path: str) -> list[str]:
"""All symbol names in a file's document-symbol tree, including children."""
symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots()
symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols
names: list[str] = []
def _collect(syms) -> None:
for sym in syms:
names.append(sym.get("name"))
_collect(sym.get("children", []) or [])
_collect(symbol_list)
return names
def find_document_symbol(language_server: SolidLanguageServer, relative_path: str, name: str) -> UnifiedSymbolInformation:
"""The first symbol called ``name`` in a file's document-symbol tree; fails the test if absent."""
symbols = language_server.request_document_symbols(relative_path).get_all_symbols_and_roots()
symbol_list = symbols[0] if symbols and isinstance(symbols[0], list) else symbols
def _search(syms):
for sym in syms:
if sym.get("name") == name:
return sym
found = _search(sym.get("children", []) or [])
if found is not None:
return found
return None
result = _search(symbol_list)
assert result is not None, f"Symbol '{name}' not found in {relative_path}"
return result
def has_malformed_name(
symbol: UnifiedSymbolInformation,
whitespace_allowed: bool = False,
+187
View File
@@ -0,0 +1,187 @@
"""
Unreal Engine 5 fixture tests for the clangd language server.
UE game code is written against a macro-based reflection layer (UCLASS, UFUNCTION,
UPROPERTY, GENERATED_BODY) and engine container types (TArray, TMap). These tests
verify that the clangd backend resolves symbols, references, definitions, and
rename edits in the hand-written sources under Source/, and never in the
UnrealHeaderTool-style generated files under Intermediate/ (which contain the
same identifiers, as real generated reflection code does).
The fixture's stub engine headers mirror UE 5.7's ObjectMacros.h: annotation
macros are empty in real UE compilation too (only UnrealHeaderTool parses them).
The fixture lives in its own repository directory (ue_test_repo) served by
clangd only: ccls 0.20240202, the build shipped by Ubuntu and Homebrew, crashes
intermittently when its session covers the stub engine headers, and clangd is
the supported backend for Unreal Engine projects (see the setup guide).
"""
import os
from collections.abc import Iterator
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from solidlsp.ls_utils import SymbolUtils
from test.conftest import find_identifier_position, get_repo_path, start_ls_context
from test.solidlsp.conftest import document_symbol_names, find_document_symbol
UE_REPO_PATH = get_repo_path(Language.CPP).parent / "ue_test_repo"
ABILITY_COMPONENT_H = os.path.join("Source", "TestGame", "AbilityComponent.h")
ABILITY_ACTOR_H = os.path.join("Source", "TestGame", "AbilityActor.h")
ABILITY_ACTOR_CPP = os.path.join("Source", "TestGame", "AbilityActor.cpp")
@pytest.fixture(scope="module")
def language_server() -> Iterator[SolidLanguageServer]:
"""Clangd over ue_test_repo; overrides the shared fixture for this module."""
with start_ls_context(Language.CPP, repo_path=str(UE_REPO_PATH)) as ls:
yield ls
@pytest.mark.cpp
class TestClangdUnrealEngine:
"""clangd on Unreal Engine-shaped C++ (reflection macros, engine containers)."""
def test_symbol_tree_contains_reflected_types(self, language_server: SolidLanguageServer) -> None:
"""UCLASS/USTRUCT-decorated types appear in the full symbol tree."""
symbols = language_server.request_full_symbol_tree()
for name in ("UAbilityComponent", "AAbilityActor", "FAbilityInfo"):
assert SymbolUtils.symbol_tree_contains_name(symbols, name), f"'{name}' not found in symbol tree"
def test_document_symbols_show_uclass_members(self, language_server: SolidLanguageServer) -> None:
"""UFUNCTION methods and UPROPERTY fields are visible despite the macro layer."""
names = document_symbol_names(language_server, ABILITY_COMPONENT_H)
for expected in ("UAbilityComponent", "TriggerAbility", "GetRemainingCooldown", "Abilities", "ActiveCooldowns"):
assert expected in names, f"Expected '{expected}' in document symbols of AbilityComponent.h, got: {names}"
def test_references_resolve_to_handwritten_sources(self, language_server: SolidLanguageServer) -> None:
"""References to a UFUNCTION are found across files, and only in Source/, never Intermediate/."""
# Precondition: the identifier appears verbatim in generated files on disk.
# A text search would return them; a symbol-level tool must not.
gen_cpp = UE_REPO_PATH / "Intermediate" / "TestGame" / "UHT" / "AbilityComponent.gen.cpp"
assert "TriggerAbility" in gen_cpp.read_text(encoding="utf-8"), "fixture broken: honeypot lost its bait"
trigger = find_document_symbol(language_server, ABILITY_COMPONENT_H, "TriggerAbility")
sel_start = trigger["selectionRange"]["start"]
refs = language_server.request_references(ABILITY_COMPONENT_H, sel_start["line"], sel_start["character"])
ref_files = [ref.get("relativePath", "") for ref in refs]
assert any("AbilityActor.cpp" in ref_file for ref_file in ref_files), (
f"Expected cross-file reference in AbilityActor.cpp, got: {ref_files}"
)
leaked = [ref_file for ref_file in ref_files if "Intermediate" in ref_file]
assert not leaked, f"References leaked into generated files: {leaked}"
def test_definition_resolves_to_source_not_generated(self, language_server: SolidLanguageServer) -> None:
"""Go-to-definition on a UCLASS usage lands in the hand-written header."""
header_path = UE_REPO_PATH / "Source" / "TestGame" / "AbilityActor.h"
position = find_identifier_position(header_path, "UAbilityComponent")
assert position is not None, "UAbilityComponent is not used in AbilityActor.h"
definitions = language_server.request_definition(ABILITY_ACTOR_H, position[0], position[1])
def_paths = [d.get("relativePath", "") for d in definitions]
assert any("AbilityComponent.h" in p for p in def_paths), f"Expected definition in AbilityComponent.h, got: {def_paths}"
assert all("Intermediate" not in p for p in def_paths), f"Definition resolved into generated files: {def_paths}"
def test_symbol_locations_are_in_handwritten_sources(self, language_server: SolidLanguageServer) -> None:
"""Each reflected symbol's defining declaration is locatable in Source/.
Serena's symbol edits (replace_symbol_body, insert_after_symbol) operate at
these locations, so this is the edit-targeting guarantee. Generated headers
may legitimately surface same-named forward declarations (real UHT output
contains them); what matters is that the Source/ declaration is present.
"""
expected_files = {
"UAbilityComponent": "AbilityComponent.h",
"AAbilityActor": "AbilityActor.h",
"FAbilityInfo": "AbilityTypes.h",
"TriggerAbility": "AbilityComponent.h",
"OnAbilityInput": "AbilityActor.h",
}
symbols = language_server.request_full_symbol_tree()
found: dict[str, list[str]] = {name: [] for name in expected_files}
def _walk(syms):
for sym in syms:
name = sym.get("name")
if name in found:
location = sym.get("location") or {}
found[name].append(location.get("relativePath", "") or str(location.get("uri", "")))
_walk(sym.get("children", []) or [])
_walk(symbols)
for name, expected_file in expected_files.items():
source_locations = [p for p in found[name] if "Source" in p and expected_file in p]
assert source_locations, f"'{name}' has no location in Source/{{...}}/{expected_file}, got: {found[name]}"
def test_uenum_and_enumerators_visible(self, language_server: SolidLanguageServer) -> None:
"""UENUM-decorated enum and its UMETA-annotated enumerators appear as symbols."""
ability_types_h = os.path.join("Source", "TestGame", "AbilityTypes.h")
names = document_symbol_names(language_server, ability_types_h)
for expected in ("EAbilityState", "Idle", "Active", "Cooldown"):
assert expected in names, f"Expected '{expected}' in document symbols of AbilityTypes.h, got: {names}"
def test_macro_generated_delegate_type_resolves_to_source(self, language_server: SolidLanguageServer) -> None:
"""DECLARE_DYNAMIC_MULTICAST_DELEGATE manufactures a class, which must appear
as a symbol located at the macro expansion site in the hand-written header.
"""
delegate = find_document_symbol(language_server, ABILITY_COMPONENT_H, "FOnAbilityTriggered")
location_path = (delegate.get("location") or {}).get("relativePath", "")
assert location_path, f"FOnAbilityTriggered has no location: {delegate}"
assert "Intermediate" not in location_path, f"Delegate symbol located in generated files: {location_path}"
member_names = document_symbol_names(language_server, ABILITY_COMPONENT_H)
assert "OnAbilityTriggered" in member_names, "BlueprintAssignable delegate property not visible"
def test_interface_pattern_symbols(self, language_server: SolidLanguageServer) -> None:
"""The UINTERFACE pattern (paired UDamageable/IDamageable classes) yields both
classes and the interface method; the implementing class shows the override.
"""
damageable_h = os.path.join("Source", "TestGame", "Damageable.h")
names = document_symbol_names(language_server, damageable_h)
for expected in ("UDamageable", "IDamageable", "ReceiveDamage"):
assert expected in names, f"Expected '{expected}' in document symbols of Damageable.h, got: {names}"
character_h = os.path.join("Source", "TestGame", "GameCharacter.h")
character_names = document_symbol_names(language_server, character_h)
assert "ReceiveDamage" in character_names, "Interface override not visible in implementing class"
def test_log_category_symbol_in_source(self, language_server: SolidLanguageServer) -> None:
"""DECLARE_LOG_CATEGORY_EXTERN manufactures a category object symbol in hand-written code."""
log_h = os.path.join("Source", "TestGame", "TestGameLog.h")
names = document_symbol_names(language_server, log_h)
assert "LogTestGame" in names, f"Expected log category symbol 'LogTestGame', got: {names}"
def test_nondynamic_delegate_type_in_source(self, language_server: SolidLanguageServer) -> None:
"""DECLARE_MULTICAST_DELEGATE (non-dynamic family) also manufactures a type in Source/."""
delegate = find_document_symbol(language_server, ABILITY_COMPONENT_H, "FOnCooldownExpired")
location_path = (delegate.get("location") or {}).get("relativePath", "")
assert location_path, f"FOnCooldownExpired has no location: {delegate}"
assert "Intermediate" not in location_path, f"Delegate symbol located in generated files: {location_path}"
def test_character_members_with_ue_types_visible(self, language_server: SolidLanguageServer) -> None:
"""Members typed with FVector/TObjectPtr/TWeakObjectPtr/TSoftObjectPtr and a
UPARAM(ref) UFUNCTION are all visible in the symbol tree.
"""
character_h = os.path.join("Source", "TestGame", "GameCharacter.h")
names = document_symbol_names(language_server, character_h)
for expected in ("AGameCharacter", "SpawnOffset", "Abilities", "CurrentTarget", "FallbackLoadout", "Heal"):
assert expected in names, f"Expected '{expected}' in document symbols of GameCharacter.h, got: {names}"
def test_rename_edit_targets_only_source_files(self, language_server: SolidLanguageServer) -> None:
"""A rename WorkspaceEdit for a UFUNCTION touches only hand-written files (edit is not applied)."""
trigger = find_document_symbol(language_server, ABILITY_COMPONENT_H, "TriggerAbility")
sel_start = trigger["selectionRange"]["start"]
edit = language_server.request_rename_symbol_edit(ABILITY_COMPONENT_H, sel_start["line"], sel_start["character"], "ActivateAbility")
assert edit is not None, "clangd should support rename"
touched: list[str] = []
for uri in edit.get("changes") or {}:
touched.append(uri)
for doc_change in edit.get("documentChanges") or []:
text_doc = doc_change.get("textDocument") or {}
if text_doc.get("uri"):
touched.append(text_doc["uri"])
assert any("AbilityComponent.h" in uri for uri in touched), f"Rename does not edit the declaring header, touched: {touched}"
leaked = [uri for uri in touched if "Intermediate" in uri]
assert not leaked, f"Rename would edit generated files: {leaked}"
+60
View File
@@ -0,0 +1,60 @@
"""Unreal Engine handling in the clangd and ccls servers, without starting a process.
No ``cpp`` marker: these build server shells with ``object.__new__`` and call the no-database
and directory-ignore code paths directly, so they run in the default suite.
"""
import pytest
from solidlsp.language_servers.ccls_language_server import CCLS
from solidlsp.language_servers.clangd_language_server import ClangdLanguageServer
from solidlsp.language_servers.common import UE_IGNORED_DIRNAMES, is_unreal_engine_project
from solidlsp.ls_exceptions import SolidLSPException
def _make_clangd(root) -> ClangdLanguageServer:
server = object.__new__(ClangdLanguageServer)
server.repository_root_path = str(root)
return server
def _make_ccls(root) -> CCLS:
server = object.__new__(CCLS)
server.repository_root_path = str(root)
return server
def test_is_unreal_engine_project_detection(tmp_path):
assert is_unreal_engine_project(str(tmp_path)) is False
(tmp_path / "MyGame.uproject").write_text("{}")
assert is_unreal_engine_project(str(tmp_path)) is True
def test_missing_compile_commands_raises_for_unreal_project(tmp_path):
(tmp_path / "MyGame.uproject").write_text("{}")
server = _make_clangd(tmp_path)
with pytest.raises(SolidLSPException, match="Unreal Engine"):
server._prepare_compile_commands()
def test_missing_compile_commands_returns_none_for_non_unreal_project(tmp_path):
server = _make_clangd(tmp_path)
assert server._prepare_compile_commands() is None
@pytest.mark.parametrize("make_server", [_make_clangd, _make_ccls])
def test_unreal_dirs_ignored_only_for_unreal_project(make_server, tmp_path):
non_ue = make_server(tmp_path)
for dirname in UE_IGNORED_DIRNAMES:
assert not non_ue.is_ignored_dirname(dirname), f"{dirname} should not be pruned without a .uproject"
ue_root = tmp_path / "ue"
ue_root.mkdir()
(ue_root / "MyGame.uproject").write_text("{}")
ue = make_server(ue_root)
for dirname in UE_IGNORED_DIRNAMES:
assert ue.is_ignored_dirname(dirname), f"{dirname} should be pruned for a UE project"
assert non_ue.is_ignored_dirname(".ccls-cache")
assert ue.is_ignored_dirname(".ccls-cache")