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}"); + + 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