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/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/ContextActions/StringToKeyedTranslationAction.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs
new file mode 100644
index 0000000..8fa8aeb
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/ContextActions/StringToKeyedTranslationAction.cs
@@ -0,0 +1,250 @@
+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)
+ {
+ if (!ScopeHelper.IsRimworldProject())
+ return false;
+
+ 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)
+ {
+ if (!ScopeHelper.IsRimworldProject())
+ return false;
+
+ 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
+// 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,
+ [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}{translationKey}>");
+
+ csharpExpression.ReplaceBy(invocation);
+ ModificationUtil.AddChildAfter(languageDataTag, lastTag, newTag);
+
+ return true;
+ }
+}
\ No newline at end of file
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
+{
+ 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 nullableKeyTag = xmlSymbolTable.GetTranslationKey(key);
+ if (!nullableKeyTag.HasValue) continue;
+ var keyTag = nullableKeyTag.Value;
+
+ var lookup = LookupFactory.CreateDeclaredElementLookupItem(
+ context,
+ key,
+ new DeclaredElementInstance(new XMLTagDeclaredElement(keyTag.Tag, $"{keyTag.Language}/{keyTag.KeyName}", false))
+ );
+
+ collector.Add(lookup);
+ }
+
+ return base.AddLookupItems(context, collector);
+ }
+
+}
\ No newline at end of file
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/ProblemAnalyzers/CustomXmlAnalysisStageProcess.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/CustomXmlAnalysisStageProcess.cs
index dd4cd83..e7a65a8 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/CustomXmlAnalysisStageProcess.cs
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/CustomXmlAnalysisStageProcess.cs
@@ -30,6 +30,8 @@ public CustomXmlAnalysisStageProcess(
public override void Execute([InstantHandle] Action 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
new file mode 100644
index 0000000..5af0f1e
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/ProblemAnalyzers/KeyedTranslationAnalysis.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Linq;
+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)
+ {
+ if (!ScopeHelper.IsRimworldProject()) return;
+
+ 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+\})")
+ .Select(match => match.Value)
+ .Distinct()
+ .ToList();
+
+ 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
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/QuickDocumentation/KeyedTranslationProvider.cs
new file mode 100644
index 0000000..0343b1b
--- /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.GetText() };
+
+ 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/References/RimworldCSharpKeyedTranslationProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs
new file mode 100644
index 0000000..63c821f
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldCSharpKeyedTranslationProvider.cs
@@ -0,0 +1,60 @@
+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.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();
+
+ if (!xmlSymbolTable.HasTranslationKey(key))
+ return new ReferenceCollection();
+
+ 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)
+ {
+ 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/References/RimworldXmlKeyedTranslationReferenceProvider.cs b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs
new file mode 100644
index 0000000..a5691e6
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/References/RimworldXmlKeyedTranslationReferenceProvider.cs
@@ -0,0 +1,66 @@
+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 nullableTag = xmlSymbolTable.GetTranslationKey(keyName);
+ if (nullableTag is null)
+ return new ReferenceCollection();
+
+ var tag = nullableTag.Value;
+
+ return new ReferenceCollection(new RimworldKeyedTranslationReference(element, tag.Tag, tag.Language, tag.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
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
diff --git a/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs b/src/dotnet/ReSharperPlugin.RimworldDev/ScopeHelper.cs
index 57660cd..187927b 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;
@@ -26,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();
@@ -48,17 +56,21 @@ 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)
.GetTypeElementByCLRName("Verse.ThingDef") != null);
}
+ isRimworldProject = true;
return true;
}
}
@@ -90,8 +102,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..64584c3
--- /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, keyName, langauge);
+ }
+
+ 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..ee37b46
--- /dev/null
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/SymbolScope/RimworldKeyedTranslationSymbolScope.cs
@@ -0,0 +1,230 @@
+using System.Collections.Generic;
+using System.Linq;
+using System.Text.RegularExpressions;
+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;
+
+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;
+ private Dictionary declaredElements = new();
+ private SymbolTable symbolTable;
+
+ private string defaultLanguage = "English";
+ private string ideLanguage = "English";
+
+ public List GetKeys()
+ {
+ var keys = new List();
+ foreach (var language in keyedTranslations.Values)
+ {
+ keys.AddRange(language.Keys);
+ }
+
+ return keys.Distinct().ToList();
+ }
+
+ public TranslationKey? GetTranslationKey(string key)
+ {
+ if (!keyedTranslations.ContainsKey(ideLanguage))
+ keyedTranslations[ideLanguage] = new ();
+
+ if (!keyedTranslations[ideLanguage].ContainsKey(key))
+ return null;
+
+ 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(
+ Lifetime lifetime,
+ [NotNull] IShellLocks locks,
+ [NotNull] IPersistentIndexManager persistentIndexManager,
+ 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)
+ {
+ 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,
+ language
+ )).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 ();
+
+ var translationKey = new TranslationKey(item.Langauge, item.KeyName, xmlTag);
+
+ if (!keyedTranslations[item.Langauge].ContainsKey(item.KeyName))
+ keyedTranslations[item.Langauge].Add(item.KeyName, translationKey);
+ else
+ keyedTranslations[item.Langauge][item.KeyName] = translationKey;
+ }
+
+ 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..3d4352d 100644
--- a/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs
+++ b/src/dotnet/ReSharperPlugin.RimworldDev/TypeDeclaration/XMLTagDeclaredElement.cs
@@ -1,8 +1,8 @@
using System.Collections.Generic;
-using System.Linq;
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;
@@ -22,6 +22,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;
@@ -70,7 +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