diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c8e7599..f351bb27f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ Status of the `main` branch. Changes prior to the next official version change w - Allow `query_project` tool to access read-only tools that are not enabled in the current configuration * 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. - `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 diff --git a/docs/01-about/020_programming-languages.md b/docs/01-about/020_programming-languages.md index 6f435c2f4..81a48d382 100644 --- a/docs/01-about/020_programming-languages.md +++ b/docs/01-about/020_programming-languages.md @@ -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; diff --git a/docs/03-special-guides/cpp_setup.md b/docs/03-special-guides/cpp_setup.md index c54f2105d..dd5622ddf 100644 --- a/docs/03-special-guides/cpp_setup.md +++ b/docs/03-special-guides/cpp_setup.md @@ -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). diff --git a/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md b/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md new file mode 100644 index 000000000..eeaa1b610 --- /dev/null +++ b/docs/03-special-guides/unreal_engine_setup_guide_for_serena.md @@ -0,0 +1,101 @@ +# 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 + +Pick one of the following routes. + +### 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): + + \Build\BatchFiles\Build.bat -projectfiles -project=".uproject" -game -VSCode + +This produces `.vscode/compileCommands_.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) + + \Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.exe -mode=GenerateClangDatabase -project=".uproject" Editor Win64 Development -OutputDir="" + +This emits clang-native commands (cleanest flags for clangd) but requires a Clang +toolchain installed on Windows. + +--- +## Recommended project configuration + +Generated reflection code (`*.gen.cpp`, `*.generated.h`) legitimately references your +functions, so symbol results can include hits inside `Intermediate/`. Exclude UE's +build artifacts in your project's `.serena/project.yml`: + + ignored_paths: + - "Intermediate" + - "Saved" + - "Binaries" + - "DerivedDataCache" + +--- +## 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. diff --git a/pyproject.toml b/pyproject.toml index 9a03e5531..4345f9ffa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -365,4 +365,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' diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h new file mode 100644 index 000000000..dfe4e1123 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityActor.generated.h @@ -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; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp new file mode 100644 index 000000000..f63150c20 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.gen.cpp @@ -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", + }; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h new file mode 100644 index 000000000..6b67bfcee --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityComponent.generated.h @@ -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; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h new file mode 100644 index 000000000..94f730452 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/AbilityTypes.generated.h @@ -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; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h new file mode 100644 index 000000000..d8925f26f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/Damageable.generated.h @@ -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; diff --git a/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h new file mode 100644 index 000000000..312e057c8 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Intermediate/TestGame/UHT/GameCharacter.generated.h @@ -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; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp new file mode 100644 index 000000000..8ca27d9c1 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.cpp @@ -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); + } +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h new file mode 100644 index 000000000..a728131f2 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityActor.h @@ -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 AbilityComponent = nullptr; + + UPROPERTY(EditDefaultsOnly, Category = "Abilities") + TSubclassOf ComponentClass; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp new file mode 100644 index 000000000..81cab7c1e --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.cpp @@ -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; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h new file mode 100644 index 000000000..0216ac431 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityComponent.h @@ -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 Abilities; + + UPROPERTY(VisibleAnywhere, Category = "Abilities") + TMap ActiveCooldowns; + + UPROPERTY(BlueprintAssignable, Category = "Abilities") + FOnAbilityTriggered OnAbilityTriggered; + + UPROPERTY(EditAnywhere, Category = "Abilities") + EAbilityState State = EAbilityState::Idle; + + UPROPERTY(EditAnywhere, Category = "Abilities") + TSet UnlockedAbilities; + + // Non-dynamic delegates are not reflected and cannot be UPROPERTYs. + FOnCooldownExpired OnCooldownExpired; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h new file mode 100644 index 000000000..a694e25cd --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/AbilityTypes.h @@ -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; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h new file mode 100644 index 000000000..825c1d89f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/Damageable.h @@ -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; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp new file mode 100644 index 000000000..f50b129db --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.cpp @@ -0,0 +1,29 @@ +#include "GameCharacter.h" +#include "TestGameLog.h" + +AGameCharacter::AGameCharacter() +{ + Abilities = CreateDefaultSubobject(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(Target)) + { + OtherCharacter->Heal(Amount); + } + } +} + +void AGameCharacter::Heal(float& Amount) +{ + Health += Amount; + Amount = 0.0f; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h new file mode 100644 index 000000000..892a42eab --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/GameCharacter.h @@ -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 Abilities; + + UPROPERTY(VisibleAnywhere, Category = "Targeting") + TWeakObjectPtr CurrentTarget; + + UPROPERTY(EditAnywhere, Category = "Loadout") + TSoftObjectPtr FallbackLoadout; + +private: + // Non-reflected runtime state: smart pointers and optionals are not UPROPERTYs. + TSharedPtr PendingAbility; + TOptional LastDamageAmount; + float Health = 100.0f; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h new file mode 100644 index 000000000..10caade9a --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameLog.h @@ -0,0 +1,5 @@ +#pragma once + +#include "CoreMinimal.h" + +DECLARE_LOG_CATEGORY_EXTERN(LogTestGame, Log, All); diff --git a/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp new file mode 100644 index 000000000..4bd6a5dfa --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Source/TestGame/TestGameModule.cpp @@ -0,0 +1,6 @@ +#include "TestGameLog.h" +#include "Modules/ModuleManager.h" + +DEFINE_LOG_CATEGORY(LogTestGame) + +IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, TestGame, "TestGame"); diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h new file mode 100644 index 000000000..b728d771d --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Components/ActorComponent.h @@ -0,0 +1,10 @@ +#pragma once + +#include "UObject/Object.h" + +class UActorComponent : public UObject +{ +public: + virtual void BeginPlay() {} + virtual void TickComponent(float DeltaTime) {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h new file mode 100644 index 000000000..408385f34 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Array.h @@ -0,0 +1,11 @@ +// Minimal TArray stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template +class TArray +{ +public: + void Add(const ElementType& Item) {} + int Num() const { return 0; } + ElementType* GetData() { return nullptr; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h new file mode 100644 index 000000000..10c835f54 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Map.h @@ -0,0 +1,12 @@ +// Minimal TMap stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template +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; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h new file mode 100644 index 000000000..a30afa364 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Containers/Set.h @@ -0,0 +1,11 @@ +// Minimal TSet stand-in: just enough surface for the fixture sources to parse. +#pragma once + +template +class TSet +{ +public: + void Add(const ElementType& Item) {} + bool Contains(const ElementType& Item) const { return false; } + int Num() const { return 0; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h new file mode 100644 index 000000000..18211753c --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreMinimal.h @@ -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" diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.h new file mode 100644 index 000000000..275a29185 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/CoreTypes.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)) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h new file mode 100644 index 000000000..5b1fc2b2f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Delegates/DelegateCombinations.h @@ -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) {} \ + }; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h new file mode 100644 index 000000000..9da8b5ddf --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Engine/World.h @@ -0,0 +1,9 @@ +#pragma once + +#include "UObject/Object.h" + +class UWorld : public UObject +{ +public: + float GetTimeSeconds() const { return 0.0f; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h new file mode 100644 index 000000000..80c4536fc --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Actor.h @@ -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; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h new file mode 100644 index 000000000..014799ce7 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Character.h @@ -0,0 +1,9 @@ +#pragma once + +#include "GameFramework/Pawn.h" + +class ACharacter : public APawn +{ +public: + virtual void Jump() {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h new file mode 100644 index 000000000..4007abc17 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/GameFramework/Pawn.h @@ -0,0 +1,9 @@ +#pragma once + +#include "GameFramework/Actor.h" + +class APawn : public AActor +{ +public: + virtual void PossessedBy(AActor* NewController) {} +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h new file mode 100644 index 000000000..fd7746108 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Logging/LogMacros.h @@ -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, ...) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h new file mode 100644 index 000000000..5c6aede58 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Math/MathFwd.h @@ -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(); } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h new file mode 100644 index 000000000..a49794bde --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Misc/Optional.h @@ -0,0 +1,12 @@ +// Minimal TOptional stand-in. +#pragma once + +template +class TOptional +{ +public: + TOptional() = default; + TOptional(const T& InValue) {} + bool IsSet() const { return false; } + const T& GetValue() const { static T Value; return Value; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h new file mode 100644 index 000000000..20aa630df --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Modules/ModuleManager.h @@ -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) diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h new file mode 100644 index 000000000..3eaf59a79 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SharedPointer.h @@ -0,0 +1,31 @@ +// Minimal stand-ins for UE's non-UObject smart pointers. +#pragma once + +template +class TSharedPtr +{ +public: + TSharedPtr() = default; + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template +class TSharedRef +{ +public: + T& Get() const { static T Instance; return Instance; } +}; + +template +class TWeakPtr +{ +public: + TSharedPtr Pin() const { return TSharedPtr(); } +}; + +template +TSharedRef MakeShared(ArgTypes&&... Args) +{ + return TSharedRef(); +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h new file mode 100644 index 000000000..f7b84e178 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/SubclassOf.h @@ -0,0 +1,9 @@ +// Minimal TSubclassOf stand-in. +#pragma once + +template +class TSubclassOf +{ +public: + TSubclassOf() = default; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h new file mode 100644 index 000000000..c86daf747 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/Templates/UniquePtr.h @@ -0,0 +1,17 @@ +// Minimal TUniquePtr stand-in. +#pragma once + +template +class TUniquePtr +{ +public: + TUniquePtr() = default; + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template +TUniquePtr MakeUnique(ArgTypes&&... Args) +{ + return TUniquePtr(); +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h new file mode 100644 index 000000000..091afce6f --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Interface.h @@ -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 +{ +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h new file mode 100644 index 000000000..ba30f34d1 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/Object.h @@ -0,0 +1,15 @@ +#pragma once + +#include "CoreTypes.h" + +class UObject +{ +public: + virtual ~UObject() = default; + + template + T* CreateDefaultSubobject(const FName& SubobjectName) + { + return nullptr; + } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h new file mode 100644 index 000000000..56a46b168 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectMacros.h @@ -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: diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h new file mode 100644 index 000000000..e6b150322 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/ObjectPtr.h @@ -0,0 +1,13 @@ +// Minimal TObjectPtr stand-in (UE5's idiomatic UPROPERTY pointer wrapper). +#pragma once + +template +class TObjectPtr +{ +public: + TObjectPtr() = default; + TObjectPtr(T* InPtr) {} + T* Get() const { return nullptr; } + T* operator->() const { return nullptr; } + explicit operator bool() const { return false; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h new file mode 100644 index 000000000..609443d10 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/SoftObjectPtr.h @@ -0,0 +1,18 @@ +// Minimal stand-ins for UE's soft (lazy-loadable) object references. +#pragma once + +template +class TSoftObjectPtr +{ +public: + TSoftObjectPtr() = default; + T* LoadSynchronous() const { return nullptr; } + bool IsValid() const { return false; } +}; + +template +class TSoftClassPtr +{ +public: + TSoftClassPtr() = default; +}; diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h new file mode 100644 index 000000000..4a86f20bb --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/UObjectGlobals.h @@ -0,0 +1,16 @@ +// Minimal stand-ins for UObject global helpers (Cast, NewObject). +#pragma once + +class UObject; + +template +T* Cast(UObject* Object) +{ + return nullptr; +} + +template +T* NewObject(UObject* Outer = nullptr) +{ + return nullptr; +} diff --git a/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h new file mode 100644 index 000000000..e45451aa6 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/Stubs/UObject/WeakObjectPtr.h @@ -0,0 +1,12 @@ +// Minimal TWeakObjectPtr stand-in. +#pragma once + +template +class TWeakObjectPtr +{ +public: + TWeakObjectPtr() = default; + TWeakObjectPtr(T* InPtr) {} + T* Get() const { return nullptr; } + bool IsValid() const { return false; } +}; diff --git a/test/resources/repos/cpp/ue_test_repo/compile_commands.json b/test/resources/repos/cpp/ue_test_repo/compile_commands.json new file mode 100644 index 000000000..3009849e2 --- /dev/null +++ b/test/resources/repos/cpp/ue_test_repo/compile_commands.json @@ -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" + } +] diff --git a/test/solidlsp/conftest.py b/test/solidlsp/conftest.py index de894f133..79c04617d 100644 --- a/test/solidlsp/conftest.py +++ b/test/solidlsp/conftest.py @@ -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, diff --git a/test/solidlsp/cpp/test_cpp_unreal.py b/test/solidlsp/cpp/test_cpp_unreal.py new file mode 100644 index 000000000..540becd0f --- /dev/null +++ b/test/solidlsp/cpp/test_cpp_unreal.py @@ -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}"