diff --git a/.gitattributes b/.gitattributes
index 3a94d56..7dfe4d2 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,5 +1,9 @@
# Set the default behavior, in case people don't have core.autocrlf set.
* text=auto
+# Backend test inputs and golds must be LF everywhere. Some golds record document offsets (navigation/Find Usages
+# RANGE: (78,88)), which shift if a Windows checkout turns the inputs into CRLF. Same rule as JetBrains' own plugins.
+src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/** text eol=lf
+
# Preserve line endings in gradle scripts
gradlew* -text diff
diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml
index d1ea6cb..0beb2de 100644
--- a/.github/workflows/CI.yml
+++ b/.github/workflows/CI.yml
@@ -5,6 +5,7 @@ on:
branches:
- main
pull_request:
+ workflow_dispatch:
jobs:
Build:
@@ -31,21 +32,3 @@ jobs:
with:
name: ${{ github.event.repository.name }}.CI.${{ github.head_ref || github.ref_name }}
path: output
- Test:
- runs-on: ubuntu-latest
- steps:
- - uses: jlumbroso/free-disk-space@main
- - uses: actions/checkout@v4
- with:
- submodules: recursive
- - name: configure_java
- uses: actions/setup-java@v4
- with:
- distribution: 'corretto'
- java-version: '21'
- cache: 'gradle'
- - name: Setup .NET
- uses: actions/setup-dotnet@v4
- with:
- global-json-file: global.json
- - run: ./gradlew :testDotNet --no-daemon
\ No newline at end of file
diff --git a/.github/workflows/Deploy.yml b/.github/workflows/Deploy.yml
index 119b70b..5090ee3 100644
--- a/.github/workflows/Deploy.yml
+++ b/.github/workflows/Deploy.yml
@@ -7,10 +7,16 @@ on:
jobs:
Publish:
- runs-on: ubuntu-latest
+ # Windows so that :publishPlugin's dependency on :testDotNet runs the real backend test suite before anything is
+ # published (the ReSharper test shell only runs on Windows; on Linux every test reports as skipped). Releases are
+ # rare, so the Windows runner cost is acceptable here, unlike on every PR commit (see Tests.yml).
+ runs-on: windows-latest
environment: Deploy
+ defaults:
+ run:
+ # ./gradlew and the output/* globs below are written for bash; Windows runners ship Git Bash.
+ shell: bash
steps:
- - uses: jlumbroso/free-disk-space@main
- uses: actions/checkout@v4
with:
submodules: recursive
@@ -36,6 +42,5 @@ jobs:
- name: Upload binaries to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- shell: bash
run: |
gh release upload ${{ github.ref_name }} output/*
\ No newline at end of file
diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml
new file mode 100644
index 0000000..f523817
--- /dev/null
+++ b/.github/workflows/Tests.yml
@@ -0,0 +1,44 @@
+name: Tests
+
+# Separate from CI.yml so that adding or removing the 'feature-testing' label re-runs only the tests, on the other OS,
+# without touching the Build check.
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ types: [opened, synchronize, reopened, labeled, unlabeled]
+ workflow_dispatch:
+ inputs:
+ windows:
+ description: Run the full backend test suite on Windows
+ type: boolean
+ default: false
+
+jobs:
+ Test:
+ # The backend tests boot the ReSharper test shell, which only runs on Windows (docs/testing-research.md, "Platform").
+ # Windows runners cost more, so by default this runs on Linux, where the suite builds and every test reports as
+ # skipped (WindowsOnlyGuard). The 'feature-testing' PR label (or the workflow_dispatch checkbox) runs it for real.
+ # Other label changes don't re-run anything.
+ if: >-
+ (github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
+ github.event.label.name == 'feature-testing'
+ runs-on: ${{ (contains(github.event.pull_request.labels.*.name, 'feature-testing') || inputs.windows) && 'windows-latest' || 'ubuntu-latest' }}
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+ - name: Cache NuGet packages
+ uses: actions/cache@v4
+ with:
+ path: ~/.nuget/packages
+ key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Build.props', 'src/dotnet/**/*.csproj', 'src/dotnet/**/*.props') }}
+ restore-keys: nuget-${{ runner.os }}-
+ # Same command as Gradle's :testDotNet, without needing Java/Gradle on this runner.
+ - run: dotnet test ReSharperPlugin.RimworldDev.sln --logger GitHubActions
diff --git a/.gitignore b/.gitignore
index f8c1dbd..7844937 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,7 +26,7 @@ _dotTrace*
# Nuget
packages/
-**/NuGetLocks/*
+# NuGetLocks are committed on purpose: the lock files pin test-framework package versions
# Example Mod
example-mod/.idea
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index e7b8979..aea3e4f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -190,7 +190,10 @@ tasks.runIde {
// part of a plugin, but there are dangers about keeping plugins in sync
autoReload = false
- val exampleModSolution = layout.projectDirectory.file("example-mod/AshAndDust.sln").asFile.absolutePath
+ // What the sandbox opens. Defaults to the checked-in example mod; the highlighting fixtures under example-mods/
+ // are opened with e.g. -PrunIdeSolution=example-mods/XmlOnlyMod (a folder is fine, it doesn't have to be a .sln).
+ val solutionToOpen = providers.gradleProperty("runIdeSolution").getOrElse("example-mod/AshAndDust.sln")
+ val exampleModSolution = layout.projectDirectory.file(solutionToOpen).asFile.absolutePath
argumentProviders += CommandLineArgumentProvider {
listOf(exampleModSolution)
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/AcceptCompletion/RimworldXmlTests.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/AcceptCompletion/RimworldXmlTests.cs
new file mode 100644
index 0000000..bbbd20a
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/AcceptCompletion/RimworldXmlTests.cs
@@ -0,0 +1,18 @@
+using JetBrains.ReSharper.FeaturesTestFramework.Completion;
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.CompletionSuggestions;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+namespace ReSharperPlugin.RimworldDev.Tests.AcceptCompletion;
+
+[ProjectLayouts(ProjectLayout.XmlProject, ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldXmlTests(ProjectLayout layout) : RimworldCompletionTestBase(layout)
+{
+ protected override CodeCompletionTestType TestType => CodeCompletionTestType.Action;
+ protected override string RelativeTestDataPath => @"AcceptCompletion\Rimworld";
+
+ [Test] public void TestCompleteTag() => DoNamedTest();
+ [Test] public void TestCompleteEnumValue() => DoNamedTest();
+}
\ No newline at end of file
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/CompletionSuggestions/RimworldCSharpCompletionTests.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/CompletionSuggestions/RimworldCSharpCompletionTests.cs
new file mode 100644
index 0000000..684cc14
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/CompletionSuggestions/RimworldCSharpCompletionTests.cs
@@ -0,0 +1,18 @@
+using JetBrains.ReSharper.FeaturesTestFramework.Completion;
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+namespace ReSharperPlugin.RimworldDev.Tests.CompletionSuggestions;
+
+[TestFileExtension(".cs")]
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+public class RimworldCSharpCompletionTests(ProjectLayout layout) : RimworldCompletionTestBase(layout)
+{
+ protected override CodeCompletionTestType TestType => CodeCompletionTestType.ModernList;
+ protected override string RelativeTestDataPath => @"CompletionSuggestions\RimworldCSharp";
+
+ [Test] public void TestDefOfFieldWithPrefixAndSemicolon() => DoNamedTest("Defs.xml");
+ [Test] public void TestDefOfFieldWithPrefix() => DoNamedTest("Defs.xml");
+ [Test] public void TestDefDatabaseGetNamed() => DoNamedTest("Defs.xml");
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/CompletionSuggestions/RimworldXmlCompletionTests.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/CompletionSuggestions/RimworldXmlCompletionTests.cs
new file mode 100644
index 0000000..c064415
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/CompletionSuggestions/RimworldXmlCompletionTests.cs
@@ -0,0 +1,40 @@
+using JetBrains.ReSharper.FeaturesTestFramework.Completion;
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+namespace ReSharperPlugin.RimworldDev.Tests.CompletionSuggestions;
+
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldXmlCompletionTests(ProjectLayout layout) : RimworldCompletionTestBase(layout)
+{
+ protected override CodeCompletionTestType TestType => CodeCompletionTestType.ModernList;
+ protected override string RelativeTestDataPath => @"CompletionSuggestions\Rimworld";
+
+ // Test Property Names
+ [Test] public void TestDefName() => DoNamedTest();
+ [Test] public void TestThingDefProperties() => DoNamedTest();
+ [Test] public void TestModDefClassProperties() => DoNamedTest("ModTypes.cs");
+ [Test] public void TestNestedFieldProperties() => DoNamedTest();
+ [Test] public void TestListItemProperties() => DoNamedTest();
+ [Test] public void TestListItemWithClassProperties() => DoNamedTest();
+ [Test] public void TestModListItemClassProperties() => DoNamedTest("ModTypes.cs");
+
+ // Test Property Values
+ [Test] public void TestBooleanPropertyValue() => DoNamedTest();
+ [Test] public void TestEnumValue() => DoNamedTest();
+
+ [Test] public void TestStructPropertyValue() => DoNamedTest();
+
+ // Test Def references
+ [Test] public void TestDefReferenceSameFile() => DoNamedTest();
+ [Test] public void TestDefReferenceOtherFile() => DoNamedTest("OtherDefs.xml");
+
+ [Test]
+ public void TestDefsFilterByType() => DoNamedTest("OtherDefs.xml", "StuffCategoryDefs.xml");
+ [Test] public void TestParentName() => DoNamedTest();
+
+ // When a property expects a specific DefType (like ThingDef), modded classes that extend that should be offered
+ [Test] public void TestModDefAsSuperclassReference() => DoNamedTest("ModTypes.cs");
+}
\ No newline at end of file
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/FindUsages/RimworldFindUsagesTests.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/FindUsages/RimworldFindUsagesTests.cs
new file mode 100644
index 0000000..5973ee9
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/FindUsages/RimworldFindUsagesTests.cs
@@ -0,0 +1,32 @@
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.References;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+namespace ReSharperPlugin.RimworldDev.Tests.FindUsages;
+
+///
+/// Find Usages (and the other navigation providers) started from a def, with usages spread across XML and C# files.
+/// XMLTagDeclaredElement + RimworldSearcherFactory/CustomSearcher is the known rough edge; see "Find Usages.md".
+/// The golds record *current* behaviour: usages in XML are found, usages in C# (the [DefOf] field and the
+/// DefDatabase string in CSharpUsages.cs) are not, because RimworldSearcherFactory.IsCompatibleWithLanguage only
+/// accepts XML. Allowing C# there makes both appear (verified), so when that's fixed these golds should gain them.
+///
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldFindUsagesFromXmlTests(ProjectLayout layout) : RimworldNavigationTestBase(layout)
+{
+ protected override string RelativeTestDataPath => "FindUsages";
+
+ [Test] public void TestFromDefName() => DoNamedTest("OtherUsages.xml", "CSharpUsages.cs");
+ [Test] public void TestFromNameAttribute() => DoNamedTest();
+}
+
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+[TestFileExtension(".cs")]
+public class RimworldFindUsagesFromCSharpTests(ProjectLayout layout) : RimworldNavigationTestBase(layout)
+{
+ protected override string RelativeTestDataPath => "FindUsages";
+
+ [Test] public void TestFromCSharpString() => DoNamedTest("Defs.xml", "OtherUsages.xml");
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Generate/RimworldGenerateTests.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Generate/RimworldGenerateTests.cs
new file mode 100644
index 0000000..2e8ff20
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Generate/RimworldGenerateTests.cs
@@ -0,0 +1,20 @@
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+namespace ReSharperPlugin.RimworldDev.Tests.Generate;
+
+///
+/// This tests the `Alt+Insert` Generation menu inside a Def.
+///
+[ProjectLayouts(ProjectLayout.XmlProject, ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldGenerateTests(ProjectLayout layout) : RimworldGenerateTestBase(layout)
+{
+ protected override string RelativeTestDataPath => @"Generate";
+
+ [ProjectLayouts(ProjectLayout.XmlProject)]
+ [Test] public void TestGenerateProperties() => DoNamedTest();
+
+ [Test] public void TestGenerateInListItem() => DoNamedTest();
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Highlighting/RimworldXmlHighlightingTests.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Highlighting/RimworldXmlHighlightingTests.cs
new file mode 100644
index 0000000..c1facd9
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Highlighting/RimworldXmlHighlightingTests.cs
@@ -0,0 +1,31 @@
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+namespace ReSharperPlugin.RimworldDev.Tests.Highlighting;
+
+[ProjectLayouts(ProjectLayout.XmlProject, ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldXmlHighlightingTests(ProjectLayout layout) : RimworldHighlightingTestBase(layout)
+{
+ protected override string RelativeTestDataPath => @"Highlighting";
+
+ [Test] public void TestValidValues() => DoNamedTest();
+
+ ///
+ /// Float values are only reported when loaded up XmlProjects, they don't actually get matched properly in CSharp
+ /// projects
+ ///
+ [ProjectLayouts(ProjectLayout.XmlProject)]
+ [Test] public void TestInvalidValues() => DoNamedTest();
+}
+
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldXmlHighlightingWithoutRimworldTests(ProjectLayout layout) : RimworldHighlightingTestBase(layout)
+{
+ protected override string RelativeTestDataPath => @"Highlighting";
+ protected override bool ReferenceRimworld => false;
+
+ [Test] public void TestWithoutRimworld() => DoNamedTest();
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Navigation/RimworldNavigationAfterEdits.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Navigation/RimworldNavigationAfterEdits.cs
new file mode 100644
index 0000000..24afacf
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Navigation/RimworldNavigationAfterEdits.cs
@@ -0,0 +1,58 @@
+using System.Linq;
+using JetBrains.Lifetimes;
+using JetBrains.ProjectModel;
+using JetBrains.ReSharper.Psi;
+using JetBrains.ReSharper.Psi.Files;
+using JetBrains.ReSharper.Psi.Resolve;
+using JetBrains.ReSharper.Psi.Tree;
+using JetBrains.ReSharper.Resources.Shell;
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+namespace ReSharperPlugin.RimworldDev.Tests.Navigation;
+
+///
+/// We previously had an issue around keeping stale references to ITreeNodes in our Symbol Cache, which meant that if
+/// Rider attempted to access it from our Symbol Cache (for navigation for example) after the file had been edited but
+/// before we'd rebuilt our cache then we'd get an error about trying to read an uncommited PSI Document. The fix for
+/// that was to store data that allows us to look up the real ITreeNode, and then do that lookup on demand rather than
+/// storing the ITreeNode itself.
+///
+/// These tests exist to act as a regression test against that behavior coming back.
+///
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldNavigationAfterEditTests(ProjectLayout layout) : RimworldNavigationTestBase(layout)
+{
+ private const string DefsFile = "MovingDefs.xml";
+ private const string ThingALine = " ThingA\n";
+
+ protected override string RelativeTestDataPath => "Navigation";
+
+ [Test] public void TestNavigateAfterDefsMove() => DoNamedTest(DefsFile);
+
+ protected override void DoTest(Lifetime lifetime, IProject testProject)
+ {
+ var files = Solution.GetPsiServices().Files;
+ var document = testProject.GetAllProjectFiles().Single(file => file.Name == DefsFile).ToSourceFiles().Single()
+ .Document;
+
+ files.CommitAllDocuments();
+ using (ReadLockCookie.Create())
+ {
+ var psiFile = testProject.GetAllProjectFiles().Single(file => file.Name != DefsFile).ToSourceFiles()
+ .Single().GetPrimaryPsiFile()!;
+
+ foreach (var node in psiFile.Descendants().ToEnumerable())
+ foreach (var reference in node.GetReferences())
+ reference.Resolve();
+ }
+
+ using (WriteLockCookie.Create())
+ document.InsertText(document.GetText().IndexOf(" -->"), ThingALine);
+ files.CommitAllDocuments();
+
+ base.DoTest(lifetime, testProject);
+ }
+}
\ No newline at end of file
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Navigation/RimworldNavigationTests.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Navigation/RimworldNavigationTests.cs
new file mode 100644
index 0000000..d5bb1a2
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/Navigation/RimworldNavigationTests.cs
@@ -0,0 +1,31 @@
+using JetBrains.Lifetimes;
+using JetBrains.ProjectModel;
+using JetBrains.ReSharper.Psi;
+using JetBrains.ReSharper.Psi.Files;
+using JetBrains.ReSharper.Psi.Resolve;
+using JetBrains.ReSharper.Psi.Tree;
+using JetBrains.ReSharper.Resources.Shell;
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+using System.Linq;
+
+namespace ReSharperPlugin.RimworldDev.Tests.Navigation;
+
+///
+/// These tests essentially emulate us doing a Ctrl+Click in the IDE. The gold files should show what options were
+/// presented to the IDE (not the user) from what sources. The distinction is that the same reference/result may be
+/// presented to the IDE form multiple sources and that'll be deduplicated to a single option for the IDE.
+///
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldNavigationTests(ProjectLayout layout) : RimworldNavigationTestBase(layout)
+{
+ protected override string RelativeTestDataPath => "Navigation";
+
+ [Test] public void TestNavigatePropertyToCSharpField() => DoNamedTest();
+ [Test] public void TestNavigateValueToXmlDef() => DoNamedTest();
+ [Test] public void TestNavigateValueToCSharpEnum() => DoNamedTest();
+}
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ProjectLayout.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ProjectLayout.cs
new file mode 100644
index 0000000..5f50e1b
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ProjectLayout.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using JetBrains.ProjectModel;
+using JetBrains.Util;
+using JetBrains.Util.Dotnet.TargetFrameworkIds;
+using NUnit.Framework;
+using NUnit.Framework.Interfaces;
+using NUnit.Framework.Internal;
+using NUnit.Framework.Internal.Builders;
+
+namespace ReSharperPlugin.RimworldDev.Tests;
+
+/// How the solution under test is laid out. It changes what the plugin sees, so most suites want both.
+public enum ProjectLayout
+{
+ /// Everything in one C# project, which is what the SDK's test bases build by default.
+ CSharpProject,
+
+ ///
+ /// What a mod looks like: the Defs in a project of their own with no references, RimWorld referenced by a C#
+ /// project beside it. Some values are validated here and not in a C# project; see boundary 8 in
+ /// docs/testing-plan.md.
+ ///
+ XmlProject
+}
+
+/// A fixture built for one layout. The layout-aware test bases implement it; tests don't need to.
+public interface IProjectLayoutFixture
+{
+ ProjectLayout Layout { get; }
+}
+
+///
+/// On a fixture, builds one of it per layout listed, so every test in the class runs once in each:
+///
+/// [ProjectLayouts(ProjectLayout.XmlProject, ProjectLayout.CSharpProject)]
+///
+/// On a test method, narrows that one test to the layouts listed and skips it in the others.
+///
+/// It also resets ScopeHelper around every test it applies to, which is why the bases don't need a SetUp for it.
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public class ProjectLayoutsAttribute(params ProjectLayout[] layouts) : NUnitAttribute, IFixtureBuilder2, ITestAction
+{
+ public IReadOnlyList Layouts { get; } = layouts;
+
+ public IEnumerable BuildFrom(ITypeInfo typeInfo) => BuildFrom(typeInfo, MatchEverything.Instance);
+
+ public IEnumerable BuildFrom(ITypeInfo typeInfo, IPreFilter filter) =>
+ layouts.Select(layout =>
+ new NUnitTestFixtureBuilder().BuildFrom(typeInfo, filter, new TestFixtureParameters(layout)));
+
+ public ActionTargets Targets => ActionTargets.Test;
+
+ public void BeforeTest(ITest test)
+ {
+ if (test.Fixture is IProjectLayoutFixture fixture && !Layouts.Contains(fixture.Layout))
+ Assert.Ignore($"Only runs in {string.Join(", ", Layouts)}");
+
+ // ScopeHelper caches the RimWorld scope in statics; without this the next test reuses a scope from a solution
+ // that no longer exists. The discovery switch stops it from finding a real RimWorld install on this machine.
+ ScopeHelper.Reset();
+ ScopeHelper.SkipAssemblyDiscovery = true;
+ }
+
+ public void AfterTest(ITest test) => ScopeHelper.Reset();
+
+ /// NUnit's own empty filter is internal, and this is all of it that gets used.
+ private class MatchEverything : IPreFilter
+ {
+ public static readonly IPreFilter Instance = new MatchEverything();
+
+ public bool IsMatch(Type type) => true;
+ public bool IsMatch(Type type, MethodInfo method) => true;
+ }
+}
+
+///
+/// Everything takes, so that a test base wiring it up is five one-line
+/// overrides and nothing else. A base for an SDK test class that doesn't have one yet is a copy of those five.
+///
+public static class ProjectLayoutSupport
+{
+ private const string XmlProjectName = "RimworldXmlProject";
+ private const string CSharpProjectName = "ModAssembly";
+ private const string CSharpProjectFile = "ModAssembly.cs";
+
+ /// What the two projects are called, given what the SDK named them. Call it from the base's constructor.
+ public static (string ProjectName, string SecondProjectName) ProjectNames(
+ ProjectLayout layout, string projectName, string secondProjectName) =>
+ layout == ProjectLayout.XmlProject
+ ? (XmlProjectName, CSharpProjectName)
+ : (projectName, secondProjectName);
+
+ /// A solution built by a fixture in the other layout has the wrong projects in it to reuse.
+ public static bool CanReuse(bool baseResult, ISolution solution, string projectName) =>
+ baseResult && solution.GetAllProjects().Any(project => project.Name == projectName);
+
+ /// The game's assemblies, unless the fixture is one that checks life without them.
+ public static IEnumerable ReferencedAssemblies(IEnumerable baseAssemblies, bool referenceRimworld) =>
+ referenceRimworld ? baseAssemblies.Concat(RimworldReferenceAssemblies()) : baseAssemblies;
+
+ ///
+ /// Every DLL from the Krafs package's ref/net472 folder except the framework ones, which the test platform
+ /// already provides. The folder path is baked into this assembly by the csproj from NuGet's restore.
+ ///
+ private static IEnumerable RimworldReferenceAssemblies()
+ {
+ var refDir = typeof(ProjectLayoutSupport).Assembly
+ .GetCustomAttributes()
+ .Single(attribute => attribute.Key == "RimworldRefDir").Value;
+
+ return Directory.GetFiles(refDir, "*.dll")
+ .Where(path =>
+ {
+ var name = Path.GetFileName(path);
+ return !name.StartsWith("mscorlib", StringComparison.OrdinalIgnoreCase) &&
+ !name.StartsWith("System", StringComparison.OrdinalIgnoreCase) &&
+ !name.StartsWith("netstandard", StringComparison.OrdinalIgnoreCase) &&
+ !name.StartsWith("Mono.", StringComparison.OrdinalIgnoreCase);
+ });
+ }
+
+ /// Only the XML project loses its references; that is what makes it behave like a mod's Defs folder.
+ public static ICollection>> Libraries(
+ ProjectLayout layout, string projectName, string mainProjectName,
+ ICollection>> libraries) =>
+ layout == ProjectLayout.XmlProject && projectName == mainProjectName
+ ? libraries
+ .Select(pair =>
+ new KeyValuePair>(pair.Key, EmptyList.Instance))
+ .ToList()
+ : libraries;
+
+ ///
+ /// Builds the solution the way the layout wants it: one project, or the Defs in the XML project and the .cs files
+ /// in the mod's assembly beside it. The C# project holds test/data/ModAssembly.cs, shared by every suite.
+ ///
+ public static void BuildSolution(ProjectLayout layout, string testFile, IEnumerable otherFiles,
+ string relativeTestDataPath, Action singleProject, Action twoProjects)
+ {
+ var others = otherFiles.AsList();
+
+ if (layout != ProjectLayout.XmlProject)
+ {
+ singleProject(new[] { testFile }.Concat(others).ToArray());
+ return;
+ }
+
+ var depth = relativeTestDataPath.Split('/', '\\').Count(part => part.Length > 0);
+ var sharedFile = string.Concat(Enumerable.Repeat(@"..\", depth)) + CSharpProjectFile;
+
+ twoProjects(
+ new[] { testFile }.Concat(others.Where(file => !file.EndsWith(".cs"))).ToArray(),
+ others.Where(file => file.EndsWith(".cs")).Concat(new[] { sharedFile }).ToArray());
+ }
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ReSharperPlugin.RimworldDev.Tests.csproj b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ReSharperPlugin.RimworldDev.Tests.csproj
index 8f8bcdd..a01143c 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ReSharperPlugin.RimworldDev.Tests.csproj
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ReSharperPlugin.RimworldDev.Tests.csproj
@@ -1,12 +1,57 @@
+
+ net10.0-windows
+
+ true
+ true
+
+
+
+ net10.0
+ false
+ false
+
+
+
+
+ net472false
+ latest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_Parameter1>RimworldRefDir
+ <_Parameter2>$(PkgKrafs_Rimworld_Ref)\ref\net472
+
@@ -16,6 +61,34 @@
+
+
+
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+ <_JetPinnedLib Include="@(RuntimeCopyLocalItems)"
+ Condition="'%(RuntimeCopyLocalItems.Extension)' == '.dll' And ('%(RuntimeCopyLocalItems.NuGetPackageId)' == 'JetBrains.Lifetimes' Or '%(RuntimeCopyLocalItems.NuGetPackageId)' == 'JetBrains.RdFramework')" />
+ <_JetNetFxLib Include="@(_JetPinnedLib->'%(RootDir)%(Directory)..\net472\%(Filename)%(Extension)')" />
+
+
+
+
+
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/References/RimworldReferenceTests.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/References/RimworldReferenceTests.cs
new file mode 100644
index 0000000..4f67cb9
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/References/RimworldReferenceTests.cs
@@ -0,0 +1,24 @@
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+using ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+namespace ReSharperPlugin.RimworldDev.Tests.References;
+
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+[TestFileExtension(".xml")]
+public class RimworldReferencesFromXmlTests(ProjectLayout layout) : RimworldReferenceTestBase(layout)
+{
+ protected override string RelativeTestDataPath => @"References";
+
+ [Test] public void TestXmlToCSharp() => DoNamedTest();
+ [Test] public void TestXmlToXmlDef() => DoNamedTest("OtherDefs.xml");
+}
+
+[ProjectLayouts(ProjectLayout.CSharpProject)]
+[TestFileExtension(".cs")]
+public class RimworldReferencesFromCSharpTests(ProjectLayout layout) : RimworldReferenceTestBase(layout)
+{
+ protected override string RelativeTestDataPath => @"References";
+
+ [Test] public void TestCSharpToXmlDef() => DoNamedTest("CSharpDefs.xml");
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/RimworldDevTestEnvironmentZone.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/RimworldDevTestEnvironmentZone.cs
new file mode 100644
index 0000000..9a842ac
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/RimworldDevTestEnvironmentZone.cs
@@ -0,0 +1,16 @@
+using System.Threading;
+using JetBrains.Application.BuildScript.Application.Zones;
+using JetBrains.ReSharper.TestFramework;
+using JetBrains.TestFramework.Application.Zones;
+using NUnit.Framework;
+
+#if WINDOWS
+// WINDOWS is defined for the net10.0-windows build only. Elsewhere NUnit can't honour STA and would silently run nothing;
+// the tests are skipped there by WindowsOnlyGuard instead.
+[assembly: Apartment(ApartmentState.STA)]
+#endif
+
+namespace ReSharperPlugin.RimworldDev.Tests;
+
+[ZoneDefinition]
+public class RimworldDevTestEnvironmentZone : ITestsEnvZone, IRequire;
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/RimworldDevTestsAssembly.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/RimworldDevTestsAssembly.cs
new file mode 100644
index 0000000..90e8fec
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/RimworldDevTestsAssembly.cs
@@ -0,0 +1,7 @@
+using JetBrains.TestFramework;
+using NUnit.Framework;
+
+namespace ReSharperPlugin.RimworldDev.Tests;
+
+[SetUpFixture]
+public class RimworldDevTestsAssembly : ExtensionTestEnvironmentAssembly;
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/CSharpCompletion.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/CSharpCompletion.cs
new file mode 100644
index 0000000..b4ee495
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/CSharpCompletion.cs
@@ -0,0 +1,19 @@
+using JetBrains.ReSharper.FeaturesTestFramework.Completion;
+using NUnit.Framework;
+
+namespace ReSharperPlugin.RimworldDev.Tests.SmokeTests;
+
+///
+/// The smoke tests are here to prove that tests, in general, are functioning. This is in case we do an upgrade and find
+/// our automated tests are no longer functioning. It allows us to narrow it down to whether tests are broken, parsing
+/// is broken, completion is broken or just our specific tests are broken.
+///
+/// This test is specific to completion and just tests if we can assert on autocompleting a C# variable.
+///
+public class CSharpCompletion : CodeCompletionTestBase
+{
+ protected override CodeCompletionTestType TestType => CodeCompletionTestType.ModernList;
+ protected override string RelativeTestDataPath => @"SmokeTests\CSharp";
+
+ [Test] public void TestLocalVariable() => DoNamedTest();
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/ShellStartsTest.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/ShellStartsTest.cs
new file mode 100644
index 0000000..0c40d0e
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/ShellStartsTest.cs
@@ -0,0 +1,20 @@
+using JetBrains.TestFramework;
+using NUnit.Framework;
+
+namespace ReSharperPlugin.RimworldDev.Tests.SmokeTests;
+
+///
+/// The smoke tests are here to prove that tests, in general, are functioning. This is in case we do an upgrade and find
+/// our automated tests are no longer functioning. It allows us to narrow it down to whether tests are broken, parsing
+/// is broken, completion is broken or just our specific tests are broken.
+///
+/// This test is just checking that we can start tests in the first place.
+///
+public class ShellStartsTest : BaseTest
+{
+ [Test]
+ public void ShellStarts()
+ {
+ Assert.That(ShellInstance, Is.Not.Null);
+ }
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/XmlParsing.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/XmlParsing.cs
new file mode 100644
index 0000000..d737160
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/SmokeTests/XmlParsing.cs
@@ -0,0 +1,46 @@
+using System.Linq;
+using JetBrains.Lifetimes;
+using JetBrains.ProjectModel;
+using JetBrains.ReSharper.Psi;
+using JetBrains.ReSharper.Psi.Files;
+using JetBrains.ReSharper.Psi.Tree;
+using JetBrains.ReSharper.Psi.Xml.Tree;
+using JetBrains.ReSharper.Resources.Shell;
+using JetBrains.ReSharper.TestFramework;
+using NUnit.Framework;
+
+namespace ReSharperPlugin.RimworldDev.Tests.SmokeTests;
+
+///
+/// The smoke tests are here to prove that tests, in general, are functioning. This is in case we do an upgrade and find
+/// our automated tests are no longer functioning. It allows us to narrow it down to whether tests are broken, parsing
+/// is broken, completion is broken or just our specific tests are broken.
+///
+/// This test is about whether we can parse XML files.
+///
+[TestFileExtension(".xml")]
+public class XmlParsing : BaseTestWithSingleProject
+{
+ protected override string RelativeTestDataPath => @"SmokeTests\Xml";
+
+ [Test] public void TestXmlIsParsed() => DoTestSolution("XmlIsParsed.xml");
+
+ protected override void DoTest(Lifetime lifetime, IProject project)
+ {
+ Solution.GetPsiServices().Files.CommitAllDocuments();
+ using (ReadLockCookie.Create())
+ {
+ var projectFile = project.GetAllProjectFiles().Single();
+ var sourceFile = projectFile.ToSourceFiles().Single();
+ var psiFile = sourceFile.GetPrimaryPsiFile();
+
+ ExecuteWithGold(projectFile, writer =>
+ {
+ writer.WriteLine($"ProjectFileType: {projectFile.LanguageType.Name}");
+ writer.WriteLine($"PrimaryPsiLanguage: {sourceFile.PrimaryPsiLanguage.Name}");
+ writer.WriteLine($"PsiFile: {psiFile?.GetType().FullName ?? ""}");
+ writer.WriteLine($"XmlTags: {psiFile?.Descendants().ToEnumerable().Count() ?? -1}");
+ });
+ }
+ }
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldCompletionTestBase.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldCompletionTestBase.cs
new file mode 100644
index 0000000..bd68604
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldCompletionTestBase.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using JetBrains.ProjectModel;
+using JetBrains.ProjectModel.Update;
+using JetBrains.Util;
+using JetBrains.Util.Dotnet.TargetFrameworkIds;
+using JetBrains.ReSharper.FeaturesTestFramework.Completion;
+
+namespace ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+///
+/// Completion tests backed by the game's types, in the layout the fixture asks for. Krafs.Rimworld.Ref (a complete
+/// reference assembly for RimWorld) is referenced into the in-memory project, and ScopeHelper finds it the same way it
+/// finds the real Assembly-CSharp.dll: by looking for Verse.ThingDef.
+///
+public abstract class RimworldCompletionTestBase : CodeCompletionTestBase, IProjectLayoutFixture
+{
+ protected RimworldCompletionTestBase(ProjectLayout layout)
+ {
+ Layout = layout;
+ (ProjectName, SecondProjectName) = ProjectLayoutSupport.ProjectNames(layout, ProjectName, SecondProjectName);
+ }
+
+ public ProjectLayout Layout { get; }
+
+ /// Off for fixtures that check what happens with no RimWorld types around.
+ protected virtual bool ReferenceRimworld => true;
+
+ protected override bool CanReuseSolution(ISolution solution) =>
+ ProjectLayoutSupport.CanReuse(base.CanReuseSolution(solution), solution, ProjectName);
+
+ protected override IEnumerable GetReferencedAssemblies(TargetFrameworkId targetFrameworkId) =>
+ ProjectLayoutSupport.ReferencedAssemblies(base.GetReferencedAssemblies(targetFrameworkId), ReferenceRimworld);
+
+ protected override Pair>>
+ CreateProjectDescriptor(string projectName, string outputAssemblyName,
+ ICollection absoluteFileSet,
+ ICollection>> libraries, Guid projectGuid,
+ FileSystemPath projectLocation = null) =>
+ base.CreateProjectDescriptor(projectName, outputAssemblyName, absoluteFileSet,
+ ProjectLayoutSupport.Libraries(Layout, projectName, ProjectName, libraries), projectGuid, projectLocation);
+
+ protected override void DoNamedTest(params string[] otherFiles) =>
+ ProjectLayoutSupport.BuildSolution(Layout, TestName, otherFiles, RelativeTestDataPath,
+ _ => base.DoNamedTest(otherFiles),
+ (xmlProjectFiles, cSharpProjectFiles) => DoTestSolution(xmlProjectFiles, cSharpProjectFiles));
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldGenerateTestBase.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldGenerateTestBase.cs
new file mode 100644
index 0000000..2db7c44
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldGenerateTestBase.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using JetBrains.ProjectModel;
+using JetBrains.ProjectModel.Update;
+using JetBrains.Util;
+using JetBrains.Util.Dotnet.TargetFrameworkIds;
+using JetBrains.Lifetimes;
+using JetBrains.ReSharper.Feature.Services.Generate;
+using JetBrains.ReSharper.Resources.Shell;
+using JetBrains.ReSharper.FeaturesTestFramework.Generate;
+using JetBrains.ReSharper.Psi;
+using JetBrains.TextControl;
+
+namespace ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+/// The Generate menu the way the IDE runs it, in the layout the fixture asks for.
+public abstract class RimworldGenerateTestBase : GenerateTestBase, IProjectLayoutFixture
+{
+ protected RimworldGenerateTestBase(ProjectLayout layout)
+ {
+ Layout = layout;
+ (ProjectName, SecondProjectName) = ProjectLayoutSupport.ProjectNames(layout, ProjectName, SecondProjectName);
+ }
+
+ public ProjectLayout Layout { get; }
+
+ /// Off for fixtures that check what happens with no RimWorld types around.
+ protected virtual bool ReferenceRimworld => true;
+
+ protected override bool CanReuseSolution(ISolution solution) =>
+ ProjectLayoutSupport.CanReuse(base.CanReuseSolution(solution), solution, ProjectName);
+
+ protected override IEnumerable GetReferencedAssemblies(TargetFrameworkId targetFrameworkId) =>
+ ProjectLayoutSupport.ReferencedAssemblies(base.GetReferencedAssemblies(targetFrameworkId), ReferenceRimworld);
+
+ protected override Pair>>
+ CreateProjectDescriptor(string projectName, string outputAssemblyName,
+ ICollection absoluteFileSet,
+ ICollection>> libraries, Guid projectGuid,
+ FileSystemPath projectLocation = null) =>
+ base.CreateProjectDescriptor(projectName, outputAssemblyName, absoluteFileSet,
+ ProjectLayoutSupport.Libraries(Layout, projectName, ProjectName, libraries), projectGuid, projectLocation);
+
+ // GenerateTestBase decapitalises the first test file, and the gold is named after the file it ends up with
+ protected override void DoNamedTest(params string[] otherFiles) =>
+ ProjectLayoutSupport.BuildSolution(Layout, ModifyTestFiles([TestName]).Single(), otherFiles,
+ RelativeTestDataPath,
+ _ => base.DoNamedTest(otherFiles),
+ (xmlProjectFiles, cSharpProjectFiles) => DoTestSolution(xmlProjectFiles, cSharpProjectFiles));
+
+ // The generator writes tags straight into the tree, which in the IDE runs under the action's own write lock
+ protected override void DoTest(Lifetime lifetime, IProject testProject)
+ {
+ using (WriteLockCookie.Create())
+ base.DoTest(lifetime, testProject);
+ }
+
+ // The generator reads the scopes from ScopeHelper's statics, which in the IDE another feature has filled in by now
+ protected override IGeneratorWorkflow CreateWorkflow(string kind, ITextControl textControl,
+ IPsiSourceFile sourceFile)
+ {
+ ScopeHelper.UpdateScopes(Solution);
+
+ return base.CreateWorkflow(kind, textControl, sourceFile);
+ }
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldHighlightingTestBase.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldHighlightingTestBase.cs
new file mode 100644
index 0000000..18f8f61
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldHighlightingTestBase.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using JetBrains.ProjectModel;
+using JetBrains.ProjectModel.Update;
+using JetBrains.Util;
+using JetBrains.Util.Dotnet.TargetFrameworkIds;
+using JetBrains.ReSharper.FeaturesTestFramework.Daemon;
+using JetBrains.ReSharper.Psi;
+using JetBrains.ReSharper.Psi.Xml;
+
+namespace ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+/// Highlighting tests against the game's types, in the layout the fixture asks for.
+public abstract class RimworldHighlightingTestBase : HighlightingTestBase, IProjectLayoutFixture
+{
+ protected override PsiLanguageType CompilerIdsLanguage => XmlLanguage.Instance;
+
+ protected RimworldHighlightingTestBase(ProjectLayout layout)
+ {
+ Layout = layout;
+ (ProjectName, SecondProjectName) = ProjectLayoutSupport.ProjectNames(layout, ProjectName, SecondProjectName);
+ }
+
+ public ProjectLayout Layout { get; }
+
+ /// Off for fixtures that check what happens with no RimWorld types around.
+ protected virtual bool ReferenceRimworld => true;
+
+ protected override bool CanReuseSolution(ISolution solution) =>
+ ProjectLayoutSupport.CanReuse(base.CanReuseSolution(solution), solution, ProjectName);
+
+ protected override IEnumerable GetReferencedAssemblies(TargetFrameworkId targetFrameworkId) =>
+ ProjectLayoutSupport.ReferencedAssemblies(base.GetReferencedAssemblies(targetFrameworkId), ReferenceRimworld);
+
+ protected override Pair>>
+ CreateProjectDescriptor(string projectName, string outputAssemblyName,
+ ICollection absoluteFileSet,
+ ICollection>> libraries, Guid projectGuid,
+ FileSystemPath projectLocation = null) =>
+ base.CreateProjectDescriptor(projectName, outputAssemblyName, absoluteFileSet,
+ ProjectLayoutSupport.Libraries(Layout, projectName, ProjectName, libraries), projectGuid, projectLocation);
+
+ protected override void DoNamedTest(params string[] auxFiles) =>
+ ProjectLayoutSupport.BuildSolution(Layout, TestName, auxFiles, RelativeTestDataPath,
+ _ => base.DoNamedTest(auxFiles),
+ (xmlProjectFiles, cSharpProjectFiles) => DoTestSolution(xmlProjectFiles, cSharpProjectFiles));
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldNavigationTestBase.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldNavigationTestBase.cs
new file mode 100644
index 0000000..79e4097
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldNavigationTestBase.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using JetBrains.ProjectModel;
+using JetBrains.ProjectModel.Update;
+using JetBrains.Util;
+using JetBrains.Util.Dotnet.TargetFrameworkIds;
+using JetBrains.ReSharper.IntentionsTests.Navigation;
+
+namespace ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+/// Navigation and Find Usages the way the IDE runs them, in the layout the fixture asks for.
+public abstract class RimworldNavigationTestBase : AllNavigationProvidersTestBase, IProjectLayoutFixture
+{
+ protected sealed override string ExtraPath => "";
+
+ protected RimworldNavigationTestBase(ProjectLayout layout)
+ {
+ Layout = layout;
+ (ProjectName, SecondProjectName) = ProjectLayoutSupport.ProjectNames(layout, ProjectName, SecondProjectName);
+ }
+
+ public ProjectLayout Layout { get; }
+
+ /// Off for fixtures that check what happens with no RimWorld types around.
+ protected virtual bool ReferenceRimworld => true;
+
+ protected override bool CanReuseSolution(ISolution solution) =>
+ ProjectLayoutSupport.CanReuse(base.CanReuseSolution(solution), solution, ProjectName);
+
+ protected override IEnumerable GetReferencedAssemblies(TargetFrameworkId targetFrameworkId) =>
+ ProjectLayoutSupport.ReferencedAssemblies(base.GetReferencedAssemblies(targetFrameworkId), ReferenceRimworld);
+
+ protected override Pair>>
+ CreateProjectDescriptor(string projectName, string outputAssemblyName,
+ ICollection absoluteFileSet,
+ ICollection>> libraries, Guid projectGuid,
+ FileSystemPath projectLocation = null) =>
+ base.CreateProjectDescriptor(projectName, outputAssemblyName, absoluteFileSet,
+ ProjectLayoutSupport.Libraries(Layout, projectName, ProjectName, libraries), projectGuid, projectLocation);
+
+ protected override void DoNamedTest(params string[] otherFiles) =>
+ ProjectLayoutSupport.BuildSolution(Layout, TestName, otherFiles, RelativeTestDataPath,
+ _ => base.DoNamedTest(otherFiles),
+ (xmlProjectFiles, cSharpProjectFiles) => DoTestSolution(xmlProjectFiles, cSharpProjectFiles));
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldReferenceTestBase.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldReferenceTestBase.cs
new file mode 100644
index 0000000..6a5130f
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/TestBases/RimworldReferenceTestBase.cs
@@ -0,0 +1,48 @@
+using System;
+using System.Collections.Generic;
+using JetBrains.ProjectModel;
+using JetBrains.ProjectModel.Update;
+using JetBrains.Util;
+using JetBrains.Util.Dotnet.TargetFrameworkIds;
+using JetBrains.ReSharper.Psi.Resolve;
+using JetBrains.ReSharper.TestFramework;
+
+namespace ReSharperPlugin.RimworldDev.Tests.TestBases;
+
+/// Reference resolution the way the SDK's own resolve tests check it, in the layout the fixture asks for.
+public abstract class RimworldReferenceTestBase : ReferenceTestBase, IProjectLayoutFixture
+{
+ protected RimworldReferenceTestBase(ProjectLayout layout)
+ {
+ Layout = layout;
+ (ProjectName, SecondProjectName) = ProjectLayoutSupport.ProjectNames(layout, ProjectName, SecondProjectName);
+ }
+
+ public ProjectLayout Layout { get; }
+
+ /// Off for fixtures that check what happens with no RimWorld types around.
+ protected virtual bool ReferenceRimworld => true;
+
+ protected override bool CanReuseSolution(ISolution solution) =>
+ ProjectLayoutSupport.CanReuse(base.CanReuseSolution(solution), solution, ProjectName);
+
+ protected override IEnumerable GetReferencedAssemblies(TargetFrameworkId targetFrameworkId) =>
+ ProjectLayoutSupport.ReferencedAssemblies(base.GetReferencedAssemblies(targetFrameworkId), ReferenceRimworld);
+
+ protected override Pair>>
+ CreateProjectDescriptor(string projectName, string outputAssemblyName,
+ ICollection absoluteFileSet,
+ ICollection>> libraries, Guid projectGuid,
+ FileSystemPath projectLocation = null) =>
+ base.CreateProjectDescriptor(projectName, outputAssemblyName, absoluteFileSet,
+ ProjectLayoutSupport.Libraries(Layout, projectName, ProjectName, libraries), projectGuid, projectLocation);
+
+ protected override void DoNamedTest(params string[] otherFiles) =>
+ ProjectLayoutSupport.BuildSolution(Layout, TestName, otherFiles, RelativeTestDataPath,
+ _ => base.DoNamedTest(otherFiles),
+ (xmlProjectFiles, cSharpProjectFiles) => DoTestSolution(xmlProjectFiles, cSharpProjectFiles));
+
+ // Only the plugin's references; a C# file is otherwise full of ordinary type/namespace references.
+ protected override bool AcceptReference(IReference reference) =>
+ reference.GetType().Assembly == typeof(ScopeHelper).Assembly;
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/WindowsOnlyGuard.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/WindowsOnlyGuard.cs
new file mode 100644
index 0000000..b02f408
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/WindowsOnlyGuard.cs
@@ -0,0 +1,24 @@
+using System;
+using NUnit.Framework;
+
+///
+/// Everything in this assembly boots the ReSharper test shell, which needs the Windows Desktop runtime (WPF/WinForms)
+/// and can't run elsewhere (docs/testing-research.md, "Platform"). Off Windows this reports every test as skipped, with
+/// the reason, instead of letting the shell crash on startup.
+///
+///
+/// Deliberately outside any namespace: NUnit runs set-up fixtures from the outermost namespace in, so this runs before
+/// RimworldDevTestsAssembly (which boots the shell), and Assert.Ignore here marks each test beneath it as
+/// ignored. An assembly-level [Platform] doesn't work for this: the adapter then reports "No test is available"
+/// and nothing else, which reads as a pass. OS-agnostic tests would need their own assembly.
+///
+[SetUpFixture]
+public class WindowsOnlyGuard
+{
+ [OneTimeSetUp]
+ public void RequireWindows()
+ {
+ if (!OperatingSystem.IsWindows())
+ Assert.Ignore("The ReSharper test shell only runs on Windows; run the suite on Windows (CI: add the 'feature-testing' PR label).");
+ }
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ZoneMarker.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ZoneMarker.cs
new file mode 100644
index 0000000..56d8ed6
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/ZoneMarker.cs
@@ -0,0 +1,6 @@
+using JetBrains.Application.BuildScript.Application.Zones;
+
+namespace ReSharperPlugin.RimworldDev.Tests;
+
+[ZoneMarker]
+public class ZoneMarker : IRequire;
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteEnumValue.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteEnumValue.xml
new file mode 100644
index 0000000..2aabcd3
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteEnumValue.xml
@@ -0,0 +1,8 @@
+
+
+
+
+ TestThing
+ {caret}
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteEnumValue.xml.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteEnumValue.xml.gold
new file mode 100644
index 0000000..1621cb0
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteEnumValue.xml.gold
@@ -0,0 +1,8 @@
+
+
+
+
+ TestThing
+ Building{caret}
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteTag.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteTag.xml
new file mode 100644
index 0000000..d068a3d
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteTag.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ TestThing
+
+ <{caret}
+
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteTag.xml.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteTag.xml.gold
new file mode 100644
index 0000000..966b725
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/AcceptCompletion/Rimworld/TestCompleteTag.xml.gold
@@ -0,0 +1,10 @@
+
+
+
+
+ TestThing
+
+
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/ModTypes.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/ModTypes.cs
new file mode 100644
index 0000000..31a09b6
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/ModTypes.cs
@@ -0,0 +1,12 @@
+namespace MyMod
+{
+ public class CustomThingDef : Verse.ThingDef
+ {
+ public int customField;
+ }
+
+ public class CompProperties_Custom : Verse.CompProperties
+ {
+ public float customValue;
+ }
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/OtherDefs.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/OtherDefs.xml
new file mode 100644
index 0000000..0a5f5f5
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/OtherDefs.xml
@@ -0,0 +1,9 @@
+
+
+
+ OtherThing
+
+
+ NotAThing
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/StuffCategoryDefs.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/StuffCategoryDefs.xml
new file mode 100644
index 0000000..84f9491
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/StuffCategoryDefs.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ Metallic
+
+ metal
+ BuildingDestroyed_Metal_Small
+ BuildingDestroyed_Metal_Medium
+ BuildingDestroyed_Metal_Big
+
+
+
+ Woody
+
+ wood
+ BuildingDestroyed_Wood_Small
+ BuildingDestroyed_Wood_Medium
+ BuildingDestroyed_Wood_Big
+
+
+
\ No newline at end of file
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestBooleanPropertyValue.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestBooleanPropertyValue.xml
new file mode 100644
index 0000000..613c44f
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestBooleanPropertyValue.xml
@@ -0,0 +1,5 @@
+
+
+ {caret}
+
+
\ No newline at end of file
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestBooleanPropertyValue.xml.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestBooleanPropertyValue.xml.gold
new file mode 100644
index 0000000..62a7967
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestBooleanPropertyValue.xml.gold
@@ -0,0 +1,16 @@
+Completion: Basic
+Count: 2
+Focus: Hard
+Range: "♦"
+
+##### RELEVANCE SORT #####
+
+ [FromSingleCompletion, FromLightAndDynamicEvaluation, NormalSelectionPriority, Other]
+false <==
+true
+
+##### ALPHABETIC SORT #####
+
+ [Light, Generic]
+false <==
+true
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefName.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefName.xml
new file mode 100644
index 0000000..776ddfa
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefName.xml
@@ -0,0 +1,3 @@
+
+
\ No newline at end of file
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefName.xml.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefName.xml.gold
new file mode 100644
index 0000000..3bffd35
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefName.xml.gold
@@ -0,0 +1,16 @@
+Completion: Basic
+Prefix: "Thou"
+Count: 1
+Focus: Hard
+Range: "
+
+
+ TestThingB
+ {caret}
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceOtherFile.xml.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceOtherFile.xml.gold
new file mode 100644
index 0000000..54f3cd5
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceOtherFile.xml.gold
@@ -0,0 +1,16 @@
+Completion: Basic
+Count: 2
+Focus: Hard
+Range: "♦"
+
+##### RELEVANCE SORT #####
+
+ [FromSingleCompletion, FromLightAndDynamicEvaluation, NormalSelectionPriority, Other]
+OtherThing <==
+TestThingB
+
+##### ALPHABETIC SORT #####
+
+ [Light, Generic]
+OtherThing <==
+TestThingB
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceSameFile.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceSameFile.xml
new file mode 100644
index 0000000..1418c75
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceSameFile.xml
@@ -0,0 +1,10 @@
+
+
+
+ TestThingA
+
+
+ TestThingB
+ {caret}
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceSameFile.xml.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceSameFile.xml.gold
new file mode 100644
index 0000000..03ebb55
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefReferenceSameFile.xml.gold
@@ -0,0 +1,16 @@
+Completion: Basic
+Count: 2
+Focus: Hard
+Range: "♦"
+
+##### RELEVANCE SORT #####
+
+ [FromSingleCompletion, FromLightAndDynamicEvaluation, NormalSelectionPriority, Other]
+TestThingA <==
+TestThingB
+
+##### ALPHABETIC SORT #####
+
+ [Light, Generic]
+TestThingA <==
+TestThingB
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefsFilterByType.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefsFilterByType.xml
new file mode 100644
index 0000000..94779dd
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefsFilterByType.xml
@@ -0,0 +1,7 @@
+
+
+
+
{caret}
+
+
+
\ No newline at end of file
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefsFilterByType.xml.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefsFilterByType.xml.gold
new file mode 100644
index 0000000..315b4b2
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/CompletionSuggestions/Rimworld/TestDefsFilterByType.xml.gold
@@ -0,0 +1,16 @@
+Completion: Basic
+Count: 2
+Focus: Hard
+Range: "
+
+
+
+ TestThingSecondOperand
+ |1~two|(13)
+ |0.5~high|(14)
+ |left,2|(15)
+
+ |(1,2,down)|(16)
+
+
+
+
+---------------------------------------------------------
+(0): ReSharper Underlined Error Highlighting: Value must be "true" or "false"
+(1): ReSharper Underlined Error Highlighting: Value must be a whole number with no decimal points
+(2): ReSharper Underlined Error Highlighting: Value must be a valid number
+(3): ReSharper Underlined Error Highlighting: Your value must be two integers in a format similar to "1~2"
+(4): ReSharper Underlined Error Highlighting: "half" is not a valid float
+(5): ReSharper Underlined Error Highlighting: "up" is not a valid number
+(6): ReSharper Underlined Error Highlighting: "Bulding" is not a valid value for ThingCategory
+(7): ReSharper Underlined Error Highlighting: "Sideways" is not a valid value for Rot4
+(8): ReSharper Underlined Error Highlighting: Value must be a whole number with no decimal points
+(9): ReSharper Underlined Error Highlighting: "wide" is not a valid number
+(10): ReSharper Underlined Error Highlighting: Your value must be in a format similar to (1,2,3)
+(11): ReSharper Underlined Error Highlighting: Value must be a valid number
+(12): ReSharper Underlined Error Highlighting: Value must be a valid number
+(13): ReSharper Underlined Error Highlighting: "two" is not a valid integer
+(14): ReSharper Underlined Error Highlighting: "high" is not a valid float
+(15): ReSharper Underlined Error Highlighting: "left" is not a valid number
+(16): ReSharper Underlined Error Highlighting: "down" is not a valid number
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/Highlighting/TestValidValues.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/Highlighting/TestValidValues.xml
new file mode 100644
index 0000000..6b7d5e3
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/Highlighting/TestValidValues.xml
@@ -0,0 +1,65 @@
+
+
+
+ UnknownDefType
+ 75
+
+
+ TestThing
+
+ 1~2~3
+ 75
+
+ True
+ -75
+ 1.5
+ 2~8
+ 0.5~1
+ (0.5,-0.25)
+ Building
+ North
+
+ (1,2)
+ (0,0,-1)
+ (1,1,1)
+
+ 75
+
+ 75
+
+ 75
+
+
+ (1,1)
+ (0,0.2,0)
+ 12.5
+ (0.5,0.5,0.5)
+ (0,0.2,0)
+
+
+
+
+
+
+
+
+------------------------------------------------
+0: result=OK declaredElem=Verse.ThingDef
+1: result=OK declaredElem=ThingDef/BaseThing
+2: result=OK declaredElem=Verse.ThingDef
+3: result=OK declaredElem=ThingDef/BaseThing
+4: result=OK declaredElem=System.String Verse.Def.defName
+5: result=OK declaredElem=ThingDef/LocalThing
+6: result=OK declaredElem=Verse.ThingDef
+7: result=OK declaredElem=System.String Verse.Def.defName
+8: result=OK declaredElem=ThingDef/Referrer
+9: result=OK declaredElem=Verse.ThingDef Verse.ThingDef.minifiedDef
+10: result=OK declaredElem=ThingDef/LocalThing
+11: result=OK declaredElem=Verse.ThingDef Verse.ThingDef.minifiedDef
+12: result=OK declaredElem=System.Boolean Verse.ThingDef.leaveResourcesWhenKilled
+13: result=OK declaredElem=Verse.SoundDef Verse.ThingDef.soundDrop
+14: result=OK declaredElem=SoundDef/OtherSound
+15: result=OK declaredElem=Verse.SoundDef Verse.ThingDef.soundInteract
+16: result=OK declaredElem=System.Collections.Generic.List`1[T -> RimWorld.StuffCategoryDef] Verse.BuildableDef.stuffCategories
+17: result=OK declaredElem=StuffCategoryDef/OtherStuff
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/CSharp/TestLocalVariable.cs b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/CSharp/TestLocalVariable.cs
new file mode 100644
index 0000000..96bfa12
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/CSharp/TestLocalVariable.cs
@@ -0,0 +1,8 @@
+class C
+{
+ void M()
+ {
+ var myLocalVariable = 1;
+ myLoc{caret}
+ }
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/CSharp/TestLocalVariable.cs.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/CSharp/TestLocalVariable.cs.gold
new file mode 100644
index 0000000..fe33494
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/CSharp/TestLocalVariable.cs.gold
@@ -0,0 +1,16 @@
+Completion: Basic
+Prefix: "myLoc"
+Count: 1
+Focus: Hard
+Range: "myLoc♦"
+ ▲▲▲▲▲▲
+
+##### RELEVANCE SORT #####
+
+ [FromSingleCompletion, FromLightAndDynamicEvaluation, PrefixMatch, LocalVariablesAndParameters, NotObsolete, ObsoleteRuleApplied, NormalSelectionPriority, ClosestLocalVar, Other]
+ṃỵḶọc̣alVariable int <==
+
+##### ALPHABETIC SORT #####
+
+ [Light, Generic]
+ṃỵḶọc̣alVariable int <==
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/Xml/XmlIsParsed.xml b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/Xml/XmlIsParsed.xml
new file mode 100644
index 0000000..f2dd956
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/Xml/XmlIsParsed.xml
@@ -0,0 +1,4 @@
+
+ text
+ <{caret}
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/Xml/XmlIsParsed.xml.gold b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/Xml/XmlIsParsed.xml.gold
new file mode 100644
index 0000000..f77782b
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/SmokeTests/Xml/XmlIsParsed.xml.gold
@@ -0,0 +1,4 @@
+ProjectFileType: XML
+PrimaryPsiLanguage: XML
+PsiFile: JetBrains.ReSharper.Psi.Xml.Impl.Tree.XmlFile
+XmlTags: 3
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/nuget.config b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/nuget.config
index d292778..575b4ab 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/nuget.config
+++ b/src/dotnet/ReSharperPlugin.RimworldDev.Tests/test/data/nuget.config
@@ -1,21 +1,18 @@
-
+
-
-
+
+
-
-
-
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/CSharpDefsOfItemProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/CSharpDefsOfItemProvider.cs
index ec4da4a..4a9c835 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/CSharpDefsOfItemProvider.cs
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/CSharpDefsOfItemProvider.cs
@@ -21,9 +21,9 @@ protected override bool IsAvailable(CSharpCodeCompletionContext context)
{
var node = context.NodeInFile;
if (!node.Language.IsLanguage(CSharpLanguage.Instance)) return false;
- if (node.Parent is not IFieldDeclaration fieldDeclaration) return false;
- if (fieldDeclaration.Type is not IDeclaredType) return false;
- if (fieldDeclaration.GetContainingTypeElement() is not IClass containingClass) return false;
+ if (GetFieldType(node) is null) return false;
+ if (node.Parent is not ICSharpTypeMemberDeclaration memberDeclaration) return false;
+ if (memberDeclaration.GetContainingTypeElement() is not IClass containingClass) return false;
if (containingClass
.GetAttributeInstances(AttributesSource.Self)
.FirstOrDefault(attribute => attribute.GetClrName().FullName == "RimWorld.DefOf") is null) return false;
@@ -35,8 +35,7 @@ protected override bool AddLookupItems(CSharpCodeCompletionContext context, IIte
{
var node = context.NodeInFile;
- if (node.Parent is not IFieldDeclaration fieldDeclaration) return false;
- if (fieldDeclaration.Type is not IDeclaredType fieldType) return false;
+ if (GetFieldType(node) is not { } fieldType) return false;
var defTypeName = fieldType.GetClrName().ShortName;
var xmlSymbolTable = context.NodeInFile.GetSolution().GetComponent();
@@ -57,4 +56,17 @@ protected override bool AddLookupItems(CSharpCodeCompletionContext context, IIte
return base.AddLookupItems(context, collector);
}
+
+ private static IDeclaredType GetFieldType(ITreeNode node) => node.Parent switch
+ {
+ // We have two possibilities we need to account for: With a `;` already in place and without one. With it in
+ // place, Rider reports that it's a IFieldDeclaration. Without it, it'll report that it's a IMethodDeclaration.
+ // In the case that there's no trailing `;` and Rider thinks it's a method Declaration, we can also check that
+ // there's no left parenthesis, which would be the opening of the signature for the method. If there's not,
+ // we'll just keep treating it like a field.
+ IFieldDeclaration fieldDeclaration => fieldDeclaration.Type as IDeclaredType,
+ IMethodDeclaration { LPar: null } methodDeclaration when methodDeclaration.NameIdentifier == node =>
+ methodDeclaration.Type as IDeclaredType,
+ _ => null
+ };
}
\ No newline at end of file
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ReSharperPlugin.RimworldDev.csproj b/src/dotnet/ReSharperPlugin.RimworldDev/ReSharperPlugin.RimworldDev.csproj
index 017f183..fe39417 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev/ReSharperPlugin.RimworldDev.csproj
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/ReSharperPlugin.RimworldDev.csproj
@@ -21,6 +21,10 @@
true
+
+
+
+
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs
index 57660cd..57d55e8 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs
@@ -27,6 +27,26 @@ public class ScopeHelper
private static List usedScopes;
private static bool adding = false;
+ ///
+ /// When set, never go looking for Assembly-CSharp.dll on disk. Tests set this so that a RimWorld install on the
+ /// developer's machine can't leak into an in-memory test solution that already references the game types.
+ ///
+ internal static bool SkipAssemblyDiscovery;
+
+ ///
+ /// Forgets every cached scope/module. Tests need this because the statics otherwise outlive the in-memory solution
+ /// that produced them; production never calls it.
+ ///
+ internal static void Reset()
+ {
+ allScopes = new();
+ knownCustomScopes = new();
+ rimworldScope = null;
+ rimworldModule = null;
+ usedScopes = null;
+ adding = false;
+ }
+
public static bool UpdateScopes(ISolution solution)
{
if (solution == null) return false;
@@ -35,12 +55,6 @@ public static bool UpdateScopes(ISolution solution)
allScopes = solution.PsiModules().GetModules().Select(module =>
module.GetPsiServices().Symbols.GetSymbolScope(module, true, true)).ToList();
- // If we haven't determined the Rimworld scope yet, our scopes may not be ready for querying. Since I'd rather
- // that we were able to pull the scope from the dependencies than try to find it ourselves, let's check if the
- // scopes are ready for querying first. Ofcourse, if we have no scopes at all, there's nothing to wait for
- if (rimworldScope == null && allScopes.Any() && allScopes.Any(scope => !scope.GetAllShortNames().Any()))
- return false;
-
if (rimworldScope == null)
{
rimworldScope =
@@ -48,6 +62,14 @@ public static bool UpdateScopes(ISolution solution)
if (rimworldScope == null)
{
+ // If we haven't determined the Rimworld scope yet, our scopes may not be ready for querying. Since I'd
+ // rather that we were able to pull the scope from the dependencies than try to find it ourselves, let's
+ // check if the scopes are ready for querying first. Ofcourse, if we have no scopes at all, there's
+ // nothing to wait for. This check deliberately comes *after* looking for Rimworld: a module that is
+ // legitimately empty (an XML-only project) must not stop us from using a Rimworld module that's ready.
+ if (allScopes.Any() && allScopes.Any(scope => !scope.GetAllShortNames().Any()))
+ return false;
+
AddRef(solution);
return false;
@@ -81,7 +103,7 @@ public static ISymbolScope GetScopeForClass(string className)
private static async void AddRef(ISolution solution)
{
- if (adding) return;
+ if (adding || SkipAssemblyDiscovery) return;
adding = true;
var path = FindRimworldDll(solution.SolutionDirectory.FullPath);
@@ -317,6 +339,26 @@ public static List GetAllSuperTypes(string clrName)
return items;
}
+ ///
+ /// The other def types a def class can be referenced as: its superclasses below Verse.Def, nearest first, as
+ /// short names (MyMod.CustomThingDef -> ThingDef, BuildableDef). Null when the class can't be
+ /// resolved, which for a mod's class can just mean the symbol caches aren't ready yet.
+ ///
+ [CanBeNull]
+ public static List GetDefSuperClassNames(string clrName)
+ {
+ using (CompilationContextCookie.GetOrCreate(UniversalModuleReferenceContext.Instance))
+ {
+ if (GetScopeForClass(clrName)?.GetTypeElementByCLRName(clrName) is not { } typeElement) return null;
+
+ return typeElement.GetAllSuperClasses()
+ .Select(superClass => superClass.GetClrName())
+ .TakeWhile(superClass => superClass.FullName != "Verse.Def")
+ .Select(superClass => superClass.ShortName)
+ .ToList();
+ }
+ }
+
public static bool ExtendsFromVerseDef(string clrName)
{
if (RimworldScope is null) return false;
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldSymbolScope.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldSymbolScope.cs
index f3b434a..1b12668 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldSymbolScope.cs
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldSymbolScope.cs
@@ -1,13 +1,11 @@
using System.Collections.Generic;
using System.Linq;
+using System.Runtime.CompilerServices;
using JetBrains;
using JetBrains.Annotations;
using JetBrains.Application.Parts;
-using JetBrains.Application.Parts;
using JetBrains.Application.Threading;
-using JetBrains.Collections;
using JetBrains.Lifetimes;
-using JetBrains.Metadata.Reader.API;
using JetBrains.ProjectModel;
using JetBrains.ReSharper.Psi;
using JetBrains.ReSharper.Psi.Caches;
@@ -16,37 +14,67 @@
using JetBrains.ReSharper.Psi.Resolve;
using JetBrains.ReSharper.Psi.Tree;
using JetBrains.ReSharper.Psi.Util;
-using JetBrains.ReSharper.Psi.Xml.Impl.Tree;
using JetBrains.ReSharper.Psi.Xml.Tree;
using ReSharperPlugin.RimworldDev.TypeDeclaration;
namespace ReSharperPlugin.RimworldDev.SymbolScope;
-public struct DefTag
+///
+/// Where a def is declared: the offset of its <defName> text or Name="" value in its file. The tree
+/// node itself is only looked up when someone asks for it ().
+///
+public readonly struct DefTag
{
- public DefTag(ITreeNode treeNode, bool isAbstract = false)
+ public DefTag(IPsiSourceFile sourceFile, int documentOffset, bool isAbstract)
{
- TreeNode = treeNode;
+ SourceFile = sourceFile;
+ DocumentOffset = documentOffset;
IsAbstract = isAbstract;
}
- public ITreeNode TreeNode { get; }
+ public IPsiSourceFile SourceFile { get; }
+ public int DocumentOffset { get; }
public bool IsAbstract { get; }
}
+///
+/// Index of every def in the solution, keyed by "{defType}/{defName}".
+///
+/// Merge/MergeLoaded only record where each def lives; they must not touch PSI, because Merge runs in the middle of a
+/// document commit, where asking for a PSI file asserts ("Trying to get PSI file for an uncommitted document"). Tree
+/// nodes are looked up when queried, and the superclass aliases in are resolved on the
+/// first query after a change, once the RimWorld and mod types can actually be resolved.
+///
[PsiComponent(Instantiation.ContainerAsyncPrimaryThread)]
public class RimworldSymbolScope : SimpleICache>
{
- private Dictionary DefTags = new();
- private Dictionary ExtraDefTagNames = new();
+ // Version of the persisted RimworldXmlDefSymbol format; bump it whenever the marshaller changes
+ private const long PersistentVersion = 2;
+
+ private readonly ISolution _solution;
+
+ // Written in Merge/Drop (write lock), read by queries (read lock), so the two never overlap
+ private readonly Dictionary DefTags = new();
+
+ // "ThingDef/CustomThing" -> "MyMod.CustomThingDef/CustomThing". Queries can run on several threads at once, so it's
+ // rebuilt under a lock and swapped in whole.
+ private Dictionary _extraDefTagNames = new();
+ private volatile bool _extraDefTagNamesStale;
+ private readonly object _extraDefTagNamesLock = new();
+
+ // Offset -> defName/Name value node for each XML file we've looked into. Keyed weakly on the IFile so the nodes
+ // go away with the tree instead of being kept alive by the index.
+ private readonly ConditionalWeakTable> _defNodesByFile = new();
+
private Dictionary _declaredElements = new();
private SymbolTable _symbolTable;
public RimworldSymbolScope
(Lifetime lifetime, [NotNull] IShellLocks locks, [NotNull] IPersistentIndexManager persistentIndexManager,
- long? version = null)
- : base(lifetime, locks, persistentIndexManager, RimworldXmlDefSymbol.Marshaller, version)
+ ISolution solution)
+ : base(lifetime, locks, persistentIndexManager, RimworldXmlDefSymbol.Marshaller, PersistentVersion)
{
+ _solution = solution;
}
protected override bool IsApplicable(IPsiSourceFile sourceFile)
@@ -54,8 +82,24 @@ protected override bool IsApplicable(IPsiSourceFile sourceFile)
return base.IsApplicable(sourceFile) && sourceFile.LanguageType.Name == "XML";
}
+ ///
+ /// The current aliases, rebuilding them first if they're stale (see ). Call it
+ /// once per query and use the result throughout: each call may retry the rebuild, and another query may swap in a
+ /// new dictionary between calls.
+ ///
+ private Dictionary GetExtraDefTagNames()
+ {
+ if (!_extraDefTagNamesStale) return _extraDefTagNames;
+
+ lock (_extraDefTagNamesLock)
+ {
+ if (_extraDefTagNamesStale) RebuildExtraDefTagNames();
+ return _extraDefTagNames;
+ }
+ }
+
public bool HasTag(DefNameValue defName) =>
- DefTags.ContainsKey(defName.TagId) || ExtraDefTagNames.ContainsKey(defName.TagId);
+ DefTags.ContainsKey(defName.TagId) || GetExtraDefTagNames().ContainsKey(defName.TagId);
[CanBeNull]
public ITreeNode GetTagByDef(string defType, string defName)
@@ -69,31 +113,32 @@ public ITreeNode GetTagByDef(string defType, string defName)
[CanBeNull]
public ITreeNode GetTagByDef(string defId)
{
- if (!DefTags.ContainsKey(defId))
+ if (!DefTags.TryGetValue(defId, out var defTag))
return null;
- return DefTags[defId].TreeNode;
+ return FindDefNode(defTag);
}
public bool IsDefAbstract(string defId)
{
- return DefTags.ContainsKey(defId) && DefTags[defId].IsAbstract;
+ return DefTags.TryGetValue(defId, out var defTag) && defTag.IsAbstract;
}
public DefNameValue GetDefName(DefNameValue value) =>
- ExtraDefTagNames.TryGetValue(value.TagId, out var defTag) ? new DefNameValue(defTag) : value;
+ GetExtraDefTagNames().TryGetValue(value.TagId, out var defTag) ? new DefNameValue(defTag) : value;
public List GetDefsByType(string defType)
{
+ var extraDefTagNames = GetExtraDefTagNames();
+
return DefTags
.Keys
.Where(key => key.StartsWith($"{defType}/"))
- .Select(defId => ExtraDefTagNames.ContainsKey(defId) ? ExtraDefTagNames[defId] : defId)
- .ToList()
+ .Select(defId => extraDefTagNames.TryGetValue(defId, out var aliasedDefId) ? aliasedDefId : defId)
.Concat(
- ExtraDefTagNames.Keys
- .Where(key => key.StartsWith($"{defType}/"))
- .Select(key => ExtraDefTagNames[key])
+ extraDefTagNames
+ .Where(alias => alias.Key.StartsWith($"{defType}/"))
+ .Select(alias => alias.Value)
).ToList();
}
@@ -125,7 +170,7 @@ public override object Build(IPsiSourceFile sourceFile, bool isStartup)
.Children()
.FirstOrDefault(element => element is IXmlValueToken)?
.GetUnquotedText();
-
+
var defNameTag = tag.GetNestedTags("defName").
FirstOrDefault()?.
Children().
@@ -133,10 +178,15 @@ public override object Build(IPsiSourceFile sourceFile, bool isStartup)
tag.GetAttribute("Name")?.
Children().
FirstOrDefault(element => element is IXmlValueToken);
-
+
if (defName is null) continue;
- defs.Add(new RimworldXmlDefSymbol(defNameTag, defName, tag.GetTagName()));
+ // Only defs identified by a Name="" attribute can be abstract parents
+ var isAbstract = defNameTag is IXmlValueToken &&
+ tag.GetAttribute("Abstract") is { } attribute &&
+ attribute.UnquotedValue.ToLower() == "true";
+
+ defs.Add(new RimworldXmlDefSymbol(defNameTag, defName, tag.GetTagName(), isAbstract));
}
return defs;
@@ -161,76 +211,14 @@ public override void Drop(IPsiSourceFile sourceFile)
base.Drop(sourceFile);
}
+ // Runs inside Merge, i.e. mid-commit: must not ask for PSI (see the class comment)
private void AddToLocalCache(IPsiSourceFile sourceFile, [CanBeNull] List cacheItem)
{
- ScopeHelper.UpdateScopes(sourceFile.GetSolution());
- if (sourceFile.GetPrimaryPsiFile() is not IXmlFile xmlFile) return;
-
cacheItem?.ForEach(item =>
{
- var matchingDefTag = xmlFile
- .GetNestedTags("Defs/*/defName").FirstOrDefault(tag =>
- tag.Children().ElementAt(1).GetTreeStartOffset().Offset ==
- item.DocumentOffset) ??
- xmlFile
- .GetNestedTags("Defs/*")
- .FirstOrDefault(tag =>
- tag.GetAttribute("Name")?
- .Children()
- .FirstOrDefault(element => element is IXmlValueToken)?
- .GetTreeStartOffset().Offset == item.DocumentOffset
- )?
- .GetAttribute("Name")?
- .Children()
- .FirstOrDefault(element => element is IXmlValueToken);
-
- if (matchingDefTag is null) return;
-
- // If the DefName is in a [Name=""] Attribute, it'll be matched to a XmlValueToken, which doesn't have any
- // children. Otherwise, it'll be matched to the XmlTag for , where we want the first child as the
- // string value
- var xmlTag = matchingDefTag is IXmlValueToken ? matchingDefTag : matchingDefTag.Children().ElementAt(1);
-
- AddDefTagToList(item, xmlTag);
+ DefTags[$"{item.DefType}/{item.DefName}"] = new DefTag(sourceFile, item.DocumentOffset, item.IsAbstract);
+ if (item.DefType.Contains(".")) _extraDefTagNamesStale = true;
});
-
- void AddDefTagToList(RimworldXmlDefSymbol item, ITreeNode xmlTag)
- {
- using (CompilationContextCookie.GetOrCreate(UniversalModuleReferenceContext.Instance))
- {
- if (item.DefType.Contains(".") && ScopeHelper.RimworldScope is not null)
- {
- var superClasses = ScopeHelper.GetScopeForClass(item.DefType)?
- .GetTypeElementByCLRName(item.DefType)?
- .GetAllSuperClasses().ToList() ?? new();
-
- foreach (var superClass in superClasses)
- {
- if (superClass.GetClrName().FullName == "Verse.Def") break;
-
- var subDefType = superClass.GetClrName().ShortName;
- if (!ExtraDefTagNames.ContainsKey($"{subDefType}/{item.DefName}"))
- {
- ExtraDefTagNames.Add($"{subDefType}/{item.DefName}", $"{item.DefType}/{item.DefName}");
- }
- else
- {
- ExtraDefTagNames[$"{subDefType}/{item.DefName}"] = $"{item.DefType}/{item.DefName}";
- }
- }
- }
-
- var isAbstract = xmlTag is IXmlValueToken &&
- xmlTag.Parent?.Parent is XmlTagHeaderNode defTypeTag &&
- defTypeTag.GetAttribute("Abstract") is {} attribute &&
- attribute.UnquotedValue.ToLower() == "true";
-
- if (!DefTags.ContainsKey($"{item.DefType}/{item.DefName}"))
- DefTags.Add($"{item.DefType}/{item.DefName}", new DefTag(xmlTag, isAbstract));
- else
- DefTags[$"{item.DefType}/{item.DefName}"] = new DefTag(xmlTag, isAbstract);
- }
- }
}
private void RemoveFromLocalCache(IPsiSourceFile sourceFile)
@@ -239,8 +227,12 @@ private void RemoveFromLocalCache(IPsiSourceFile sourceFile)
items?.ForEach(item =>
{
- if (DefTags.ContainsKey($"{item.DefType}/{item.DefName}"))
- DefTags.Remove($"{item.DefType}/{item.DefName}");
+ var defId = $"{item.DefType}/{item.DefName}";
+
+ if (DefTags.TryGetValue(defId, out var defTag) && defTag.SourceFile.Equals(sourceFile))
+ DefTags.Remove(defId);
+
+ if (item.DefType.Contains(".")) _extraDefTagNamesStale = true;
});
}
@@ -250,6 +242,85 @@ private void PopulateLocalCache()
AddToLocalCache(sourceFile, cacheItem);
}
+ ///
+ /// Maps each def whose type is a mod class (<MyMod.CustomThingDef>) to every RimWorld superclass short
+ /// name up to Verse.Def, so that a ThingDef reference finds it. Stays stale, so the next query tries
+ /// again, until RimWorld's scope is ready and every such type resolves; on a cold load neither is true when the
+ /// index is merged.
+ ///
+ private void RebuildExtraDefTagNames()
+ {
+ var customDefs = DefTags.Keys
+ .Select(defId => new DefNameValue(defId))
+ .Where(defId => defId.DefType.Contains("."))
+ .ToList();
+
+ var extraDefTagNames = new Dictionary();
+ var allResolved = true;
+
+ if (customDefs.Any())
+ {
+ if (!ScopeHelper.UpdateScopes(_solution)) return;
+
+ foreach (var def in customDefs)
+ {
+ if (ScopeHelper.GetDefSuperClassNames(def.DefType) is not { } superClassNames)
+ {
+ allResolved = false;
+ continue;
+ }
+
+ foreach (var superClassName in superClassNames)
+ extraDefTagNames[$"{superClassName}/{def.DefName}"] = def.TagId;
+ }
+ }
+
+ _extraDefTagNames = extraDefTagNames;
+ _extraDefTagNamesStale = !allResolved;
+ }
+
+ [CanBeNull]
+ private ITreeNode FindDefNode(DefTag defTag)
+ {
+ var sourceFile = defTag.SourceFile;
+ if (!sourceFile.IsValid()) return null;
+
+ // Queries normally run on committed documents, but don't turn a stray one into the assertion this design avoids
+ if (!sourceFile.GetPsiServices().Files.IsCommitted(sourceFile)) return null;
+ if (sourceFile.GetPrimaryPsiFile() is not IXmlFile xmlFile) return null;
+
+ if (_defNodesByFile.TryGetValue(xmlFile, out var nodes) &&
+ nodes.TryGetValue(defTag.DocumentOffset, out var cachedNode) &&
+ cachedNode.IsValid() &&
+ cachedNode.GetTreeStartOffset().Offset == defTag.DocumentOffset)
+ return cachedNode;
+
+ // Not looked at yet, or reparsed since (an incremental reparse can keep the same IFile)
+ nodes = FindDefNodes(xmlFile);
+ _defNodesByFile.AddOrUpdate(xmlFile, nodes);
+
+ return nodes.TryGetValue(defTag.DocumentOffset, out var node) ? node : null;
+ }
+
+ // The same nodes Build records offsets for: the text inside , and the value of Name=""
+ private static Dictionary FindDefNodes(IXmlFile xmlFile)
+ {
+ var nodes = new Dictionary();
+
+ foreach (var tag in xmlFile.GetNestedTags("Defs/*"))
+ {
+ if (tag.GetNestedTags("defName").FirstOrDefault()?.Children().ElementAtOrDefault(1) is
+ { } defNameValue)
+ nodes[defNameValue.GetTreeStartOffset().Offset] = defNameValue;
+
+ if (tag.GetAttribute("Name")?.Children().FirstOrDefault(element => element is IXmlValueToken) is
+ { } nameValue)
+ nodes[nameValue.GetTreeStartOffset().Offset] = nameValue;
+ }
+
+ return nodes;
+ }
+
public void AddDeclaredElement(ISolution solution, ITreeNode owner, string defType, string defName,
bool caseSensitiveName)
{
@@ -279,4 +350,4 @@ public ISymbolTable GetSymbolTable(ISolution solution)
return _symbolTable;
}
-}
\ No newline at end of file
+}
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldXmlDefSymbol.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldXmlDefSymbol.cs
index fd58ba0..869573d 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldXmlDefSymbol.cs
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldXmlDefSymbol.cs
@@ -9,35 +9,39 @@ public class RimworldXmlDefSymbol
{
public static readonly IUnsafeMarshaller> Marshaller =
UnsafeMarshallers.GetCollectionMarshaller(new UniversalMarshaller(Read, Write), (size) => new List());
-
+
public string DefName { get; }
public string DefType { get; }
public int DocumentOffset { get; }
-
- // public IXmlTag Tag { get; }
- public RimworldXmlDefSymbol(ITreeNode tag, string defName, string defType)
+ public bool IsAbstract { get; }
+
+ public RimworldXmlDefSymbol(ITreeNode tag, string defName, string defType, bool isAbstract)
{
DefName = defName;
DefType = defType;
DocumentOffset = tag.GetTreeStartOffset().Offset;
+ IsAbstract = isAbstract;
}
-
- public RimworldXmlDefSymbol(int documentOffset, string defName, string defType)
+
+ public RimworldXmlDefSymbol(int documentOffset, string defName, string defType, bool isAbstract)
{
DefName = defName;
DefType = defType;
DocumentOffset = documentOffset;
+ IsAbstract = isAbstract;
}
-
+
+ // Changing what's read/written here means bumping RimworldSymbolScope.PersistentVersion, or old caches get misread
private static RimworldXmlDefSymbol Read(UnsafeReader reader)
{
var defType = reader.ReadString();
var defName = reader.ReadString();
var documentOffset = reader.ReadInt();
-
- return new RimworldXmlDefSymbol(documentOffset, defName, defType);
+ var isAbstract = reader.ReadBool();
+
+ return new RimworldXmlDefSymbol(documentOffset, defName, defType, isAbstract);
}
private static void Write(UnsafeWriter writer, RimworldXmlDefSymbol value)
@@ -45,5 +49,6 @@ private static void Write(UnsafeWriter writer, RimworldXmlDefSymbol value)
writer.Write(value.DefType);
writer.Write(value.DefName);
writer.Write(value.DocumentOffset);
+ writer.Write(value.IsAbstract);
}
-}
\ No newline at end of file
+}