diff --git a/PleasantUI.sln b/PleasantUI.sln
index 714bf104..c05dd4ef 100644
--- a/PleasantUI.sln
+++ b/PleasantUI.sln
@@ -12,24 +12,31 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.DataGrid", "src\
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Example", "samples\PleasantUI.Example\PleasantUI.Example.csproj", "{59DC0F4B-421E-407D-9435-F6ED14765634}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "designer", "designer", "{EBAC1577-CFD7-477C-848E-86BFFC345D90}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.ToolKit", "src\PleasantUI.ToolKit\PleasantUI.ToolKit.csproj", "{722B39BB-1CBF-4762-A5C5-439F4EB3A781}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Designer", "designer\PleasantUI.Designer\PleasantUI.Designer.csproj", "{88C264B9-51D4-41CF-AEC0-24F5075FA6B8}"
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_build", "_build", "{D6E493DE-5180-4BA0-B122-D155A5934D08}"
+ ProjectSection(SolutionItems) = preProject
+ build\Package.props = build\Package.props
+ build\PleasantUI.targets = build\PleasantUI.targets
+ build\PleasantUI.Localization.Analyzer.targets = build\PleasantUI.Localization.Analyzer.targets
+ build\PleasantUI.Localization.Generator.targets = build\PleasantUI.Localization.Generator.targets
+ EndProjectSection
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "generators", "generators", "{941F7A66-EFCB-4141-97CA-A0B779ED1BB4}"
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "generators", "generators", "{77203EAD-CB0D-4D37-B8E7-2F2BA0A1B1EF}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.MaterialIcons", "src\PleasantUI.MaterialIcons\PleasantUI.MaterialIcons.csproj", "{2ABE0576-837B-4D1B-970D-00B52BF0E078}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Localization.Generator", "generators\PleasantUI.Localization.Generator\PleasantUI.Localization.Generator.csproj", "{967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.MaterialIcons.SourceGenerator", "generators\PleasantUI.MaterialIcons.SourceGenerator\PleasantUI.MaterialIcons.SourceGenerator.csproj", "{9FDFD065-7664-4E0C-B928-D7FC8B074EDD}"
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "analyzers", "analyzers", "{20ECD7A9-C3D1-41AA-A8BB-640E3B28BBC0}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.ToolKit", "src\PleasantUI.ToolKit\PleasantUI.ToolKit.csproj", "{722B39BB-1CBF-4762-A5C5-439F4EB3A781}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Localization.Analyzer", "analyzers\PleasantUI.Localization.Analyzer\PleasantUI.Localization.Analyzer.csproj", "{A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{D6E493DE-5180-4BA0-B122-D155A5934D08}"
- ProjectSection(SolutionItems) = preProject
- build\Package.props = build\Package.props
- EndProjectSection
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_tests", "_tests", "{80E17402-8124-43B7-A0BB-E939CDD20E21}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "analyzers", "analyzers", "{6B8337BF-8256-47CF-836E-C5A3C745D931}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Localization.Analyzer.Tests", "tests\analyzers\PleasantUI.Localization.Analyzer.Tests\PleasantUI.Localization.Analyzer.Tests.csproj", "{D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{4BCCA866-A31E-49B1-AB79-B9C94651D295}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PleasantUI.Tests", "tests\PleasantUI.Tests\PleasantUI.Tests.csproj", "{A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -57,29 +64,35 @@ Global
{59DC0F4B-421E-407D-9435-F6ED14765634}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59DC0F4B-421E-407D-9435-F6ED14765634}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59DC0F4B-421E-407D-9435-F6ED14765634}.Release|Any CPU.Build.0 = Release|Any CPU
- {88C264B9-51D4-41CF-AEC0-24F5075FA6B8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {88C264B9-51D4-41CF-AEC0-24F5075FA6B8}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {88C264B9-51D4-41CF-AEC0-24F5075FA6B8}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {88C264B9-51D4-41CF-AEC0-24F5075FA6B8}.Release|Any CPU.Build.0 = Release|Any CPU
- {2ABE0576-837B-4D1B-970D-00B52BF0E078}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {2ABE0576-837B-4D1B-970D-00B52BF0E078}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {2ABE0576-837B-4D1B-970D-00B52BF0E078}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {2ABE0576-837B-4D1B-970D-00B52BF0E078}.Release|Any CPU.Build.0 = Release|Any CPU
- {9FDFD065-7664-4E0C-B928-D7FC8B074EDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {9FDFD065-7664-4E0C-B928-D7FC8B074EDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {9FDFD065-7664-4E0C-B928-D7FC8B074EDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {9FDFD065-7664-4E0C-B928-D7FC8B074EDD}.Release|Any CPU.Build.0 = Release|Any CPU
{722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Debug|Any CPU.Build.0 = Debug|Any CPU
{722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Release|Any CPU.ActiveCfg = Release|Any CPU
{722B39BB-1CBF-4762-A5C5-439F4EB3A781}.Release|Any CPU.Build.0 = Release|Any CPU
+ {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346}.Release|Any CPU.Build.0 = Release|Any CPU
+ {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{71BFCFA6-B53E-4F28-819F-51CB26516B5A} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40}
{91F6CDD0-6925-4289-90EC-8BDD245F7BD0} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40}
{59DC0F4B-421E-407D-9435-F6ED14765634} = {BEE78D8A-ED3D-4D34-9B29-43D5BB953D40}
- {88C264B9-51D4-41CF-AEC0-24F5075FA6B8} = {EBAC1577-CFD7-477C-848E-86BFFC345D90}
- {9FDFD065-7664-4E0C-B928-D7FC8B074EDD} = {941F7A66-EFCB-4141-97CA-A0B779ED1BB4}
- {2ABE0576-837B-4D1B-970D-00B52BF0E078} = {4BCCA866-A31E-49B1-AB79-B9C94651D295}
+ {967BBBAA-7F6F-4DA6-A8E5-9F0A70DFF684} = {77203EAD-CB0D-4D37-B8E7-2F2BA0A1B1EF}
+ {A519CDCF-2774-4FD9-AE5F-0F6B62F7C346} = {20ECD7A9-C3D1-41AA-A8BB-640E3B28BBC0}
+ {6B8337BF-8256-47CF-836E-C5A3C745D931} = {80E17402-8124-43B7-A0BB-E939CDD20E21}
+ {D3E71BE9-D438-48ED-A99D-EF30AEFD66A5} = {6B8337BF-8256-47CF-836E-C5A3C745D931}
+ {A6230EC5-60E1-41E8-BE8E-CA9A82D7F835} = {80E17402-8124-43B7-A0BB-E939CDD20E21}
EndGlobalSection
EndGlobal
diff --git a/README.md b/README.md
index 7cb5f909..3b3a198c 100644
--- a/README.md
+++ b/README.md
@@ -11,10 +11,16 @@
> **Repositories:** [Original (Onebeld)](https://github.com/Onebeld/PleasantUI) · [Fork (ghudulf)](https://github.com/ghudulf/PleasantUI)
-PleasantUI is a cross-platform UI theme and control library for [Avalonia](https://github.com/AvaloniaUI/Avalonia), inspired by Microsoft Fluent Design and the WinUI/UWP visual language. It completely re-styles every standard Avalonia control and adds a suite of custom controls, a multi-theme engine with custom theme support, a reactive localization system, and a custom window chrome — all AOT-compatible with no `rd.xml` required.
+PleasantUI is a cross-platform UI theme and control library for [Avalonia](https://github.com/AvaloniaUI/Avalonia), inspired by Microsoft Fluent Design, Google Material Design and the WinUI/UWP visual language. It completely re-styles every standard Avalonia control and adds a suite of custom controls, a multi-theme engine with custom theme support, a reactive localization system, and a custom window chrome — all AOT-compatible with no `rd.xml` required.
The project has been in active development since 2021, originally as part of the [Regul](https://github.com/Onebeld/Regul) and [Regul Save Cleaner](https://github.com/Onebeld/RegulSaveCleaner) projects.
+## 🏛️ Philosophy
+
+The philosophy is to provide the user with a _**pleasant**_ experience in using applications built on this library. This library isn't intended to be compared to other design principles or other UI libraries. If the interface looks good, then this library is doing its job.
+
+This library may also contain elements from other design principles or UI libraries if it makes the interface _**pleasant**_ enough (but with due attribution, of course).
+
## ✨ Features
### Complete Fluent-style control theming
@@ -47,7 +53,6 @@ Controls built from scratch that go beyond what Avalonia ships:
| `InformationBlock` | Compact pill-shaped label combining an icon and a value |  |
| `MarkedTextBox` / `MarkedNumericUpDown` | Input controls with inline label/unit markers | None |
| `RippleEffect` | Material-style ripple click feedback |  |
-| `SmoothScrollViewer` | ScrollViewer with inertia gesture support | None |
| `PleasantMiniWindow` | Lightweight floating window | None |
| `Timeline` | Displays a list of events in chronological order along a vertical axis. Supports four layout modes, custom icons, and five severity types. |  |
| `InstallWizard` | A multi-step installation wizard with a sidebar step list, progress bar, and Back / Next / Cancel navigation. |  |
@@ -311,9 +316,17 @@ string title = Localizer.TrDefault("DialogTitle", "Confirm");
## ❤️ Credits
-- [Avalonia](https://github.com/AvaloniaUI/Avalonia)
-- Some controls inspired by PieroCastillo's [Aura.UI](https://github.com/PieroCastillo/Aura.UI)
-- [ProgressRing](https://github.com/ymg2006/FluentAvalonia.ProgressRing) by ymg2006
+### Main
+- [Avalonia](https://github.com/AvaloniaUI/Avalonia) under license from MIT
- Built with [JetBrains Rider](https://www.jetbrains.com/rider/)
+### Controls and Code
+- [Aura.UI](https://github.com/PieroCastillo/Aura.UI) (PieroCastillo) - NavigationView and AuraTabView under license from MIT
+- [ProgressRing](https://github.com/ymg2006/FluentAvalonia.ProgressRing) (ymg2006) - under license from MIT
+- [RichTextKit](https://github.com/toptensoftware/RichTextKit) (toptensoftware) - implementation of BinarySearch under license from Apache 2.0
+- [weather](https://github.com/rocksdanister/weather) (rocksdanister) - implementation of BackdropBlurBorder under license from MIT
+- [Xaml.Behaviors.Interactions.Animated](https://github.com/Egolds/Xaml.Behaviors.Interactions.Animated) (Egolds) - implementation of VerticalScrollViewerAnimatedBehavior under license from MIT
+- [BilibiliClient](https://github.com/afunc233/BilibiliClient) (afunc233) - implementation of VirtualizingWrapPanel
+- [Material.Avalonia](https://github.com/AvaloniaCommunity/Material.Avalonia) (CommunityAvalonia) - implementation of Ripple Effect under license from MIT
+
diff --git a/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Shipped.md b/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Shipped.md
new file mode 100644
index 00000000..8dd1b9ad
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Shipped.md
@@ -0,0 +1,8 @@
+## Release 1.0
+
+### New Rules
+
+| Rule ID | Category | Severity | Notes |
+|---------|----------|----------|--------------------------------------------------|
+| AB0001 | Naming | Warning | Type names should not contain the company name. |
+| AB0002 | Usage | Warning | The speed must be lower than the Speed of Light. |
\ No newline at end of file
diff --git a/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Unshipped.md b/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Unshipped.md
new file mode 100644
index 00000000..8400ebe5
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/AnalyzerReleases.Unshipped.md
@@ -0,0 +1,6 @@
+### New Rules
+
+| Rule ID | Category | Severity | Notes |
+|---------|----------|----------|-------|
+PUILOC101 | Localization | Warning | ResxUsageAnalyzer
+PUILOC102 | Localization | Warning | MissingResxKeyAnalyzer
\ No newline at end of file
diff --git a/analyzers/PleasantUI.Localization.Analyzer/LocExclusions.txt b/analyzers/PleasantUI.Localization.Analyzer/LocExclusions.txt
new file mode 100644
index 00000000..8ff604e3
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/LocExclusions.txt
@@ -0,0 +1,61 @@
+AttachOverWindows
+Back
+Cancel
+ChangeMenu
+Close
+CloseMenu
+Collapse
+CollapseMenu
+ColorWheel
+Confirm
+CopyMenu
+CutMenu
+DeleteMenu
+Expand
+ExpandMenu
+Menu
+Next
+NoItems
+Off
+Ok
+On
+PasteMenu
+PathPicker/OpenFile
+Restore
+RestoreMenu
+SearchableComboBox/SearchPlaceholder
+Theme/Light
+Theme/Cave
+Theme/Cherry
+Theme/Custom
+Theme/Dark
+Theme/Ice
+Theme/Lunar
+Theme/Sunny
+Theme/System
+Theme/Mint
+Theme/Spruce
+Theme/Strawberry
+ToolKit/AddColor
+ToolKit/AddYourNewColor
+ToolKit/Colors
+ToolKit/CustomThemes
+ToolKit/NoCustomThemes
+ToolKit/Redo
+ToolKit/SavedColors
+ToolKit/ThemeEditor
+ToolKit/ThemeEditor/ColorCopiedToClipboard
+ToolKit/ThemeEditor/CopyTheme
+ToolKit/ThemeEditor/DragAndDropThemeHere
+ToolKit/ThemeEditor/ExportTheme
+ToolKit/ThemeEditor/GetColorsBasedOn
+ToolKit/ThemeEditor/ImportTheme
+ToolKit/ThemeEditor/NumberOfColorsInstalled
+ToolKit/ThemeEditor/PasteTheme
+ToolKit/ThemeEditor/ThemeCopiedToClipboard
+ToolKit/ThemeEditor/ThemeExported
+ToolKit/ThemeEditor/ThemeImported
+ToolKit/ThemeEditor/ThemeImportError
+ToolKit/ThemeEditor/ThemeName
+ToolKit/Themes
+ToolKit/Undo
\ No newline at end of file
diff --git a/analyzers/PleasantUI.Localization.Analyzer/MissingResxKeyAnalyzer.cs b/analyzers/PleasantUI.Localization.Analyzer/MissingResxKeyAnalyzer.cs
new file mode 100644
index 00000000..1c78a646
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/MissingResxKeyAnalyzer.cs
@@ -0,0 +1,374 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+
+namespace PleasantUI.Localization.Analyzer;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class MissingResxKeyAnalyzer : DiagnosticAnalyzer
+{
+ private static readonly DiagnosticDescriptor Rule = new(
+ "PUILOC102",
+ "Missing localization key",
+ "Localization key '{0}' is used in code but is missing from the .resx files",
+ "Localization",
+ DiagnosticSeverity.Warning,
+ true
+ );
+
+ private static readonly Regex CultureSuffixRegex = new(@"\.[a-z]{2}(-[A-Z]{2,4})?(-[A-Z][a-z]{3})?$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ private enum TargetMethodType
+ {
+ Tr,
+ TrDefault,
+ TryGetString,
+ BindingCreate
+ }
+
+ public override ImmutableArray SupportedDiagnostics => [Rule];
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+
+ context.RegisterCompilationStartAction(compilationContext =>
+ {
+ AnalyzerConfigOptions globalOptions =
+ compilationContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions;
+ if (globalOptions.TryGetValue("build_property.PleasantUIDisableResxAnalyzer", out string? msbuildValue) &&
+ string.Equals(msbuildValue.Trim(), "true", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ HashSet existingKeys = [];
+
+ List resxFiles = compilationContext.Options.AdditionalFiles
+ .Where(f => Path.GetExtension(f.Path).Equals(".resx", StringComparison.OrdinalIgnoreCase))
+ .Where(f => !CultureSuffixRegex.IsMatch(Path.GetFileNameWithoutExtension(f.Path)))
+ .ToList();
+
+ foreach (AdditionalText? file in resxFiles)
+ {
+ SourceText? sourceText = file.GetText(compilationContext.CancellationToken);
+ if (sourceText == null) continue;
+ try
+ {
+ XDocument doc = XDocument.Parse(sourceText.ToString());
+ IEnumerable? dataElements = doc.Root?.Elements("data");
+
+ if (dataElements == null)
+ continue;
+
+ foreach (XElement? element in dataElements)
+ {
+ string? name = element.Attribute("name")?.Value;
+ if (!string.IsNullOrEmpty(name)) existingKeys.Add(name?.Trim());
+ }
+ }
+ catch
+ {
+ // ignored
+ }
+ }
+
+ compilationContext.RegisterSemanticModelAction(semanticContext =>
+ {
+ AnalyzeLocalizerCalls(
+ semanticContext.SemanticModel,
+ existingKeys,
+ semanticContext.ReportDiagnostic,
+ semanticContext.CancellationToken);
+ });
+
+ compilationContext.RegisterSyntaxNodeAction(syntaxContext =>
+ {
+ AnalyzePropertyDeclaration(
+ (PropertyDeclarationSyntax)syntaxContext.Node,
+ syntaxContext.SemanticModel,
+ existingKeys,
+ syntaxContext.ReportDiagnostic,
+ syntaxContext.CancellationToken);
+ }, SyntaxKind.PropertyDeclaration);
+
+ compilationContext.RegisterAdditionalFileAction(fileContext =>
+ {
+ if (Path.GetExtension(fileContext.AdditionalFile.Path)
+ .Equals(".axaml", StringComparison.OrdinalIgnoreCase))
+ {
+ AnalyzeAxamlFile(
+ fileContext.AdditionalFile,
+ existingKeys,
+ fileContext.ReportDiagnostic,
+ fileContext.CancellationToken);
+ }
+ });
+ });
+ }
+
+ private static void AnalyzeLocalizerCalls(
+ SemanticModel model,
+ HashSet existingKeys,
+ Action reportDiagnostic,
+ System.Threading.CancellationToken cancellationToken)
+ {
+ SyntaxNode root = model.SyntaxTree.GetRoot(cancellationToken);
+
+ foreach (InvocationExpressionSyntax invocation in root.DescendantNodes().OfType())
+ {
+ if (invocation.ArgumentList.Arguments.Count == 0) continue;
+
+ ISymbol? symbol = model.GetSymbolInfo(invocation, cancellationToken).Symbol
+ ?? model.GetSymbolInfo(invocation.Expression, cancellationToken).Symbol;
+
+ if (symbol is not IMethodSymbol methodSymbol ||
+ !IsTargetLocalizationMethod(methodSymbol, out TargetMethodType methodType))
+ continue;
+
+ SeparatedSyntaxList args = invocation.ArgumentList.Arguments;
+ ExpressionSyntax keyExpr = args[0].Expression;
+
+ string? rawKey = TryGetStringArgument(model, keyExpr);
+ if (string.IsNullOrEmpty(rawKey)) continue;
+
+ string? context = ExtractContextFromArguments(args, methodType, model);
+ string combinedKey = !string.IsNullOrEmpty(context) ? $"{context}/{rawKey}" : rawKey!;
+
+ if (!existingKeys.Contains(combinedKey))
+ reportDiagnostic(Diagnostic.Create(Rule, keyExpr.GetLocation(), combinedKey));
+ }
+ }
+
+ private static void AnalyzePropertyDeclaration(
+ PropertyDeclarationSyntax propDecl,
+ SemanticModel model,
+ HashSet existingKeys,
+ Action reportDiagnostic,
+ System.Threading.CancellationToken cancellationToken)
+ {
+ ExpressionSyntax? initializer = propDecl.Initializer?.Value ?? propDecl.ExpressionBody?.Expression;
+ if (initializer == null) return;
+
+ string? key = TryGetStringArgument(model, initializer);
+ if (string.IsNullOrEmpty(key)) return;
+
+ if (model.GetDeclaredSymbol(propDecl, cancellationToken) is not { } propertySymbol)
+ return;
+
+ bool isLocalizationProperty = false;
+
+ if (propertySymbol.Name.Contains("TitleKey"))
+ isLocalizationProperty = true;
+ else if (propertySymbol.IsOverride)
+ {
+ IPropertySymbol? current = propertySymbol.OverriddenProperty;
+ while (current != null)
+ {
+ if (current.Name.Contains("TitleKey"))
+ {
+ isLocalizationProperty = true;
+ break;
+ }
+
+ current = current.OverriddenProperty;
+ }
+ }
+
+ if (isLocalizationProperty && !existingKeys.Contains(key ?? string.Empty))
+ reportDiagnostic(Diagnostic.Create(Rule, initializer.GetLocation(), key));
+ }
+
+ private static bool IsTargetLocalizationMethod(IMethodSymbol method, out TargetMethodType type)
+ {
+ type = default;
+ string typeName = method.ContainingType?.Name ?? string.Empty;
+ switch (typeName)
+ {
+ case "LocalizeBinding" when method.Name == "Create":
+ type = TargetMethodType.BindingCreate;
+ return true;
+ case "Localizer":
+ switch (method.Name)
+ {
+ case "Tr":
+ type = TargetMethodType.Tr;
+ return true;
+ case "TrDefault":
+ type = TargetMethodType.TrDefault;
+ return true;
+ case "TryGetString":
+ type = TargetMethodType.TryGetString;
+ return true;
+ }
+
+ break;
+ }
+
+ return false;
+ }
+
+ private static string? ExtractContextFromArguments(SeparatedSyntaxList args,
+ TargetMethodType methodType, SemanticModel model)
+ {
+ if (methodType == TargetMethodType.BindingCreate && args.Count > 1)
+ return TryGetStringArgument(model, args[1].Expression);
+
+ ArgumentSyntax? contextArg = args.FirstOrDefault(a => a.NameColon?.Name.Identifier.ValueText == "context");
+
+ if (contextArg != null)
+ return TryGetStringArgument(model, contextArg.Expression);
+
+ return methodType switch
+ {
+ TargetMethodType.Tr when args.Count > 1 && args[1].NameColon == null => TryGetStringArgument(model,
+ args[1].Expression),
+ TargetMethodType.TrDefault when args.Count > 2 && args[2].NameColon == null => TryGetStringArgument(model,
+ args[2].Expression),
+ _ => null
+ };
+ }
+
+ private static void AnalyzeAxamlFile(AdditionalText axamlFile, HashSet existingKeys,
+ Action reportDiagnostic, System.Threading.CancellationToken cancellationToken)
+ {
+ SourceText? sourceText = axamlFile.GetText(cancellationToken);
+ if (sourceText == null) return;
+
+ try
+ {
+ string textContent = sourceText.ToString();
+ XDocument doc = XDocument.Parse(textContent, LoadOptions.SetLineInfo);
+ if (doc.Root == null) return;
+
+ foreach (XElement element in doc.Descendants())
+ {
+ if (cancellationToken.IsCancellationRequested) return;
+
+ foreach (XAttribute attribute in element.Attributes())
+ {
+ string value = attribute.Value.Trim();
+ if (!value.StartsWith("{", StringComparison.Ordinal) || !value.Contains("Localize "))
+ continue;
+
+ (string Key, string? Context)? parsed = ExtractKeyFromExtension(value);
+
+ if (parsed == null)
+ continue;
+
+ string combinedKey = !string.IsNullOrEmpty(parsed.Value.Context)
+ ? $"{parsed.Value.Context}/{parsed.Value.Key}"
+ : parsed.Value.Key;
+
+ if (existingKeys.Contains(combinedKey))
+ continue;
+
+ System.Xml.IXmlLineInfo lineInfo = attribute;
+
+ if (!lineInfo.HasLineInfo())
+ continue;
+
+ int lineNumber = lineInfo.LineNumber - 1;
+
+ TextLine textLine = sourceText.Lines[lineNumber];
+ string lineText = textLine.ToString();
+
+ int indexInLine = lineText.IndexOf(attribute.Value, StringComparison.Ordinal);
+ if (indexInLine == -1) indexInLine = lineInfo.LinePosition - 1;
+
+ int startPosition = textLine.Start + indexInLine;
+ int length = attribute.Value.Length;
+
+ TextSpan span = new(startPosition, length);
+ LinePositionSpan lineSpan = new(
+ new LinePosition(lineNumber, indexInLine),
+ new LinePosition(lineNumber, indexInLine + length)
+ );
+
+ Location loc = Location.Create(axamlFile.Path, span, lineSpan);
+ reportDiagnostic(Diagnostic.Create(Rule, loc, combinedKey));
+ }
+ }
+ }
+ catch
+ {
+ // ignored
+ }
+ }
+
+ private static (string Key, string? Context)? ExtractKeyFromExtension(string extensionValue)
+ {
+ string content = extensionValue.Trim('{', '}').Trim();
+ int firstSpace = content.IndexOf(' ');
+
+ if (firstSpace == -1)
+ return null;
+
+ string argumentsPart = content.Substring(firstSpace + 1).Trim();
+ List arguments = SplitXamlArguments(argumentsPart);
+
+ if (arguments.Count == 0)
+ return null;
+
+ string rawKey = arguments[0].Trim().Trim('\'', '"');
+
+ if (rawKey.StartsWith("{", StringComparison.Ordinal) || rawKey.Contains("LocKey."))
+ return null;
+
+ string? context = null;
+ for (int i = 1; i < arguments.Count; i++)
+ {
+ string arg = arguments[i].Trim();
+ if (arg.StartsWith("Context", StringComparison.OrdinalIgnoreCase) && arg.Contains("="))
+ context = arg.Substring(arg.IndexOf('=') + 1).Trim('\'', '"', ' ');
+ }
+
+ return (rawKey, context);
+ }
+
+ private static string? TryGetStringArgument(SemanticModel model, ExpressionSyntax expression)
+ {
+ if (expression is LiteralExpressionSyntax literal)
+ return literal.Token.ValueText;
+
+ return model.GetConstantValue(expression).Value?.ToString();
+ }
+
+ private static List SplitXamlArguments(string p)
+ {
+ List r = [];
+ int braceCount = 0;
+ int startIdx = 0;
+
+ for (int i = 0; i < p.Length; i++)
+ {
+ switch (p[i])
+ {
+ case '{':
+ braceCount++;
+ break;
+ case '}':
+ braceCount--;
+ break;
+ case ',' when braceCount == 0:
+ r.Add(p.Substring(startIdx, i - startIdx));
+ startIdx = i + 1;
+ break;
+ }
+ }
+
+ if (startIdx < p.Length)
+ r.Add(p.Substring(startIdx));
+
+ return r;
+ }
+}
\ No newline at end of file
diff --git a/analyzers/PleasantUI.Localization.Analyzer/PleasantUI.Localization.Analyzer.csproj b/analyzers/PleasantUI.Localization.Analyzer/PleasantUI.Localization.Analyzer.csproj
new file mode 100644
index 00000000..55b154d8
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/PleasantUI.Localization.Analyzer.csproj
@@ -0,0 +1,41 @@
+
+
+
+ netstandard2.0
+ false
+ enable
+ latest
+
+ true
+ true
+
+ PleasantUI.Localization.Analyzer
+ PleasantUI.Localization.Analyzer
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+ ResXFileCodeGenerator
+ Resources.Designer.cs
+
+
+
+
+
+ True
+ True
+ Resources.resx
+
+
+
+
diff --git a/analyzers/PleasantUI.Localization.Analyzer/Properties/launchSettings.json b/analyzers/PleasantUI.Localization.Analyzer/Properties/launchSettings.json
new file mode 100644
index 00000000..0deb2b28
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/Properties/launchSettings.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "DebugRoslynAnalyzers": {
+ "commandName": "DebugRoslynComponent",
+ "targetProject": "../PleasantUI.Localization.Analyzer.Sample/PleasantUI.Localization.Analyzer.Sample.csproj"
+ }
+ }
+}
\ No newline at end of file
diff --git a/analyzers/PleasantUI.Localization.Analyzer/Readme.md b/analyzers/PleasantUI.Localization.Analyzer/Readme.md
new file mode 100644
index 00000000..a76b1902
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/Readme.md
@@ -0,0 +1,29 @@
+# Roslyn Analyzers Sample
+
+A set of three sample projects that includes Roslyn analyzers with code fix providers. Enjoy this template to learn from and modify analyzers for your own needs.
+
+## Content
+### PleasantUI.Localization.Analyzer
+A .NET Standard project with implementations of sample analyzers and code fix providers.
+**You must build this project to see the results (warnings) in the IDE.**
+
+- [SampleSemanticAnalyzer.cs](SampleSemanticAnalyzer.cs): An analyzer that reports invalid values used for the `speed` parameter of the `SetSpeed` function.
+- [SampleSyntaxAnalyzer.cs](SampleSyntaxAnalyzer.cs): An analyzer that reports the company name used in class definitions.
+- [SampleCodeFixProvider.cs](SampleCodeFixProvider.cs): A code fix that renames classes with company name in their definition. The fix is linked to [SampleSyntaxAnalyzer.cs](SampleSyntaxAnalyzer.cs).
+
+### PleasantUI.Localization.Analyzer.Sample
+A project that references the sample analyzers. Note the parameters of `ProjectReference` in [PleasantUI.Localization.Analyzer.Sample.csproj](../PleasantUI.Localization.Analyzer.Sample/PleasantUI.Localization.Analyzer.Sample.csproj), they make sure that the project is referenced as a set of analyzers.
+
+### PleasantUI.Localization.Analyzer.Tests
+Unit tests for the sample analyzers and code fix provider. The easiest way to develop language-related features is to start with unit tests.
+
+## How To?
+### How to debug?
+- Use the [launchSettings.json](Properties/launchSettings.json) profile.
+- Debug tests.
+
+### How can I determine which syntax nodes I should expect?
+Consider using the Roslyn Visualizer tool window, which allows you to observe the syntax tree.
+
+### Learn more about wiring analyzers
+The complete set of information is available at [roslyn github repo wiki](https://github.com/dotnet/roslyn/blob/main/docs/wiki/README.md).
\ No newline at end of file
diff --git a/analyzers/PleasantUI.Localization.Analyzer/Resources.Designer.cs b/analyzers/PleasantUI.Localization.Analyzer/Resources.Designer.cs
new file mode 100644
index 00000000..a850efa5
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/Resources.Designer.cs
@@ -0,0 +1,89 @@
+//------------------------------------------------------------------------------
+//
+// This code was generated by a tool.
+//
+// Changes to this file may cause incorrect behavior and will be lost if
+// the code is regenerated.
+//
+//------------------------------------------------------------------------------
+
+namespace PleasantUI.Localization.Analyzer
+{
+ using System;
+
+
+ [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")]
+ [System.Diagnostics.DebuggerNonUserCodeAttribute()]
+ [System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
+ internal class Resources
+ {
+ private static System.Resources.ResourceManager resourceMan;
+
+ private static System.Globalization.CultureInfo resourceCulture;
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance",
+ "CA1811:AvoidUncalledPrivateCode")]
+ internal Resources()
+ {
+ }
+
+ [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static System.Resources.ResourceManager ResourceManager
+ {
+ get
+ {
+ if (object.Equals(null, resourceMan))
+ {
+ System.Resources.ResourceManager temp =
+ new System.Resources.ResourceManager("PleasantUI.Localization.Analyzer.Resources",
+ typeof(Resources).Assembly);
+ resourceMan = temp;
+ }
+
+ return resourceMan;
+ }
+ }
+
+ [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)]
+ internal static System.Globalization.CultureInfo Culture
+ {
+ get { return resourceCulture; }
+ set { resourceCulture = value; }
+ }
+
+ internal static string AB0001Description
+ {
+ get { return ResourceManager.GetString("AB0001Description", resourceCulture); }
+ }
+
+ internal static string AB0001MessageFormat
+ {
+ get { return ResourceManager.GetString("AB0001MessageFormat", resourceCulture); }
+ }
+
+ internal static string AB0001Title
+ {
+ get { return ResourceManager.GetString("AB0001Title", resourceCulture); }
+ }
+
+ internal static string AB0001CodeFixTitle
+ {
+ get { return ResourceManager.GetString("AB0001CodeFixTitle", resourceCulture); }
+ }
+
+ internal static string AB0002Description
+ {
+ get { return ResourceManager.GetString("AB0002Description", resourceCulture); }
+ }
+
+ internal static string AB0002MessageFormat
+ {
+ get { return ResourceManager.GetString("AB0002MessageFormat", resourceCulture); }
+ }
+
+ internal static string AB0002Title
+ {
+ get { return ResourceManager.GetString("AB0002Title", resourceCulture); }
+ }
+ }
+}
\ No newline at end of file
diff --git a/analyzers/PleasantUI.Localization.Analyzer/Resources.resx b/analyzers/PleasantUI.Localization.Analyzer/Resources.resx
new file mode 100644
index 00000000..889364d9
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/Resources.resx
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 1.3
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Type names should not contain the company name.An optional longer localizable description of the diagnostic.
+
+
+ Type name '{0}' contains the company nameThe format-able message the diagnostic displays.
+
+
+ Type name contains the company nameThe title of the diagnostic.
+
+
+ Replace '{0}' with '{1}'The title of the code fix.
+
+
+ The speed must be lower than the Speed of Light.An optional longer localizable description of the diagnostic.
+
+
+ The specified speed '{0}' must be lower than the Speed of LightThe format-able message the diagnostic displays.
+
+
+ The impossible speedThe title of the diagnostic.
+
+
\ No newline at end of file
diff --git a/analyzers/PleasantUI.Localization.Analyzer/ResxUsageAnalyzer.cs b/analyzers/PleasantUI.Localization.Analyzer/ResxUsageAnalyzer.cs
new file mode 100644
index 00000000..a6c9f4e5
--- /dev/null
+++ b/analyzers/PleasantUI.Localization.Analyzer/ResxUsageAnalyzer.cs
@@ -0,0 +1,773 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Xml;
+using System.Xml.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using TypeInfo = Microsoft.CodeAnalysis.TypeInfo;
+
+namespace PleasantUI.Localization.Analyzer;
+
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public class ResxUsageAnalyzer : DiagnosticAnalyzer
+{
+ private static readonly DiagnosticDescriptor Rule = new(
+ "PUILOC101",
+ "Unused localization key",
+ "Localization key '{0}' from file '{1}' is not used in the project",
+ "Localization",
+ DiagnosticSeverity.Warning,
+ true,
+ customTags: ["CompilationEnd"]
+ );
+
+ private static readonly Regex CultureSuffixRegex = new(@"\.[a-z]{2}(-[A-Z]{2,4})?(-[A-Z][a-z]{3})?$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ private static readonly ConcurrentHashSet LibraryExclusions = LoadEmbeddedExclusions();
+
+ private enum TargetMethodType
+ {
+ Tr,
+ TrDefault,
+ TryGetString,
+ BindingCreate,
+ Indexer
+ }
+
+ public override ImmutableArray SupportedDiagnostics => [Rule];
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+ context.EnableConcurrentExecution();
+
+ context.RegisterCompilationStartAction(compilationContext =>
+ {
+ AnalyzerConfigOptions globalOptions = compilationContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions;
+
+ if (globalOptions.TryGetValue("build_property.PleasantUIDisableResxAnalyzer", out string? msbuildValue) &&
+ string.Equals(msbuildValue.Trim(), "true", StringComparison.OrdinalIgnoreCase))
+ return;
+
+ Compilation compilation = compilationContext.Compilation;
+
+ List resxFiles = compilationContext.Options.AdditionalFiles
+ .Where(f => Path.GetExtension(f.Path).Equals(".resx", StringComparison.OrdinalIgnoreCase))
+ .Where(f => !IsLocalizationCultureFile(f.Path))
+ .ToList();
+
+ List axamlFiles = compilationContext.Options.AdditionalFiles
+ .Where(f => Path.GetExtension(f.Path).Equals(".axaml", StringComparison.OrdinalIgnoreCase))
+ .ToList();
+
+ if (!resxFiles.Any()) return;
+
+ ConcurrentDictionary
+ trackedKeys = new();
+ ConcurrentHashSet normalizedTrackedKeys = new();
+ ConcurrentHashSet usedNormalizedKeys = new();
+
+ foreach (AdditionalText? file in resxFiles)
+ {
+ SourceText? sourceText = file.GetText(compilationContext.CancellationToken);
+ if (sourceText == null) continue;
+
+ try
+ {
+ XDocument doc = XDocument.Parse(sourceText.ToString(), LoadOptions.SetLineInfo);
+ IEnumerable? dataElements = doc.Root?.Elements("data");
+ if (dataElements == null) continue;
+
+ foreach (XElement element in dataElements)
+ {
+ string? keyName = element.Attribute("name")?.Value;
+ if (string.IsNullOrEmpty(keyName)) continue;
+
+ if (ShouldIgnoreKey(element)) continue;
+
+ IXmlLineInfo lineInfo = element;
+ int line = lineInfo.HasLineInfo() ? lineInfo.LineNumber : 1;
+
+ if (trackedKeys.TryAdd(keyName ?? string.Empty, (Path.GetFileName(file.Path), file, line)))
+ normalizedTrackedKeys.Add(NormalizeKey(keyName));
+ }
+ }
+ catch
+ {
+ // ignored
+ }
+ }
+
+ if (!trackedKeys.Any()) return;
+
+ foreach (AdditionalText axamlText in axamlFiles)
+ {
+ AnalyzeAxamlFile(axamlText, normalizedTrackedKeys, usedNormalizedKeys,
+ compilationContext.CancellationToken);
+ }
+
+ foreach (string exclusion in LibraryExclusions.ToEnumerable())
+ {
+ if (normalizedTrackedKeys.Contains(exclusion))
+ usedNormalizedKeys.Add(exclusion);
+ }
+
+ compilationContext.RegisterSemanticModelAction(semanticContext =>
+ {
+ AnalyzeLocalizerCalls(
+ semanticContext.SemanticModel,
+ compilation,
+ normalizedTrackedKeys,
+ usedNormalizedKeys,
+ semanticContext.CancellationToken);
+ });
+
+ compilationContext.RegisterCompilationEndAction(endContext =>
+ {
+ foreach (var keyPair in trackedKeys)
+ {
+ string originalKey = keyPair.Key;
+ string normalizedKey = NormalizeKey(originalKey);
+
+ if (usedNormalizedKeys.Contains(normalizedKey))
+ continue;
+
+ (string? fileName, AdditionalText? additionalText, int lineNumber) = keyPair.Value;
+ SourceText? sourceText = additionalText.GetText(endContext.CancellationToken);
+
+ Location location;
+ if (sourceText != null)
+ {
+ TextSpan? textSpan = FindKeySpan(sourceText, originalKey, lineNumber);
+ location = textSpan.HasValue
+ ? Location.Create(additionalText.Path, textSpan.Value,
+ sourceText.Lines.GetLinePositionSpan(textSpan.Value))
+ : Location.Create(additionalText.Path, new TextSpan(0, 0),
+ new LinePositionSpan(new LinePosition(Math.Max(0, lineNumber - 1), 0),
+ new LinePosition(Math.Max(0, lineNumber - 1), 0)));
+ }
+ else
+ {
+ location = Location.Create(additionalText.Path, new TextSpan(0, 0),
+ new LinePositionSpan(new LinePosition(Math.Max(0, lineNumber - 1), 0),
+ new LinePosition(Math.Max(0, lineNumber - 1), 0)));
+ }
+
+ Diagnostic diagnostic = Diagnostic.Create(Rule, location, originalKey, fileName);
+ endContext.ReportDiagnostic(diagnostic);
+ }
+ });
+ });
+ }
+
+ private static void AnalyzeLocalizerCalls(
+ SemanticModel model,
+ Compilation compilation,
+ ConcurrentHashSet normalizedTrackedKeys,
+ ConcurrentHashSet usedNormalizedKeys,
+ CancellationToken cancellationToken)
+ {
+ SyntaxNode root = model.SyntaxTree.GetRoot(cancellationToken);
+
+ foreach (InvocationExpressionSyntax invocation in root.DescendantNodes().OfType())
+ {
+ if (invocation.ArgumentList.Arguments.Count == 0) continue;
+
+ ISymbol? symbol = model.GetSymbolInfo(invocation, cancellationToken).Symbol
+ ?? model.GetSymbolInfo(invocation.Expression, cancellationToken).Symbol;
+
+ if (symbol is not IMethodSymbol methodSymbol || !IsLocalizationMethod(methodSymbol, out TargetMethodType methodType))
+ continue;
+
+ SeparatedSyntaxList args = invocation.ArgumentList.Arguments;
+ ProcessKeyExpression(args[0].Expression, args, methodType, model, compilation, normalizedTrackedKeys,
+ usedNormalizedKeys, cancellationToken);
+ }
+
+ foreach (ElementAccessExpressionSyntax elementAccess in root.DescendantNodes()
+ .OfType())
+ {
+ if (elementAccess.ArgumentList.Arguments.Count == 0) continue;
+
+ ISymbol? symbol = model.GetSymbolInfo(elementAccess, cancellationToken).Symbol;
+
+ if (symbol is not IPropertySymbol { IsIndexer: true } propertySymbol || propertySymbol.ContainingType?.Name != "Localizer")
+ continue;
+
+ SeparatedSyntaxList args = elementAccess.ArgumentList.Arguments;
+ ProcessKeyExpression(args[0].Expression, args, TargetMethodType.Indexer, model, compilation,
+ normalizedTrackedKeys, usedNormalizedKeys, cancellationToken);
+ }
+
+ foreach (var creation in root.DescendantNodes().OfType())
+ {
+ if (creation.ArgumentList == null || creation.ArgumentList.Arguments.Count == 0) continue;
+
+ SymbolInfo symbolInfo = model.GetSymbolInfo(creation, cancellationToken);
+ INamedTypeSymbol? targetType = null;
+
+ if (symbolInfo.Symbol is IMethodSymbol ctorSymbol)
+ targetType = ctorSymbol.ContainingType;
+ else if (creation is ImplicitObjectCreationExpressionSyntax)
+ targetType = model.GetTypeInfo(creation, cancellationToken).Type as INamedTypeSymbol;
+
+ if (targetType == null) continue;
+
+ for (int i = 0; i < creation.ArgumentList.Arguments.Count; i++)
+ {
+ if (!TryTraceConstructorParameterToProperty(targetType, i))
+ continue;
+
+ ExpressionSyntax argExpr = creation.ArgumentList.Arguments[i].Expression;
+ string? incKey = IsLocKeyMember(argExpr, model, cancellationToken, out string? enumName)
+ ? enumName
+ : TryGetStringArgument(model, argExpr);
+
+ if (!string.IsNullOrEmpty(incKey))
+ MarkAsUsed(incKey, normalizedTrackedKeys, usedNormalizedKeys);
+ }
+ }
+ }
+
+ private static void ProcessKeyExpression(
+ ExpressionSyntax keyExpression,
+ SeparatedSyntaxList originalArgs,
+ TargetMethodType methodType,
+ SemanticModel model,
+ Compilation compilation,
+ ConcurrentHashSet normalizedTrackedKeys,
+ ConcurrentHashSet usedNormalizedKeys,
+ CancellationToken cancellationToken)
+ {
+ if (IsLocKeyMember(keyExpression, model, cancellationToken, out string? directKey))
+ {
+ MarkAsUsed(directKey, normalizedTrackedKeys, usedNormalizedKeys);
+ return;
+ }
+
+ string? key = TryGetStringArgument(model, keyExpression);
+
+ if (string.IsNullOrEmpty(key) && keyExpression is IdentifierNameSyntax or MemberAccessExpressionSyntax)
+ {
+ ISymbol? symbol = model.GetSymbolInfo(keyExpression, cancellationToken).Symbol;
+ if (symbol is IPropertySymbol || symbol is IFieldSymbol)
+ {
+ INamedTypeSymbol? containingType = symbol.ContainingType;
+ if (containingType != null)
+ {
+ if (TryTracePropertyToConstructorParameter(symbol, containingType, out int paramIndex))
+ ProcessConstructorArguments(containingType, paramIndex, originalArgs, methodType, model,
+ normalizedTrackedKeys, usedNormalizedKeys, cancellationToken);
+
+ ProcessInheritedAndLocalProperties(symbol, containingType, originalArgs, methodType, model,
+ compilation, normalizedTrackedKeys, usedNormalizedKeys, cancellationToken);
+
+ return;
+ }
+ }
+ }
+
+ if (string.IsNullOrEmpty(key) && keyExpression is IdentifierNameSyntax identifier)
+ {
+ ISymbol? localSymbol = model.GetSymbolInfo(identifier, cancellationToken).Symbol;
+ if (localSymbol is IParameterSymbol { ContainingSymbol: IMethodSymbol containingMethod } parameterSymbol)
+ {
+ int paramIndex = parameterSymbol.Ordinal;
+
+ if (containingMethod.MethodKind == MethodKind.Constructor)
+ {
+ ProcessConstructorArguments(containingMethod.ContainingType, paramIndex, originalArgs, methodType,
+ model, normalizedTrackedKeys, usedNormalizedKeys, cancellationToken);
+ return;
+ }
+
+ ProcessMethodWrapperArguments(containingMethod, paramIndex, originalArgs, methodType, model,
+ compilation, normalizedTrackedKeys, usedNormalizedKeys, cancellationToken);
+
+ return;
+ }
+ }
+
+ if (string.IsNullOrEmpty(key))
+ return;
+
+ string? context = ExtractContext(originalArgs, methodType, model);
+ MarkAsUsed(context != null ? $"{context}/{key}" : key, normalizedTrackedKeys, usedNormalizedKeys);
+ }
+
+ private static void ProcessMethodWrapperArguments(
+ IMethodSymbol wrapperMethod,
+ int parameterIndex,
+ SeparatedSyntaxList originalArgs,
+ TargetMethodType methodType,
+ SemanticModel model,
+ Compilation compilation,
+ ConcurrentHashSet normalizedTrackedKeys,
+ ConcurrentHashSet usedNormalizedKeys,
+ CancellationToken cancellationToken)
+ {
+ SyntaxNode root = model.SyntaxTree.GetRoot(cancellationToken);
+
+ foreach (InvocationExpressionSyntax? invocation in root.DescendantNodes().OfType())
+ {
+ if (invocation.ArgumentList.Arguments.Count <= parameterIndex) continue;
+
+ ISymbol? symbol = model.GetSymbolInfo(invocation, cancellationToken).Symbol;
+
+ if (!SymbolEqualityComparer.Default.Equals(symbol, wrapperMethod))
+ continue;
+
+ ExpressionSyntax incomingArgExpr = invocation.ArgumentList.Arguments[parameterIndex].Expression;
+
+ ProcessKeyExpression(incomingArgExpr, originalArgs, methodType, model, compilation,
+ normalizedTrackedKeys, usedNormalizedKeys, cancellationToken);
+ }
+ }
+
+ private static void ProcessConstructorArguments(
+ INamedTypeSymbol targetClassType,
+ int parameterIndex,
+ SeparatedSyntaxList originalArgs,
+ TargetMethodType methodType,
+ SemanticModel model,
+ ConcurrentHashSet normalizedTrackedKeys,
+ ConcurrentHashSet usedNormalizedKeys,
+ CancellationToken cancellationToken)
+ {
+ string targetTypeName = targetClassType.Name;
+ SyntaxNode root = model.SyntaxTree.GetRoot(cancellationToken);
+
+ foreach (BaseObjectCreationExpressionSyntax? creation in root.DescendantNodes().OfType())
+ {
+ if (creation.ArgumentList == null || creation.ArgumentList.Arguments.Count <= parameterIndex) continue;
+
+ bool isTargetCtor = false;
+ SymbolInfo symbolInfo = model.GetSymbolInfo(creation, cancellationToken);
+
+ if (symbolInfo.Symbol is IMethodSymbol ctorSymbol)
+ isTargetCtor = SymbolEqualityComparer.Default.Equals(ctorSymbol.ContainingType, targetClassType);
+ else if (creation is ImplicitObjectCreationExpressionSyntax)
+ {
+ TypeInfo typeInfo = model.GetTypeInfo(creation, cancellationToken);
+ isTargetCtor = typeInfo.Type?.Name == targetTypeName;
+ }
+
+ if (!isTargetCtor)
+ continue;
+
+ ExpressionSyntax argExpr = creation.ArgumentList.Arguments[parameterIndex].Expression;
+ string? incKey = IsLocKeyMember(argExpr, model, cancellationToken, out string? enumName)
+ ? enumName
+ : TryGetStringArgument(model, argExpr);
+
+ if (string.IsNullOrEmpty(incKey))
+ continue;
+
+ string? context = ExtractContext(originalArgs, methodType, model);
+ MarkAsUsed(context != null ? $"{context}/{incKey}" : incKey, normalizedTrackedKeys,
+ usedNormalizedKeys);
+ }
+ }
+
+ private static void ProcessInheritedAndLocalProperties(
+ ISymbol baseMember,
+ INamedTypeSymbol baseType,
+ SeparatedSyntaxList originalArgs,
+ TargetMethodType methodType,
+ SemanticModel baseModel,
+ Compilation compilation,
+ ConcurrentHashSet normalizedTrackedKeys,
+ ConcurrentHashSet usedNormalizedKeys,
+ CancellationToken cancellationToken)
+ {
+ IEnumerable allTypes = GetAllTypes(compilation.GlobalNamespace)
+ .Where(t => SymbolEqualityComparer.Default.Equals(t, baseType) || IsSubclassOf(t, baseType));
+
+ foreach (INamedTypeSymbol? typeSymbol in allTypes)
+ {
+ ISymbol? member = typeSymbol.GetMembers().FirstOrDefault(m => m.Name == baseMember.Name);
+ if (member == null) continue;
+
+ foreach (SyntaxReference? reference in member.DeclaringSyntaxReferences)
+ {
+ SyntaxNode syntax = reference.GetSyntax(cancellationToken);
+ string? val = null;
+
+ switch (syntax)
+ {
+ case PropertyDeclarationSyntax { Initializer: not null } prop:
+ val = TryGetStringFromExpression(prop.Initializer.Value, baseModel,
+ cancellationToken);
+ break;
+ case PropertyDeclarationSyntax prop:
+ {
+ if (prop.ExpressionBody != null)
+ val = TryGetStringFromExpression(prop.ExpressionBody.Expression, baseModel,
+ cancellationToken);
+ break;
+ }
+ case VariableDeclaratorSyntax { Initializer: not null } field:
+ val = TryGetStringFromExpression(field.Initializer.Value, baseModel,
+ cancellationToken);
+ break;
+ }
+
+ if (string.IsNullOrEmpty(val))
+ continue;
+
+ string? context = ExtractContext(originalArgs, methodType, baseModel);
+ MarkAsUsed(context != null ? $"{context}/{val}" : val, normalizedTrackedKeys, usedNormalizedKeys);
+ }
+ }
+ }
+
+ private static bool TryTraceConstructorParameterToProperty(
+ INamedTypeSymbol containingType,
+ int parameterIndex)
+ {
+ foreach (IMethodSymbol? ctor in containingType.Constructors)
+ {
+ if (ctor.Parameters.Length <= parameterIndex) continue;
+ IParameterSymbol targetParam = ctor.Parameters[parameterIndex];
+
+ foreach (SyntaxNode syntax in ctor.DeclaringSyntaxReferences.Select(reference => reference.GetSyntax()))
+ {
+ if (syntax is not ConstructorDeclarationSyntax ctorDecl || ctorDecl.Body == null)
+ continue;
+
+ foreach (AssignmentExpressionSyntax? assignment in ctorDecl.Body.DescendantNodes().OfType())
+ {
+ if (assignment.Right is not IdentifierNameSyntax rightId || rightId.Identifier.ValueText != targetParam.Name)
+ continue;
+
+ string memberName = assignment.Left switch
+ {
+ IdentifierNameSyntax leftId => leftId.Identifier.ValueText,
+ MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax } ma => ma.Name
+ .Identifier.ValueText,
+ _ => string.Empty
+ };
+
+ if (string.IsNullOrEmpty(memberName))
+ continue;
+
+ containingType.GetMembers().FirstOrDefault(m => m.Name == memberName);
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static bool TryTracePropertyToConstructorParameter(
+ ISymbol propertyOrFieldSymbol,
+ INamedTypeSymbol containingType,
+ out int parameterIndex)
+ {
+ parameterIndex = -1;
+
+ foreach (IMethodSymbol? ctor in containingType.Constructors)
+ {
+ foreach (SyntaxNode syntax in ctor.DeclaringSyntaxReferences.Select(reference => reference.GetSyntax()))
+ {
+ if (syntax is not ConstructorDeclarationSyntax ctorDecl || ctorDecl.Body == null)
+ continue;
+
+ foreach (AssignmentExpressionSyntax? assignment in ctorDecl.Body.DescendantNodes().OfType())
+ {
+ if ((assignment.Left is not IdentifierNameSyntax leftId ||
+ leftId.Identifier.ValueText != propertyOrFieldSymbol.Name) &&
+ (assignment.Left is not MemberAccessExpressionSyntax { Expression: ThisExpressionSyntax } ma ||
+ ma.Name.Identifier.ValueText != propertyOrFieldSymbol.Name))
+ continue;
+
+ if (assignment.Right is not IdentifierNameSyntax rightId) continue;
+
+ IParameterSymbol? param = ctor.Parameters.FirstOrDefault(p => p.Name == rightId.Identifier.ValueText);
+
+ if (param == null) continue;
+
+ parameterIndex = param.Ordinal;
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ private static void MarkAsUsed(string? rawKey, ConcurrentHashSet normalizedTrackedKeys, ConcurrentHashSet usedNormalizedKeys)
+ {
+ if (string.IsNullOrEmpty(rawKey)) return;
+ string normalized = NormalizeKey(rawKey);
+
+ if (normalizedTrackedKeys.Contains(normalized))
+ usedNormalizedKeys.Add(normalized);
+ }
+
+ private static string? ExtractContext(SeparatedSyntaxList args, TargetMethodType methodType,
+ SemanticModel model)
+ {
+ return methodType switch
+ {
+ TargetMethodType.BindingCreate => args.Count > 1 ? TryGetStringArgument(model, args[1].Expression) : null,
+ TargetMethodType.Tr => args.Count > 1 ? TryGetStringArgument(model, args[1].Expression) : null,
+ TargetMethodType.TrDefault => args.Count > 2 ? TryGetStringArgument(model, args[2].Expression) : null,
+ _ => null
+ };
+ }
+
+ private static bool IsLocalizationMethod(IMethodSymbol method, out TargetMethodType methodType)
+ {
+ methodType = default;
+ INamedTypeSymbol? containingType = method.ContainingType;
+ if (containingType == null) return false;
+
+ switch (containingType.Name)
+ {
+ case "LocalizeBinding" when method.Name == "Create":
+ methodType = TargetMethodType.BindingCreate;
+ return true;
+ case "Localizer" when method.Name == "Tr":
+ methodType = TargetMethodType.Tr;
+ return true;
+ case "Localizer" when method.Name == "TrDefault":
+ methodType = TargetMethodType.TrDefault;
+ return true;
+ case "Localizer" when method.Name == "TryGetString":
+ methodType = TargetMethodType.TryGetString;
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static bool IsLocKeyMember(ExpressionSyntax expr, SemanticModel model, CancellationToken token,
+ out string? keyName)
+ {
+ keyName = null;
+
+ ISymbol? symbol = expr switch
+ {
+ MemberAccessExpressionSyntax memberAccess => model.GetSymbolInfo(memberAccess, token).Symbol,
+ IdentifierNameSyntax identifier => model.GetSymbolInfo(identifier, token).Symbol,
+ _ => null
+ };
+
+ if (symbol == null || (symbol is not IFieldSymbol && symbol is not IPropertySymbol))
+ return false;
+
+ if (symbol.ContainingType?.Name != "LocKey")
+ return false;
+
+ keyName = symbol.Name;
+ return true;
+
+ }
+
+ private static string? TryGetStringArgument(SemanticModel model, ExpressionSyntax expression)
+ {
+ if (expression is LiteralExpressionSyntax literal) return literal.Token.ValueText;
+ Optional