From 0c77836f0590f47e1629da49ecda2bf87d76fdbd Mon Sep 17 00:00:00 2001 From: Gareth Date: Fri, 17 Apr 2026 19:02:19 +0100 Subject: [PATCH 01/11] Include a symbol scope for keyed translations, autocomplete in the C# and references back to the XML --- .../.idea/.idea.AshAndDust/.idea/.name | 1 - .../.idea.AshAndDust/.idea/encodings.xml | 4 - .../.idea.AshAndDust/.idea/indexLayout.xml | 8 - .../.idea/.idea.AshAndDust/.idea/vcs.xml | 6 - ...orldKeyedTranslationsCSharpItemProvider.cs | 58 ++++++ .../RimworldCSharpKeyedTranslationProvider.cs | 55 ++++++ .../RimworldKeyedTranslationReference.cs | 62 +++++++ .../ScopeHelper.cs | 15 +- .../RimworldKeyedTranslationSymbol.cs | 47 +++++ .../RimworldKeyedTranslationSymbolScope.cs | 170 ++++++++++++++++++ .../SymbolScope/RimworldSymbolScope.cs | 11 +- .../SymbolScope/RimworldXmlDefSymbol.cs | 2 - .../TypeDeclaration/XMLTagDeclaredElement.cs | 10 +- 13 files changed, 416 insertions(+), 33 deletions(-) delete mode 100644 example-mod/.idea/.idea.AshAndDust/.idea/.name delete mode 100644 example-mod/.idea/.idea.AshAndDust/.idea/encodings.xml delete mode 100644 example-mod/.idea/.idea.AshAndDust/.idea/indexLayout.xml delete mode 100644 example-mod/.idea/.idea.AshAndDust/.idea/vcs.xml create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/RimworldKeyedTranslationsCSharpItemProvider.cs create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldKeyedTranslationReference.cs create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbol.cs create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs diff --git a/example-mod/.idea/.idea.AshAndDust/.idea/.name b/example-mod/.idea/.idea.AshAndDust/.idea/.name deleted file mode 100644 index 82c5ebf..0000000 --- a/example-mod/.idea/.idea.AshAndDust/.idea/.name +++ /dev/null @@ -1 +0,0 @@ -AshAndDust \ No newline at end of file diff --git a/example-mod/.idea/.idea.AshAndDust/.idea/encodings.xml b/example-mod/.idea/.idea.AshAndDust/.idea/encodings.xml deleted file mode 100644 index df87cf9..0000000 --- a/example-mod/.idea/.idea.AshAndDust/.idea/encodings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/example-mod/.idea/.idea.AshAndDust/.idea/indexLayout.xml b/example-mod/.idea/.idea.AshAndDust/.idea/indexLayout.xml deleted file mode 100644 index 7b08163..0000000 --- a/example-mod/.idea/.idea.AshAndDust/.idea/indexLayout.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/example-mod/.idea/.idea.AshAndDust/.idea/vcs.xml b/example-mod/.idea/.idea.AshAndDust/.idea/vcs.xml deleted file mode 100644 index 6c0b863..0000000 --- a/example-mod/.idea/.idea.AshAndDust/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/RimworldKeyedTranslationsCSharpItemProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/RimworldKeyedTranslationsCSharpItemProvider.cs new file mode 100644 index 0000000..f7d1069 --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/RimworldKeyedTranslationsCSharpItemProvider.cs @@ -0,0 +1,58 @@ +using JetBrains.ProjectModel; +using JetBrains.ReSharper.Feature.Services.CodeCompletion.Infrastructure; +using JetBrains.ReSharper.Feature.Services.CodeCompletion.Infrastructure.LookupItems; +using JetBrains.ReSharper.Feature.Services.CSharp.CodeCompletion.Infrastructure; +using JetBrains.ReSharper.Psi; +using JetBrains.ReSharper.Psi.CSharp; +using JetBrains.ReSharper.Psi.CSharp.Impl.Tree; +using JetBrains.ReSharper.Psi.CSharp.Parsing; +using JetBrains.ReSharper.Psi.CSharp.Tree; +using JetBrains.ReSharper.Psi.Tree; +using ReSharperPlugin.RimworldDev.SymbolScope; +using ReSharperPlugin.RimworldDev.TypeDeclaration; + +namespace ReSharperPlugin.RimworldDev.ItemCompletion; + +[Language(typeof(CSharpLanguage))] +public class RimworldKeyedTranslationsCSharpItemProvider: ItemsProviderOfSpecificContext +{ + private static RimworldCSharpLookupFactory LookupFactory = new(); + + protected override bool IsAvailable(CSharpCodeCompletionContext context) + { + var node = context.NodeInFile; + if (!node.Language.IsLanguage(CSharpLanguage.Instance)) return false; + if (node is not CSharpGenericToken) return false; + if (node.NodeType != CSharpTokenType.STRING_LITERAL_REGULAR) return false; + + return true; + } + + protected override bool AddLookupItems(CSharpCodeCompletionContext context, IItemsCollector collector) + { + var node = context.NodeInFile; + + if (node.Parent?.NextSibling?.NextSibling is not ICSharpIdentifier identifier || + identifier.GetText() != "Translate") + return false; + + var xmlSymbolTable = context.NodeInFile.GetSolution().GetComponent(); + + foreach (var key in xmlSymbolTable.GetKeys()) + { + var keyTag = xmlSymbolTable.GetKeyTag(key); + if (keyTag == null) continue; + + var lookup = LookupFactory.CreateDeclaredElementLookupItem( + context, + key, + new DeclaredElementInstance(new XMLTagDeclaredElement(keyTag, $"English/{key}", false)) + ); + + collector.Add(lookup); + } + + return base.AddLookupItems(context, collector); + } + +} \ No newline at end of file diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs new file mode 100644 index 0000000..0f1dab7 --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs @@ -0,0 +1,55 @@ +using JetBrains.DataFlow; +using JetBrains.Lifetimes; +using JetBrains.ProjectModel; +using JetBrains.ReSharper.Psi; +using JetBrains.ReSharper.Psi.Caches; +using JetBrains.ReSharper.Psi.CSharp; +using JetBrains.ReSharper.Psi.CSharp.Tree; +using JetBrains.ReSharper.Psi.Resolve; +using JetBrains.ReSharper.Psi.Tree; +using JetBrains.ReSharper.Psi.Util; +using ReSharperPlugin.RimworldDev.SymbolScope; + +namespace ReSharperPlugin.RimworldDev.TypeDeclaration; + +[ReferenceProviderFactory] +public class RimworldCSharpKeyedTranslationProvider : IReferenceProviderFactory +{ + public RimworldCSharpKeyedTranslationProvider(Lifetime lifetime) => + Changed = new Signal(lifetime, GetType().FullName); + + public IReferenceFactory CreateFactory(IPsiSourceFile sourceFile, IFile file, IWordIndex wordIndexForChecks) + { + return sourceFile.PrimaryPsiLanguage.Is() + ? new RimworldCSharpKeyedTranslationReferenceFactory() + : null; + } + + public ISignal Changed { get; } +} + +public class RimworldCSharpKeyedTranslationReferenceFactory : IReferenceFactory +{ + public ReferenceCollection GetReferences(ITreeNode element, ReferenceCollection oldReferences) + { + if (element is not ICSharpLiteralExpression || + element.NextSibling?.NextSibling is not ICSharpIdentifier identifier || + identifier.GetText() != "Translate") + return new ReferenceCollection(); + + var key = element.GetUnquotedText(); + var xmlSymbolTable = element.GetSolution().GetComponent(); + + var tag = xmlSymbolTable.GetKeyTag(key); + if (tag is null) + return new ReferenceCollection(); + + return new ReferenceCollection(new RimworldKeyedTranslationReference(element, tag, "English", key)); + } + + public bool HasReference(ITreeNode element, IReferenceNameContainer names) + { + if (element.NodeType.ToString() != "TEXT") return false; + return !element.Parent.GetText().Contains("defName") && names.Contains(element.GetText()); + } +} \ No newline at end of file diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldKeyedTranslationReference.cs b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldKeyedTranslationReference.cs new file mode 100644 index 0000000..6c04ec5 --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldKeyedTranslationReference.cs @@ -0,0 +1,62 @@ +using JetBrains.Annotations; +using JetBrains.ProjectModel; +using JetBrains.ReSharper.Psi; +using JetBrains.ReSharper.Psi.ExtensionsAPI.Resolve; +using JetBrains.ReSharper.Psi.Resolve; +using JetBrains.ReSharper.Psi.Tree; +using ReSharperPlugin.RimworldDev.SymbolScope; + +namespace ReSharperPlugin.RimworldDev.TypeDeclaration; + +public class RimworldKeyedTranslationReference : TreeReferenceBase +{ + private readonly ITreeNode myTypeElement; + + private string myName; + private string keyName; + private string language; + + public RimworldKeyedTranslationReference([NotNull] ITreeNode owner, ITreeNode typeElement, string laguage, string keyName) : base(owner) + { + myTypeElement = typeElement; + myName = $"{laguage}/{keyName}"; + this.keyName = keyName; + this.language = laguage; + } + + public override ISymbolTable GetReferenceSymbolTable(bool useReferenceName) + { + var symbolScope = myOwner.GetSolution().GetComponent(); + + symbolScope.AddDeclaredElement( + myOwner.GetSolution(), + myTypeElement, + language, + keyName, + false + ); + + return symbolScope.GetSymbolTable(myOwner.GetSolution()); + } + + public override ResolveResultWithInfo ResolveWithoutCache() + { + return GetReferenceSymbolTable(true).GetResolveResult(GetName()); + } + + public override string GetName() => myName; + + public override IReference BindTo(IDeclaredElement element) => + BindTo(element, EmptySubstitution.INSTANCE); + + public override IReference BindTo( + IDeclaredElement element, + ISubstitution substitution) + { + return this; + } + + public override TreeTextRange GetTreeTextRange() => myOwner.GetTreeTextRange(); + + public override IAccessContext GetAccessContext() => new ElementAccessContext(myOwner); +} \ No newline at end of file diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs index 57660cd..d43d313 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs @@ -4,7 +4,6 @@ using System.Linq; using System.Xml; using JetBrains.Annotations; -using JetBrains.Application.Threading.Tasks; using JetBrains.Metadata.Reader.API; using JetBrains.ProjectModel; using JetBrains.ProjectModel.model2.Assemblies.Interfaces; @@ -12,8 +11,8 @@ using JetBrains.ReSharper.Psi.Caches; using JetBrains.ReSharper.Psi.Modules; using JetBrains.ReSharper.Psi.Util; +using JetBrains.ReSharper.Resources.Shell; using JetBrains.Util; -using JetBrains.Util.Threading.Tasks; using ReSharperPlugin.RimworldDev.Settings; namespace ReSharperPlugin.RimworldDev; @@ -48,11 +47,14 @@ public static bool UpdateScopes(ISolution solution) if (rimworldScope == null) { - AddRef(solution); + using (WriteLockCookie.Create()) + { + AddRef(solution); + } return false; } - + rimworldModule = solution.PsiModules().GetModules() .First(module => module.GetPsiServices().Symbols.GetSymbolScope(module, true, true) @@ -90,8 +92,9 @@ private static async void AddRef(ISolution solution) var moduleReferenceResolveContext = (IModuleReferenceResolveContext)UniversalModuleReferenceContext.Instance; - await solution.Locks.Tasks.YieldTo(solution.GetSolutionLifetimes().MaximumLifetime, Scheduling.MainDispatcher, - TaskPriority.Low); + // await solution.Locks.Tasks.YieldTo(solution.GetSolutionLifetimes().MaximumLifetime, + // Scheduling.MainDispatcher, + // TaskPriority.Low); solution.GetComponent().AddRef(path.ToAssemblyLocation(), "ScopeHelper::AddRef", moduleReferenceResolveContext); diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbol.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbol.cs new file mode 100644 index 0000000..c345768 --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbol.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using JetBrains.ReSharper.Psi.Tree; +using JetBrains.Serialization; +using JetBrains.Util.PersistentMap; + +namespace ReSharperPlugin.RimworldDev.SymbolScope; + +public class RimworldKeyedTranslationSymbol +{ + public static readonly IUnsafeMarshaller> Marshaller = + UnsafeMarshallers.GetCollectionMarshaller(new UniversalMarshaller(Read, Write), (size) => new List()); + + public string KeyName { get; } + public string Langauge { get; } + + public int DocumentOffset { get; } + + public RimworldKeyedTranslationSymbol(ITreeNode tag, string keyName, string language) + { + KeyName = keyName; + Langauge = language; + DocumentOffset = tag.GetTreeStartOffset().Offset; + } + + public RimworldKeyedTranslationSymbol(int documentOffset, string keyName, string language) + { + KeyName = keyName; + Langauge = language; + DocumentOffset = documentOffset; + } + + private static RimworldKeyedTranslationSymbol Read(UnsafeReader reader) + { + var keyName = reader.ReadString(); + var langauge = reader.ReadString(); + var documentOffset = reader.ReadInt(); + + return new RimworldKeyedTranslationSymbol(documentOffset, langauge, keyName); + } + + private static void Write(UnsafeWriter writer, RimworldKeyedTranslationSymbol value) + { + writer.Write(value.KeyName); + writer.Write(value.Langauge); + writer.Write(value.DocumentOffset); + } +} \ No newline at end of file diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs new file mode 100644 index 0000000..656828a --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs @@ -0,0 +1,170 @@ +using System.Collections.Generic; +using System.Linq; +using JetBrains; +using JetBrains.Annotations; +using JetBrains.Application.Parts; +using JetBrains.Application.Threading; +using JetBrains.Lifetimes; +using JetBrains.ProjectModel; +using JetBrains.ReSharper.Psi; +using JetBrains.ReSharper.Psi.Caches; +using JetBrains.ReSharper.Psi.ExtensionsAPI.Resolve; +using JetBrains.ReSharper.Psi.Files; +using JetBrains.ReSharper.Psi.Resolve; +using JetBrains.ReSharper.Psi.Tree; +using JetBrains.ReSharper.Psi.Xml.Tree; +using ReSharperPlugin.RimworldDev.TypeDeclaration; + +namespace ReSharperPlugin.RimworldDev.SymbolScope; + +[PsiComponent(Instantiation.ContainerAsyncPrimaryThread)] +public class RimworldKeyedTranslationSymbolScope: SimpleICache> +{ + private Dictionary> KeyedTranslations = new(); + private Dictionary _declaredElements = new(); + private SymbolTable _symbolTable; + + public List GetKeys() + { + if (!KeyedTranslations.ContainsKey("English")) + KeyedTranslations["English"] = new Dictionary(); + + return KeyedTranslations["English"].Keys.ToList(); + } + + public IXmlTag GetKeyTag(string key) + { + if (!KeyedTranslations.ContainsKey("English")) + KeyedTranslations["English"] = new Dictionary(); + + if (!KeyedTranslations["English"].ContainsKey(key)) + return null; + + return KeyedTranslations["English"][key]; + } + + public RimworldKeyedTranslationSymbolScope( + Lifetime lifetime, + [NotNull] IShellLocks locks, + [NotNull] IPersistentIndexManager persistentIndexManager, + long? version = null + ) : base(lifetime, locks, persistentIndexManager, RimworldKeyedTranslationSymbol.Marshaller, version) + { + } + + public override object Build(IPsiSourceFile sourceFile, bool isStartup) + { + if (!IsApplicable(sourceFile)) return null; + if (sourceFile.GetPrimaryPsiFile() is not IXmlFile xmlFile) return null; + + var tags = xmlFile.GetNestedTags("LanguageData/*").Where(tag => true); + + var symbols = tags.Select(tag => new RimworldKeyedTranslationSymbol( + tag, + tag.GetName().XmlName, + "English" + )).ToList(); + + if (symbols.Count == 0) return null; + return symbols; + } + + public override void Merge(IPsiSourceFile sourceFile, object builtPart) + { + RemoveFromLocalCache(sourceFile); + AddToLocalCache(sourceFile, builtPart as List); + base.Merge(sourceFile, builtPart); + } + + public override void MergeLoaded(object data) + { + PopulateLocalCache(); + base.MergeLoaded(data); + } + + public override void Drop(IPsiSourceFile sourceFile) + { + RemoveFromLocalCache(sourceFile); + base.Drop(sourceFile); + } + + private void RemoveFromLocalCache(IPsiSourceFile sourceFile) + { + var items = Map!.GetValueSafe(sourceFile); + + items?.ForEach(item => + { + if (KeyedTranslations.ContainsKey(item.Langauge) && KeyedTranslations[item.Langauge].ContainsKey(item.KeyName)) + KeyedTranslations[item.Langauge].Remove(item.KeyName); + }); + } + + private void PopulateLocalCache() + { + foreach (var (sourceFile, cacheItem) in Map) + AddToLocalCache(sourceFile, cacheItem); + } + + private void AddToLocalCache(IPsiSourceFile sourceFile, [CanBeNull] List cacheItem) + { + if (sourceFile.GetPrimaryPsiFile() is not IXmlFile xmlFile) return; + + cacheItem?.ForEach(item => + { + var matchingItem = xmlFile.GetNestedTags($"LanguageData/{item.KeyName}").FirstOrDefault(); + + if (matchingItem is null) return; + + AddKeyToList(item, matchingItem); + }); + } + + private void AddKeyToList(RimworldKeyedTranslationSymbol item, IXmlTag xmlTag) + { + if (!KeyedTranslations.ContainsKey(item.Langauge)) + KeyedTranslations[item.Langauge] = new Dictionary(); + + if (!KeyedTranslations[item.Langauge].ContainsKey(item.KeyName)) + KeyedTranslations[item.Langauge].Add(item.KeyName, xmlTag); + else + KeyedTranslations[item.Langauge][item.KeyName] = xmlTag; + } + + protected override bool IsApplicable(IPsiSourceFile sourceFile) + { + return base.IsApplicable(sourceFile) && sourceFile.LanguageType.Name == "XML"; + } + + public void AddDeclaredElement( + ISolution solution, + ITreeNode owner, + string language, + string keyName, + bool caseSensitiveName) + { + if (_symbolTable == null) _symbolTable = new SymbolTable(solution.GetPsiServices()); + + if (_declaredElements.ContainsKey($"{language}/{keyName}")) + { + _declaredElements[$"{language}/{keyName}"].Update(owner); + return; + } + + var declaredElement = new XMLTagDeclaredElement( + owner, + $"{language}/{keyName}", + caseSensitiveName + ); + + // @TODO: We seem to get "Key Already Exists" errors. Race condition? + _declaredElements.Add($"{language}/{keyName}", declaredElement); + _symbolTable.AddSymbol(declaredElement); + } + + public ISymbolTable GetSymbolTable(ISolution solution) + { + if (_symbolTable == null) _symbolTable = new SymbolTable(solution.GetPsiServices()); + + return _symbolTable; + } +} \ No newline at end of file diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldSymbolScope.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldSymbolScope.cs index f3b434a..45a6dc7 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldSymbolScope.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldSymbolScope.cs @@ -3,9 +3,7 @@ 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; @@ -43,9 +41,12 @@ public class RimworldSymbolScope : SimpleICache> private SymbolTable _symbolTable; public RimworldSymbolScope - (Lifetime lifetime, [NotNull] IShellLocks locks, [NotNull] IPersistentIndexManager persistentIndexManager, - long? version = null) - : base(lifetime, locks, persistentIndexManager, RimworldXmlDefSymbol.Marshaller, version) + ( + Lifetime lifetime, + [NotNull] IShellLocks locks, + [NotNull] IPersistentIndexManager persistentIndexManager, + long? version = null + ) : base(lifetime, locks, persistentIndexManager, RimworldXmlDefSymbol.Marshaller, version) { } diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldXmlDefSymbol.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldXmlDefSymbol.cs index fd58ba0..e4bbbde 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldXmlDefSymbol.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldXmlDefSymbol.cs @@ -15,8 +15,6 @@ public class RimworldXmlDefSymbol public int DocumentOffset { get; } - // public IXmlTag Tag { get; } - public RimworldXmlDefSymbol(ITreeNode tag, string defName, string defType) { DefName = defName; diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs b/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs index 63c9121..7010924 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using System.Xml; using JetBrains.ReSharper.Psi; using JetBrains.ReSharper.Psi.Tree; @@ -22,6 +21,15 @@ public XMLTagDeclaredElement(ITreeNode owner, string defType, string defName, bo PresentationLanguage = owner.Language; } + public XMLTagDeclaredElement(ITreeNode owner, string keyName, bool caseSensitiveName) + { + this.owner = owner; + myPsiServices = owner.GetPsiServices(); + ShortName = $"{keyName}"; + CaseSensitiveName = caseSensitiveName; + PresentationLanguage = owner.Language; + } + public void Update(ITreeNode newOwner) { owner = newOwner; From 942b5cf16ea8324f428e7d6ec74f24b163ecc950 Mon Sep 17 00:00:00 2001 From: Gareth Date: Fri, 17 Apr 2026 20:17:35 +0100 Subject: [PATCH 02/11] Make it easier to swap between Rider versions --- example-mod/Source/AshAndDust.csproj | 4 +- .../RimworldXmlProjectHost.cs | 53 ++++++++++++------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/example-mod/Source/AshAndDust.csproj b/example-mod/Source/AshAndDust.csproj index 5f458eb..e62d280 100644 --- a/example-mod/Source/AshAndDust.csproj +++ b/example-mod/Source/AshAndDust.csproj @@ -33,7 +33,7 @@ False - D:\SteamLibrary\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\Assembly-CSharp.dll + ..\..\..\RimWorldWin64_Data\Managed\Assembly-CSharp.dll False @@ -41,7 +41,7 @@ - D:\SteamLibrary\steamapps\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.CoreModule.dll + ..\..\..\RimWorldWin64_Data\Managed\UnityEngine.CoreModule.dll False diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/RimworldXmlProject/RimworldXmlProjectHost.cs b/src/dotnet/ReSharperPlugin.RimworldDev/RimworldXmlProject/RimworldXmlProjectHost.cs index 4b71ed0..8af9cfc 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/RimworldXmlProject/RimworldXmlProjectHost.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/RimworldXmlProject/RimworldXmlProjectHost.cs @@ -26,7 +26,7 @@ public class RimworldXmlProjectHost : SolutionFileProjectHostBase private readonly FileSystemWildcardService myWildcardService; private readonly ProjectFilePropertiesFactory myProjectFilePropertiesFactory; private bool hasReloaded = false; - + public RimworldXmlProjectHost( IPlatformManager platformManager, ProjectFilePropertiesFactory projectFilePropertiesFactory, @@ -40,13 +40,18 @@ public RimworldXmlProjectHost( myStructureBuilder = new RimworldProjectStructureBuilder(myProjectFilePropertiesFactory); myWildcardService = wildcardService; } - + public override bool IsApplicable(IProjectMark projectMark) { return projectMark.Guid.ToString() == "f2a71f9b-5d33-465a-a702-920d77279781"; } - protected override void Reload(ProjectHostReloadChange change, VirtualFileSystemPath logPath) + // To swap between Rider 2025.3 and Rider 2026.1, swap the `override` between these two methods + protected void Reload(ProjectHostReloadChange change, FileSystemPath logPath) => Reload(change); + + protected override void Reload(ProjectHostReloadChange change, VirtualFileSystemPath logPath) => Reload(change); + + protected void Reload(ProjectHostReloadChange change) { if (change.ProjectMark is not RimworldProjectMark projectMark) return; @@ -70,26 +75,31 @@ protected override void Reload(ProjectHostReloadChange change, VirtualFileSystem { targetFramework }, defaultLanguage, EmptyList.InstanceList); - + // This is a quick fix suggested by Jetbrains to fix where Files/Folders get created when adding them to our project - var config = projectProperties.TryGetConfiguration(projectProperties.ActiveConfigurations.TargetFrameworkIds.FirstNotNull()); + var config = + projectProperties.TryGetConfiguration(projectProperties.ActiveConfigurations + .TargetFrameworkIds.FirstNotNull()); config?.UpdatePropertyCollection(x => x[MSBuildProjectUtil.BaseDirectoryProperty] = projectMark.Location.Parent.Parent.FullPath); - - var customDescriptor = new RimworldProjectDescriptor(projectMark.Guid, projectProperties, null, projectMark.Name, + + var customDescriptor = new RimworldProjectDescriptor(projectMark.Guid, projectProperties, null, + projectMark.Name, siteProjectLocation, projectMark.Location); var byProjectLocation = ProjectDescriptor.CreateWithoutItemsByProjectDescriptor(customDescriptor); - - myStructureBuilder.Build(byProjectLocation, ProjectFolderFilter.Instance, GetLoadFolders(projectMark.Location.Parent.Parent)); - myWildcardService.RegisterDirectory(projectMark, siteProjectLocation, targetFramework, ProjectFolderFilter.Instance); - + + myStructureBuilder.Build(byProjectLocation, ProjectFolderFilter.Instance, + GetLoadFolders(projectMark.Location.Parent.Parent)); + myWildcardService.RegisterDirectory(projectMark, siteProjectLocation, targetFramework, + ProjectFolderFilter.Instance); + change.Descriptors = new ProjectHostChangeDescriptors(byProjectLocation) { ProjectReferencesDescriptor = BuildReferences(targetFramework, projectMark) }; } - + private List GetLoadFolders(VirtualFileSystemPath basePath) { var loadFolders = new List(); @@ -109,7 +119,7 @@ private List GetLoadFolders(VirtualFileSystemPath basePath) } versionList.Sort(); - + var folderTags = document.GetElementsByTagName(versionList.Last())[0].ChildNodes; for (var i = 0; i < folderTags.Count; i++) { @@ -133,16 +143,22 @@ private static VirtualFileSystemPath GetProjectLocation([NotNull] IProjectMark p return location; } - private static ProjectReferencesDescriptor BuildReferences([NotNull] TargetFrameworkId targetFrameworkId, [NotNull] IProjectMark projectMark) + private static ProjectReferencesDescriptor BuildReferences([NotNull] TargetFrameworkId targetFrameworkId, + [NotNull] IProjectMark projectMark) { - if (projectMark is not RimworldProjectMark rimworldProjectMark || rimworldProjectMark.Dependencies.Count == 0) return null; + if (projectMark is not RimworldProjectMark rimworldProjectMark || + rimworldProjectMark.Dependencies.Count == 0) return null; var pairList = new List>(); rimworldProjectMark.Dependencies.ForEach(dependency => { - var reference = new ProjectToProjectReferenceBySearchDescriptor(targetFrameworkId, dependency.ToProjectSearchDescriptor()); - pairList.Add(new Pair(reference, ProjectReferenceProperties.Instance) ); + var reference = + new ProjectToProjectReferenceBySearchDescriptor(targetFrameworkId, + dependency.ToProjectSearchDescriptor()); + pairList.Add( + new Pair(reference, + ProjectReferenceProperties.Instance)); }); return new ProjectReferencesDescriptor(pairList); @@ -161,6 +177,7 @@ public bool Filter(VirtualFileSystemPath path) => path.Name.Equals("bin", StringComparison.OrdinalIgnoreCase) || path.Name.EndsWith(".DotSettings.user", StringComparison.OrdinalIgnoreCase) || path.Name.Equals("node_modules", StringComparison.OrdinalIgnoreCase) || - !new List {"About", "Defs", "Patches", "Languages", "Sounds", "Textures", "News"}.Any(it => path.FullPath.Contains(it)); + !new List { "About", "Defs", "Patches", "Languages", "Sounds", "Textures", "News" }.Any(it => + path.FullPath.Contains(it)); } } \ No newline at end of file From 9570da5428ee740c8c35d3ac11992c712f2a3d8b Mon Sep 17 00:00:00 2001 From: Gareth Date: Sat, 18 Apr 2026 00:06:18 +0100 Subject: [PATCH 03/11] Implement Find Usages on Keyed Translations and also extend Find Usages for XML Defs to find them in C# files --- .../Navigation/RimworldSearcherFactory.cs | 7 +- ...rldXmlKeyedTranslationReferenceProvider.cs | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/Navigation/RimworldSearcherFactory.cs b/src/dotnet/ReSharperPlugin.RimworldDev/Navigation/RimworldSearcherFactory.cs index e9e1ece..256ab7f 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/Navigation/RimworldSearcherFactory.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/Navigation/RimworldSearcherFactory.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using JetBrains.ReSharper.Psi; +using JetBrains.ReSharper.Psi.CSharp; using JetBrains.ReSharper.Psi.ExtensionsAPI; using JetBrains.ReSharper.Psi.Search; using JetBrains.ReSharper.Psi.Xml; @@ -10,7 +11,8 @@ namespace ReSharperPlugin.RimworldDev.Navigation; [PsiSharedComponent] public class RimworldSearcherFactory(SearchDomainFactory searchDomainFactory) : DomainSpecificSearcherFactoryBase { - public override bool IsCompatibleWithLanguage(PsiLanguageType languageType) => languageType.Is(); + public override bool IsCompatibleWithLanguage(PsiLanguageType languageType) => + languageType.Is() || languageType.Is(); public override ISearchDomain GetDeclaredElementSearchDomain(IDeclaredElement declaredElement) { @@ -21,8 +23,7 @@ public override IDomainSpecificSearcher CreateReferenceSearcher( IDeclaredElementsSet elements, ReferenceSearcherParameters referenceSearcherParameters) { - elements = new DeclaredElementsSet(elements.Where(element => - IsCompatibleWithLanguage(element.PresentationLanguage))); + elements = new DeclaredElementsSet(elements.Where(element => element.PresentationLanguage.Is())); return new CustomSearcher(this, elements, referenceSearcherParameters, false); } diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs new file mode 100644 index 0000000..90dbe52 --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs @@ -0,0 +1,64 @@ +using System; +using System.Linq; +using JetBrains.DataFlow; +using JetBrains.Lifetimes; +using JetBrains.ProjectModel; +using JetBrains.ReSharper.Psi; +using JetBrains.ReSharper.Psi.Caches; +using JetBrains.ReSharper.Psi.Resolve; +using JetBrains.ReSharper.Psi.Tree; +using JetBrains.ReSharper.Psi.Web.WebConfig; +using JetBrains.ReSharper.Psi.Xml; +using JetBrains.ReSharper.Psi.Xml.Impl.Tree; +using ReSharperPlugin.RimworldDev.SymbolScope; + +namespace ReSharperPlugin.RimworldDev.TypeDeclaration; + +[ReferenceProviderFactory] +public class RimworldXmlKeyedTranslationReferenceProviderFactory : IReferenceProviderFactory +{ + public RimworldXmlKeyedTranslationReferenceProviderFactory(Lifetime lifetime) => + Changed = new Signal(lifetime, GetType().FullName); + + public IReferenceFactory CreateFactory(IPsiSourceFile sourceFile, IFile file, IWordIndex wordIndexForChecks) + { + return sourceFile.PrimaryPsiLanguage.Is() && sourceFile.GetExtensionWithDot() + .Equals(".xml", StringComparison.CurrentCultureIgnoreCase) + ? new RimworldXmlKeyedTranslationReferenceProvider() + : null; + } + + public ISignal Changed { get; } +} + +// This reference provider only serves to attach a reference on the Keyed Translation to itself. It's a hacky workaround +// to allow us to do Find Usages on Keyed Translations +public class RimworldXmlKeyedTranslationReferenceProvider : IReferenceFactory +{ + public ReferenceCollection GetReferences(ITreeNode element, ReferenceCollection oldReferences) + { + if ( + element is not XmlIdentifier identifier || + identifier?.Parent?.Parent?.Parent is not XmlTag { } LangaugeDataTag || + LangaugeDataTag.Children().First() is not XmlTagHeaderNode languageDataHeaderNode || + languageDataHeaderNode.Children().ElementAt(1) is not XmlIdentifier languageDataIdentifier || + languageDataIdentifier.GetText() != "LanguageData" + ) + return new ReferenceCollection(); + + var keyName = identifier.GetText(); + var xmlSymbolTable = element.GetSolution().GetComponent(); + + var tag = xmlSymbolTable.GetKeyTag(keyName); + if (tag is null) + return new ReferenceCollection(); + + return new ReferenceCollection(new RimworldKeyedTranslationReference(element, tag, "English", keyName)); + } + + public bool HasReference(ITreeNode element, IReferenceNameContainer names) + { + if (element.NodeType.ToString() != "TEXT") return false; + return !element.Parent.GetText().Contains("defName") && names.Contains(element.GetText()); + } +} \ No newline at end of file From cdb56c181f9dc51eb93f292c96026fb0b32ce7d8 Mon Sep 17 00:00:00 2001 From: Gareth Date: Sat, 18 Apr 2026 10:24:51 +0100 Subject: [PATCH 04/11] Fetch the keyed translations language from the file path instead of hardcoding it to English. Other places where we hardcode it will have to be changed as well. --- .../SymbolScope/RimworldKeyedTranslationSymbolScope.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs index 656828a..69602b7 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using System.Text.RegularExpressions; using JetBrains; using JetBrains.Annotations; using JetBrains.Application.Parts; @@ -56,13 +57,19 @@ public override object Build(IPsiSourceFile sourceFile, bool isStartup) { if (!IsApplicable(sourceFile)) return null; if (sourceFile.GetPrimaryPsiFile() is not IXmlFile xmlFile) return null; + if (!sourceFile.DisplayName.Contains("Languages")) return null; + var languageMatch = Regex.Match(sourceFile.DisplayName, @"Languages\\(.*?)\\Keyed"); + if (!languageMatch.Success) return null; + + var language = languageMatch.Groups[1].Value; + var tags = xmlFile.GetNestedTags("LanguageData/*").Where(tag => true); var symbols = tags.Select(tag => new RimworldKeyedTranslationSymbol( tag, tag.GetName().XmlName, - "English" + language )).ToList(); if (symbols.Count == 0) return null; From 10be264cb95919cf0352a774f82236e6b73875a9 Mon Sep 17 00:00:00 2001 From: Gareth Date: Sat, 18 Apr 2026 13:20:19 +0100 Subject: [PATCH 05/11] Support multiple languages for Keyed Translations --- ...orldKeyedTranslationsCSharpItemProvider.cs | 7 +- .../RimworldCSharpKeyedTranslationProvider.cs | 13 ++- ...rldXmlKeyedTranslationReferenceProvider.cs | 8 +- .../RimworldKeyedTranslationSymbolScope.cs | 105 +++++++++++++----- 4 files changed, 97 insertions(+), 36 deletions(-) diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/RimworldKeyedTranslationsCSharpItemProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/RimworldKeyedTranslationsCSharpItemProvider.cs index f7d1069..65c1637 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/RimworldKeyedTranslationsCSharpItemProvider.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ItemCompletion/RimworldKeyedTranslationsCSharpItemProvider.cs @@ -40,13 +40,14 @@ protected override bool AddLookupItems(CSharpCodeCompletionContext context, IIte foreach (var key in xmlSymbolTable.GetKeys()) { - var keyTag = xmlSymbolTable.GetKeyTag(key); - if (keyTag == null) continue; + var nullableKeyTag = xmlSymbolTable.GetTranslationKey(key); + if (!nullableKeyTag.HasValue) continue; + var keyTag = nullableKeyTag.Value; var lookup = LookupFactory.CreateDeclaredElementLookupItem( context, key, - new DeclaredElementInstance(new XMLTagDeclaredElement(keyTag, $"English/{key}", false)) + new DeclaredElementInstance(new XMLTagDeclaredElement(keyTag.Tag, $"{keyTag.Language}/{keyTag.KeyName}", false)) ); collector.Add(lookup); diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs index 0f1dab7..63c821f 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs @@ -1,3 +1,4 @@ +using System.Linq; using JetBrains.DataFlow; using JetBrains.Lifetimes; using JetBrains.ProjectModel; @@ -40,11 +41,15 @@ public ReferenceCollection GetReferences(ITreeNode element, ReferenceCollection var key = element.GetUnquotedText(); var xmlSymbolTable = element.GetSolution().GetComponent(); - var tag = xmlSymbolTable.GetKeyTag(key); - if (tag is null) + if (!xmlSymbolTable.HasTranslationKey(key)) return new ReferenceCollection(); - - return new ReferenceCollection(new RimworldKeyedTranslationReference(element, tag, "English", key)); + + var tags = xmlSymbolTable.GetAllTagsForKey(key); + return new ReferenceCollection( + tags.Select(tag => + new RimworldKeyedTranslationReference(element, tag.Tag, tag.Language, tag.KeyName + )).ToList() + ); } public bool HasReference(ITreeNode element, IReferenceNameContainer names) diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs index 90dbe52..a5691e6 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs @@ -49,11 +49,13 @@ element is not XmlIdentifier identifier || var keyName = identifier.GetText(); var xmlSymbolTable = element.GetSolution().GetComponent(); - var tag = xmlSymbolTable.GetKeyTag(keyName); - if (tag is null) + var nullableTag = xmlSymbolTable.GetTranslationKey(keyName); + if (nullableTag is null) return new ReferenceCollection(); - return new ReferenceCollection(new RimworldKeyedTranslationReference(element, tag, "English", keyName)); + var tag = nullableTag.Value; + + return new ReferenceCollection(new RimworldKeyedTranslationReference(element, tag.Tag, tag.Language, tag.KeyName)); } public bool HasReference(ITreeNode element, IReferenceNameContainer names) diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs index 69602b7..ee37b46 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs @@ -18,30 +18,72 @@ namespace ReSharperPlugin.RimworldDev.SymbolScope; +public struct TranslationKey +{ + public TranslationKey(string language, string keyName, IXmlTag tag) + { + Language = language; + KeyName = keyName; + Tag = tag; + } + + public string Language { get; } + public string KeyName { get; } + + public IXmlTag Tag { get; } +} + [PsiComponent(Instantiation.ContainerAsyncPrimaryThread)] public class RimworldKeyedTranslationSymbolScope: SimpleICache> { - private Dictionary> KeyedTranslations = new(); - private Dictionary _declaredElements = new(); - private SymbolTable _symbolTable; + private Dictionary> keyedTranslations; + private Dictionary declaredElements = new(); + private SymbolTable symbolTable; + private string defaultLanguage = "English"; + private string ideLanguage = "English"; + public List GetKeys() { - if (!KeyedTranslations.ContainsKey("English")) - KeyedTranslations["English"] = new Dictionary(); - - return KeyedTranslations["English"].Keys.ToList(); + var keys = new List(); + foreach (var language in keyedTranslations.Values) + { + keys.AddRange(language.Keys); + } + + return keys.Distinct().ToList(); } - public IXmlTag GetKeyTag(string key) + public TranslationKey? GetTranslationKey(string key) { - if (!KeyedTranslations.ContainsKey("English")) - KeyedTranslations["English"] = new Dictionary(); + if (!keyedTranslations.ContainsKey(ideLanguage)) + keyedTranslations[ideLanguage] = new (); - if (!KeyedTranslations["English"].ContainsKey(key)) + if (!keyedTranslations[ideLanguage].ContainsKey(key)) return null; - return KeyedTranslations["English"][key]; + return keyedTranslations[ideLanguage][key]; + } + + public List GetAllTagsForKey(string key) + { + var tags = new List(); + + foreach (var language in keyedTranslations.Values) + { + if (!language.ContainsKey(key)) continue; + tags.Add(language[key]); + } + + return tags; + } + + public bool HasTranslationKey(string key) + { + if (keyedTranslations[ideLanguage].ContainsKey(key)) return true; + if (ideLanguage != defaultLanguage && keyedTranslations[defaultLanguage].ContainsKey(key)) return true; + + return keyedTranslations.Any(language => language.Value.ContainsKey(key)); } public RimworldKeyedTranslationSymbolScope( @@ -51,6 +93,15 @@ public RimworldKeyedTranslationSymbolScope( long? version = null ) : base(lifetime, locks, persistentIndexManager, RimworldKeyedTranslationSymbol.Marshaller, version) { + keyedTranslations = new() + { + { defaultLanguage, new() }, + }; + + if (defaultLanguage != ideLanguage) + { + keyedTranslations.Add(ideLanguage, new()); + } } public override object Build(IPsiSourceFile sourceFile, bool isStartup) @@ -101,8 +152,8 @@ private void RemoveFromLocalCache(IPsiSourceFile sourceFile) items?.ForEach(item => { - if (KeyedTranslations.ContainsKey(item.Langauge) && KeyedTranslations[item.Langauge].ContainsKey(item.KeyName)) - KeyedTranslations[item.Langauge].Remove(item.KeyName); + if (keyedTranslations.ContainsKey(item.Langauge) && keyedTranslations[item.Langauge].ContainsKey(item.KeyName)) + keyedTranslations[item.Langauge].Remove(item.KeyName); }); } @@ -128,13 +179,15 @@ private void AddToLocalCache(IPsiSourceFile sourceFile, [CanBeNull] List(); + if (!keyedTranslations.ContainsKey(item.Langauge)) + keyedTranslations[item.Langauge] = new (); + + var translationKey = new TranslationKey(item.Langauge, item.KeyName, xmlTag); - if (!KeyedTranslations[item.Langauge].ContainsKey(item.KeyName)) - KeyedTranslations[item.Langauge].Add(item.KeyName, xmlTag); + if (!keyedTranslations[item.Langauge].ContainsKey(item.KeyName)) + keyedTranslations[item.Langauge].Add(item.KeyName, translationKey); else - KeyedTranslations[item.Langauge][item.KeyName] = xmlTag; + keyedTranslations[item.Langauge][item.KeyName] = translationKey; } protected override bool IsApplicable(IPsiSourceFile sourceFile) @@ -149,11 +202,11 @@ public void AddDeclaredElement( string keyName, bool caseSensitiveName) { - if (_symbolTable == null) _symbolTable = new SymbolTable(solution.GetPsiServices()); + if (symbolTable == null) symbolTable = new SymbolTable(solution.GetPsiServices()); - if (_declaredElements.ContainsKey($"{language}/{keyName}")) + if (declaredElements.ContainsKey($"{language}/{keyName}")) { - _declaredElements[$"{language}/{keyName}"].Update(owner); + declaredElements[$"{language}/{keyName}"].Update(owner); return; } @@ -164,14 +217,14 @@ public void AddDeclaredElement( ); // @TODO: We seem to get "Key Already Exists" errors. Race condition? - _declaredElements.Add($"{language}/{keyName}", declaredElement); - _symbolTable.AddSymbol(declaredElement); + declaredElements.Add($"{language}/{keyName}", declaredElement); + symbolTable.AddSymbol(declaredElement); } public ISymbolTable GetSymbolTable(ISolution solution) { - if (_symbolTable == null) _symbolTable = new SymbolTable(solution.GetPsiServices()); + if (symbolTable == null) symbolTable = new SymbolTable(solution.GetPsiServices()); - return _symbolTable; + return symbolTable; } } \ No newline at end of file From ca78dd72961d338dc6971f5f4dc3ce771d8625f5 Mon Sep 17 00:00:00 2001 From: Gareth Date: Mon, 18 May 2026 13:29:01 +0100 Subject: [PATCH 06/11] Add some basic error highlighting for keyed translations --- .../KeyedTranslationAnalysis.cs | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs new file mode 100644 index 0000000..f062632 --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs @@ -0,0 +1,130 @@ +using System; +using System.Text.RegularExpressions; +using JetBrains.Annotations; +using JetBrains.Application.Parts; +using JetBrains.Application.Settings; +using JetBrains.DocumentModel; +using JetBrains.ProjectModel; +using JetBrains.ReSharper.Daemon.AspRouteTemplates.Highlightings; +using JetBrains.ReSharper.Daemon.CSharp.Errors; +using JetBrains.ReSharper.Daemon.CSharp.Stages; +using JetBrains.ReSharper.Daemon.UsageChecking; +using JetBrains.ReSharper.Feature.Services.CSharp.Daemon; +using JetBrains.ReSharper.Feature.Services.Daemon; +using JetBrains.ReSharper.Psi; +using JetBrains.ReSharper.Psi.CSharp.Tree; +using JetBrains.ReSharper.Psi.Tree; +using JetBrains.ReSharper.Psi.Util; +using ReSharperPlugin.RimworldDev.SymbolScope; + +namespace ReSharperPlugin.RimworldDev.ProblemAnalyzers; + +[DaemonStage( + Instantiation.DemandAnyThreadSafe, + StagesBefore = [typeof(LanguageSpecificDaemonStage), typeof(CollectUsagesStage)], + HighlightingTypes = [typeof(KeyedTranslationAnalysisProcessStage)] +)] +public class KeyedTranslationAnalysisStage : CSharpDaemonStageBase +{ + protected override IDaemonStageProcess CreateProcess( + IDaemonProcess process, + IContextBoundSettingsStore settings, + DaemonProcessKind processKind, + ICSharpFile file + ) + { + return new KeyedTranslationAnalysisProcessStage(process, file); + } + + [HighlightingSource(HighlightingTypes = [typeof (MethodMissingRouteParametersHighlighting)])] + public class KeyedTranslationAnalysisProcessStage : CSharpDaemonStageProcessBase, IRecursiveElementProcessor + { + [NotNull] private readonly IHighlightingConsumer myConsumer; + [NotNull] private readonly RimworldKeyedTranslationSymbolScope symbolScope; + + public KeyedTranslationAnalysisProcessStage([NotNull] IDaemonProcess process, + [NotNull] ICSharpFile file) : base(process, file) + { + symbolScope = file.GetSolution().GetComponent(); + + myConsumer = new FilteringHighlightingConsumer( + DaemonProcess.SourceFile, + File, + DaemonProcess.ContextBoundSettingsStore + ); + } + + public override void Execute(Action committer) + { + File.ProcessDescendants(this); + committer(new DaemonStageResult(myConsumer.CollectHighlightings())); + } + + public bool InteriorShouldBeProcessed(ITreeNode element) + { + return true; + } + + void IRecursiveElementProcessor.ProcessBeforeInterior(ITreeNode element) + { + } + + public void ProcessAfterInterior(ITreeNode element) + { + if (element is not IIdentifier { Name: "Translate" }) return; + if (element.Parent?.Parent is not IInvocationExpression invocation) return; + if (element.Parent?.FirstChild is not ICSharpLiteralExpression translationStringElement) return; + + var translationKey = translationStringElement.GetUnquotedText(); + var arguments = invocation.ArgumentList; + var argumentCount = arguments.Arguments.Count; + + if (!symbolScope.HasTranslationKey(translationKey)) + { + AddError("Translation key does not exist", translationStringElement.GetDocumentRange()); + return; + } + + var translation = symbolScope.GetTranslationKey(translationKey).Value!.Tag.InnerText; + + var matches = Regex.Matches(translation, @"(\{\d+\})"); + + if (matches.Count != argumentCount) + { + AddError( + $"Invalid number of arguments. Expected {matches.Count}, Passed in {arguments.Arguments.Count}", + invocation.GetDocumentRange() + ); + return; + } + } + + bool IRecursiveElementProcessor.ProcessingIsFinished + { + get + { + if (base.DaemonProcess.InterruptFlag) + throw new OperationCanceledException(); + return false; + } + } + + private void AddError(string errorText, DocumentRange range) + { + IHighlighting error = new KeyedTranslationHighlighting(errorText, range); + + myConsumer.AddHighlighting(error, range); + } + } + + [StaticSeverityHighlighting(Severity.ERROR, typeof(CSharpErrors))] + public class KeyedTranslationHighlighting(string tooltip, DocumentRange range) : IHighlighting + { + public bool IsValid() => true; + + public DocumentRange CalculateRange() => range; + + public string ToolTip => tooltip; + public string ErrorStripeToolTip => "test error"; + } +} \ No newline at end of file From 5bead81e280a719bbb93471ac535812151f5cd21 Mon Sep 17 00:00:00 2001 From: Gareth Date: Mon, 18 May 2026 23:57:59 +0100 Subject: [PATCH 07/11] Add a quick doc for keyed translations --- .../KeyedTranslationProvider.cs | 104 ++++++++++++++++++ .../TypeDeclaration/XMLTagDeclaredElement.cs | 3 + 2 files changed, 107 insertions(+) create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs new file mode 100644 index 0000000..c5d8208 --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using JetBrains.Application.DataContext; +using JetBrains.Application.UI.Components.Theming; +using JetBrains.ReSharper.Feature.Services.Descriptions; +using JetBrains.ReSharper.Feature.Services.Navigation; +using JetBrains.ReSharper.Feature.Services.QuickDoc; +using JetBrains.ReSharper.Feature.Services.QuickDoc.Render; +using JetBrains.ReSharper.Psi; +using JetBrains.ReSharper.Psi.CSharp; +using JetBrains.ReSharper.Psi.DataContext; +using JetBrains.ReSharper.Psi.Modules; +using JetBrains.UI.RichText; +using ReSharperPlugin.RimworldDev.TypeDeclaration; + +namespace DefaultNamespace; + +[QuickDocProvider(1)] +public class KeyedTranslationProvider(ITheming theming): IQuickDocProvider +{ + public bool CanNavigate(IDataContext context) + { + var data = context.GetData(PsiDataConstants.DECLARED_ELEMENTS_FROM_ALL_CONTEXTS); + var tags = data?.Where(element => element is XMLTagDeclaredElement).ToList(); + + return tags != null && tags.Count != 0; + } + + public void Resolve(IDataContext context, Action resolved) + { + var data = context.GetData(PsiDataConstants.DECLARED_ELEMENTS_FROM_ALL_CONTEXTS); + var tags = data?.Where(element => element is XMLTagDeclaredElement).ToList(); + + var presenter = + new KeyedTranslationPresenter(theming, tags.First()); + + resolved(presenter, CSharpLanguage.Instance); + } +} + +public class KeyedTranslationPresenter(ITheming theming, IDeclaredElement element) : IQuickDocPresenter +{ + private readonly DeclaredElementEnvoy myEnvoy = new(element); + + public QuickDocTitleAndText GetHtml(PsiLanguageType presentationLanguage) + { + var validDeclaredElement = myEnvoy.GetValidDeclaredElement(); + if (validDeclaredElement == null) + return QuickDocTitleAndText.Empty; + var presenter = new KeyedTranslationDescriptionProvider(); + var block = presenter.GetElementDescription( + validDeclaredElement, + DeclaredElementDescriptionStyle.FULL_STYLE, + presentationLanguage + ); + + return new QuickDocTitleAndText( + new RichText().FullHtml(_ => { }, body => body.Append(block.ToHtml()), theming), + DeclaredElementPresenter.Format( + presentationLanguage, + DeclaredElementPresenter.FULL_NESTED_NAME_PRESENTER, + validDeclaredElement + ) + ); + } + + public string GetId() => null; + + public IQuickDocPresenter Resolve(string id) => null; + + public void OpenInEditor(string navigationId = "") + { + IDeclaredElement validDeclaredElement = this.myEnvoy.GetValidDeclaredElement(); + if (validDeclaredElement == null) + return; + validDeclaredElement.Navigate(true); + } + + public void ReadMore(string navigationId = "") + { + } +} + +public class KeyedTranslationDescriptionProvider : IDeclaredElementDescriptionProvider +{ + public RichTextBlock GetElementDescription(IDeclaredElement element, DeclaredElementDescriptionStyle style, + PsiLanguageType language, IPsiModule module = null) + { + if (element is not XMLTagDeclaredElement declaredElement) return new RichTextBlock(); + + var block = new RichTextBlock { declaredElement.GetInnerText() }; + + return block; + } + + public bool? IsElementObsolete(IDeclaredElement element, out RichTextBlock obsoleteDescription, + DeclaredElementDescriptionStyle style) + { + obsoleteDescription = null; + return false; + } + + public int Priority => 0; +} \ No newline at end of file diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs b/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs index 7010924..b327fcb 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs @@ -2,6 +2,7 @@ using System.Xml; using JetBrains.ReSharper.Psi; using JetBrains.ReSharper.Psi.Tree; +using JetBrains.ReSharper.Psi.Xml.Impl.Tree; using JetBrains.Util; using JetBrains.Util.DataStructures; @@ -81,4 +82,6 @@ public bool HasDeclarationsIn(IPsiSourceFile sourceFile) public XmlNode GetXMLDoc(bool inherit) => (XmlNode)null; public XmlNode GetXMLDescriptionSummary(bool inherit) => (XmlNode)null; + + public string GetInnerText() => owner is XmlTag ownerTag ? ownerTag.InnerText : owner.GetText(); } \ No newline at end of file From 707b02cbc4d5153587c21f85f54a4847171a2ed4 Mon Sep 17 00:00:00 2001 From: Gareth Date: Tue, 19 May 2026 00:22:53 +0100 Subject: [PATCH 08/11] Show full translation key tag in quick doc and fix repopulating the cache --- .../QuickDocumentation/KeyedTranslationProvider.cs | 2 +- .../SymbolScope/RimworldKeyedTranslationSymbol.cs | 2 +- .../TypeDeclaration/XMLTagDeclaredElement.cs | 6 ++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs index c5d8208..0343b1b 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs @@ -88,7 +88,7 @@ public RichTextBlock GetElementDescription(IDeclaredElement element, DeclaredEle { if (element is not XMLTagDeclaredElement declaredElement) return new RichTextBlock(); - var block = new RichTextBlock { declaredElement.GetInnerText() }; + var block = new RichTextBlock { declaredElement.GetText() }; return block; } diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbol.cs b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbol.cs index c345768..64584c3 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbol.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbol.cs @@ -35,7 +35,7 @@ private static RimworldKeyedTranslationSymbol Read(UnsafeReader reader) var langauge = reader.ReadString(); var documentOffset = reader.ReadInt(); - return new RimworldKeyedTranslationSymbol(documentOffset, langauge, keyName); + return new RimworldKeyedTranslationSymbol(documentOffset, keyName, langauge); } private static void Write(UnsafeWriter writer, RimworldKeyedTranslationSymbol value) diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs b/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs index b327fcb..3d4352d 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs @@ -79,9 +79,11 @@ public bool HasDeclarationsIn(IPsiSourceFile sourceFile) public IPsiServices GetPsiServices() => myPsiServices; - public XmlNode GetXMLDoc(bool inherit) => (XmlNode)null; + public XmlNode GetXMLDoc(bool inherit) => null; - public XmlNode GetXMLDescriptionSummary(bool inherit) => (XmlNode)null; + public XmlNode GetXMLDescriptionSummary(bool inherit) => null; + public string GetText() => owner.GetText(); + public string GetInnerText() => owner is XmlTag ownerTag ? ownerTag.InnerText : owner.GetText(); } \ No newline at end of file From e4dddaa13906f84c6fd5208f2407b68a919e01b7 Mon Sep 17 00:00:00 2001 From: Gareth Date: Thu, 21 May 2026 23:44:49 +0100 Subject: [PATCH 09/11] Add a first pass at the context action for extracting strings to keyed tranlations. There's still more to add, but we've got too much to not commit --- .../StringToKeyedTranslationAction.cs | 243 ++++++++++++++++++ .../KeyedTranslationAnalysis.cs | 7 +- 2 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs new file mode 100644 index 0000000..96c9709 --- /dev/null +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs @@ -0,0 +1,243 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using JetBrains.Annotations; +using JetBrains.Application.DataContext; +using JetBrains.Application.Help; +using JetBrains.Application.Progress; +using JetBrains.Application.UI.Actions.ActionManager; +using JetBrains.DataFlow; +using JetBrains.IDE.UI; +using JetBrains.IDE.UI.Extensions; +using JetBrains.Lifetimes; +using JetBrains.ProjectModel; +using JetBrains.ReSharper.Feature.Services.ContextActions; +using JetBrains.ReSharper.Feature.Services.CSharp.ContextActions; +using JetBrains.ReSharper.Feature.Services.Refactorings; +using JetBrains.ReSharper.Feature.Services.UI.Validation; +using JetBrains.ReSharper.Psi.CSharp; +using JetBrains.ReSharper.Psi.CSharp.Tree; +using JetBrains.ReSharper.Psi.ExtensionsAPI.Tree; +using JetBrains.ReSharper.Psi.Files; +using JetBrains.ReSharper.Psi.Paths; +using JetBrains.ReSharper.Psi.Tree; +using JetBrains.ReSharper.Psi.Util; +using JetBrains.ReSharper.Psi.Xml.Impl.Tree; +using JetBrains.ReSharper.Psi.Xml.Parsing; +using JetBrains.ReSharper.Psi.Xml.Tree; +using JetBrains.Rider.Model.UIAutomation; +using JetBrains.TextControl; +using JetBrains.Util; + +namespace ReSharperPlugin.RimworldDev.ContextActions; + +[ContextAction(Description = "Move to Keyed Translation", GroupType = typeof (CSharpContextActions), Name = "StringToKeyedTranslation", + Priority = 1)] +public class StringToKeyedTranslationAction(ICSharpContextActionDataProvider provider) : ContextActionBase +{ + public override string Text => "Move to Keyed Translation"; + + protected override Action ExecutePsiTransaction(ISolution solution, IProgressIndicator progress) + { + var selectedElement = provider.GetSelectedElement(); + + return (_) => + { + using var lifetimeDefinition = Lifetime.Define(Lifetime.Eternal); + var withDataRules = JetBrains.ReSharper.Resources.Shell.Shell.Instance.GetComponent() + .DataContexts.CreateWithDataRules(lifetimeDefinition.Lifetime); + + var workflow = new StringToKeyedTranslationWorkflow(solution, null, selectedElement); + + RefactoringActionUtil.ExecuteRefactoring(withDataRules, workflow); + }; + } + + public override bool IsAvailable(IUserDataHolder cache) + { + var selectedElement = provider.GetSelectedElement(); + + if (selectedElement is null) return false; + if (selectedElement.Parent is IReferenceExpression + { + LastChild: IIdentifier { Name: "Translate" } + }) return false; + + if (selectedElement is IInterpolatedStringExpression interpolatedStringExpression) + { + return !interpolatedStringExpression.Inserts.Any(insert => insert.Expression is not IReferenceExpression); + } + + return selectedElement is ICSharpLiteralExpression; + } +} + +public class StringToKeyedTranslationWorkflow([NotNull] ISolution solution, [CanBeNull] string actionId, ITreeNode selectedElement) + : DrivenRefactoringWorkflow(solution, actionId) +{ + public StringToKeyedTranslationDataModel DataModel; + public StringToKeyedTranslationDataProvider DataProvider = new (""); + + public override bool Initialize(IDataContext context) + { + return true; + } + + public override bool IsAvailable(IDataContext context) + { + return true; + } + + public override HelpId HelpKeyword => HelpId.Empty; + + public override IRefactoringPage FirstPendingRefactoringPage => new StringToKeyedTranslationRefactoringPage(this); + + public override bool MightModifyManyDocuments => true; + public override string Title => "Move to Keyed Translation"; + public override RefactoringActionGroup ActionGroup => RefactoringActionGroup.Convert; + + public override IRefactoringExecuter CreateRefactoring(IRefactoringDriver driver) + { + DataModel = new StringToKeyedTranslationDataModel(selectedElement); + + return new StringToKeyedTranslationRefactoring(this, solution, driver); + } +} + +public class StringToKeyedTranslationDataModel(ITreeNode stringExpression) : IDataModel +{ + public ITreeNode StringExpression { get; } = stringExpression; +} + +public class StringToKeyedTranslationDataProvider(string name) : IDataProvider +{ + public string Name = name; + + public bool NonInteractive => true; +} + +public class StringToKeyedTranslationRefactoringPage : SingleBeRefactoringPage +{ + private readonly BeGrid content; + private StringToKeyedTranslationWorkflow workflow; + public IProperty Name { get; } + + public StringToKeyedTranslationRefactoringPage(StringToKeyedTranslationWorkflow workflow) : base(workflow.WorkflowExecuterLifetime) + { + this.workflow = workflow; + + Name = new Property( "StringToKeyedTranslation.Name", ""); + + var component = workflow.GetComponent(); + var nameControl = Name.GetBeTextBox(Lifetime).WithTextNotEmpty( + Lifetime, + ValidationStates.validationError.GetIcon(component) + ); + + content = new BeControl[1] + { + nameControl.WithDescription("Key Name", Lifetime) + }.GetGrid(); + } + + public override BeControl GetPageContent() => content; + + public override void Commit() + { + workflow.DataProvider = new StringToKeyedTranslationDataProvider(Name.Value); + } +} + +// TODO: If the file isn't referencing Verse yet, we need to insert that reference +// TODO: Allow the user to select the file they want to add the key to +public class StringToKeyedTranslationRefactoring( + [NotNull] StringToKeyedTranslationWorkflow workflow, + [NotNull] ISolution solution, + [NotNull] IRefactoringDriver driver) + : DrivenRefactoringBase(workflow, solution, driver) +{ + public override bool Execute(IProgressIndicator pi) + { + var selectedElement = Workflow.DataModel.StringExpression; + if (selectedElement is not ICSharpExpression csharpExpression) return false; + + var textToTranslate = ""; + var arguments = new List(); + + if (selectedElement is ICSharpLiteralExpression literalExpression) + { + textToTranslate = literalExpression.GetUnquotedText(); + } + + if (selectedElement is IInterpolatedStringExpression interpolatedStringExpression) + { + if (!Regex.IsMatch(interpolatedStringExpression.GetUnquotedText(), "^\\$\".*")) + return false; + + textToTranslate = Regex.Replace(interpolatedStringExpression.GetUnquotedText(), "^\\$\"(.*)$", "$1"); + + arguments = interpolatedStringExpression + .Inserts + .Select(insert => + { + if (insert.Expression is not IReferenceExpression referenceExpression) + return ""; + + return insert.GetText(); + }) + .Where(argument => !string.IsNullOrEmpty(argument)) + .Distinct() + .ToList(); + } + + var translationKey = Workflow.DataProvider.Name; + + var invocation = CSharpElementFactory + .GetInstance(csharpExpression) + .CreateExpression($"\"$0\".Translate({String.Join(", ", arguments)})", Workflow.DataProvider.Name); + + var rimworldProject = Solution + .GetTopLevelProjects() + .FirstOrDefault(project => project.ProjectFileLocation.FullPath.EndsWith("About.xml")); + + if (rimworldProject == null) + return false; + + var languagesFolder = rimworldProject.GetSubItems().FirstOrDefault(item => item.Name == "Languages"); + if (languagesFolder == null) + return false; + + var translationFiles = rimworldProject + .GetAllProjectFiles() + .Where(file => file.Location.FullPath.StartsWith(languagesFolder.Location.FullPath)); + + var firstFile = translationFiles.FirstOrDefault(); + if (firstFile == null) + return false; + + var psiFile = rimworldProject.GetPsiSourceFileInProject(firstFile.Location).GetPrimaryPsiFile(); + if (psiFile is not XmlFile xmlFile) + return false; + + var languageDataTag = xmlFile.GetNestedTags("LanguageData").FirstOrDefault(); + if (languageDataTag == null) + return false; + + var lastTag = languageDataTag.Children().Last(tag => tag is IXmlTag); + + for (var index = 0; index < arguments.Count; index++) + { + var argument = arguments[index]; + + textToTranslate = textToTranslate.FullReplace("{" + argument + "}", "{" + index + "}"); + } + + var newTag = XmlElementFactory.GetInstance(languageDataTag).CreateTagForTag(languageDataTag, $"<{translationKey}>{textToTranslate}"); + + csharpExpression.ReplaceBy(invocation); + ModificationUtil.AddChildAfter(languageDataTag, lastTag, newTag); + + return true; + } +} \ No newline at end of file diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs index f062632..4b2214e 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Text.RegularExpressions; using JetBrains.Annotations; using JetBrains.Application.Parts; @@ -87,7 +88,11 @@ public void ProcessAfterInterior(ITreeNode element) var translation = symbolScope.GetTranslationKey(translationKey).Value!.Tag.InnerText; - var matches = Regex.Matches(translation, @"(\{\d+\})"); + var matches = Regex + .Matches(translation, @"(\{\d+\})") + .Select(match => match.Value) + .Distinct() + .ToList(); if (matches.Count != argumentCount) { From e5b91f2bb153ba0d42a5c8965d65acd71893fe7d Mon Sep 17 00:00:00 2001 From: Gareth Date: Thu, 21 May 2026 23:58:05 +0100 Subject: [PATCH 10/11] Add another TODO --- .../ContextActions/StringToKeyedTranslationAction.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs index 96c9709..a1fd747 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs @@ -151,6 +151,7 @@ public override void Commit() // TODO: If the file isn't referencing Verse yet, we need to insert that reference // TODO: Allow the user to select the file they want to add the key to +// TODO: Add an option to insert the XML into all languages defined, not just the default language public class StringToKeyedTranslationRefactoring( [NotNull] StringToKeyedTranslationWorkflow workflow, [NotNull] ISolution solution, From c4d0ec55d0f2db89258af0d14d90a766eabb68d7 Mon Sep 17 00:00:00 2001 From: Gareth Date: Fri, 22 May 2026 00:24:24 +0100 Subject: [PATCH 11/11] Add a method to turn off Analysis and Generators for non-rimworld projects --- .../ContextActions/StringToKeyedTranslationAction.cs | 6 ++++++ .../Generator/DefGenerator.cs | 6 +++++- .../ProblemAnalyzers/CustomXmlAnalysisStageProcess.cs | 2 ++ .../ProblemAnalyzers/KeyedTranslationAnalysis.cs | 2 ++ src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs | 10 ++++++++++ 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs index a1fd747..8fa8aeb 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs @@ -56,6 +56,9 @@ protected override Action ExecutePsiTransaction(ISolution solution public override bool IsAvailable(IUserDataHolder cache) { + if (!ScopeHelper.IsRimworldProject()) + return false; + var selectedElement = provider.GetSelectedElement(); if (selectedElement is null) return false; @@ -86,6 +89,9 @@ public override bool Initialize(IDataContext context) public override bool IsAvailable(IDataContext context) { + if (!ScopeHelper.IsRimworldProject()) + return false; + return true; } diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/Generator/DefGenerator.cs b/src/dotnet/ReSharperPlugin.RimworldDev/Generator/DefGenerator.cs index 4185279..20abcef 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/Generator/DefGenerator.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/Generator/DefGenerator.cs @@ -43,6 +43,8 @@ public GenerateDefPropertiesWorkflowXml() public override bool IsAvailable(IDataContext dataContext) { + if (!ScopeHelper.IsRimworldProject()) return false; + var solution = dataContext.GetData(ProjectModelDataConstants.SOLUTION); if (solution == null) return false; @@ -57,7 +59,7 @@ public override bool IsAvailable(IDataContext dataContext) } public override bool IsEnabled(ITreeNode context) { - return true; + return ScopeHelper.IsRimworldProject(); } public override bool IsEmptyInputAllowed(IGeneratorContext context) @@ -71,6 +73,8 @@ public class DefPropertiesGeneratorBuilderXml : GeneratorBuilderBase committer) { + if (!ScopeHelper.IsRimworldProject()) return; + File.ProcessDescendants(this); committer(new DaemonStageResult(myConsumer.CollectHighlightings())); } diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs index 4b2214e..5af0f1e 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs @@ -57,6 +57,8 @@ public KeyedTranslationAnalysisProcessStage([NotNull] IDaemonProcess process, public override void Execute(Action committer) { + if (!ScopeHelper.IsRimworldProject()) return; + File.ProcessDescendants(this); committer(new DaemonStageResult(myConsumer.CollectHighlightings())); } diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs index d43d313..187927b 100644 --- a/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs +++ b/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs @@ -25,12 +25,21 @@ public class ScopeHelper private static IPsiModule rimworldModule; private static List usedScopes; private static bool adding = false; + private static bool? isRimworldProject = false; + + public static bool IsRimworldProject() => isRimworldProject ?? false; public static bool UpdateScopes(ISolution solution) { if (solution == null) return false; using (CompilationContextCookie.GetOrCreate(UniversalModuleReferenceContext.Instance)) { + if (isRimworldProject == null) + { + isRimworldProject = solution.GetTopLevelProjects() + .Any(project => project.ProjectFileLocation.EndsWith("About.xml")); + } + allScopes = solution.PsiModules().GetModules().Select(module => module.GetPsiServices().Symbols.GetSymbolScope(module, true, true)).ToList(); @@ -61,6 +70,7 @@ public static bool UpdateScopes(ISolution solution) .GetTypeElementByCLRName("Verse.ThingDef") != null); } + isRimworldProject = true; return true; } }