From bb49c488f35f0818013f156b08f4499ad0827fe9 Mon Sep 17 00:00:00 2001 From: linckez Date: Wed, 8 Apr 2026 22:00:47 +0200 Subject: [PATCH 1/2] Add localization extraction functionality with Roslyn and Resx adapters - Implemented ScannedCallSite and ScannedArgument records to capture call site details. - Created SourceDocument to manage syntax trees and original file paths. - Developed LocaleDiscovery for discovering available locales from translations. - Introduced ProjectScanResult and ProjectScanner for orchestrating project scans. - Established TranslationPipeline for merging scanner outputs and handling conflicts. - Defined domain models including DefinitionKind, MergedTranslation, and TranslationSourceText. - Added IScannerOutput interface and ScanDiagnostic for scanner diagnostics. - Created unit tests for extension method parameter name validation. - Added sample resource files for localization testing. --- BlazorLocalization.sln | 21 +- README.md | 43 +- docs/Extractor.md | 44 +- .../Adapters/Cli/Commands/ExtractCommand.cs | 252 +++++++ .../Adapters/Cli/Commands/ExtractRequest.cs | 67 ++ .../Adapters/Cli/Commands/ExtractSettings.cs | 35 + .../Adapters/Cli/Commands/InspectCommand.cs | 111 +++ .../Adapters/Cli/Commands/InspectRequest.cs | 25 + .../Adapters/Cli/Commands/InspectSettings.cs | 30 + .../Adapters/Cli/Commands/SharedSettings.cs | 29 + .../Adapters/Cli/InteractiveWizard.cs | 168 +++++ .../Adapters/Cli/ProjectDiscovery.cs | 82 +++ .../Cli/Rendering/ConflictRenderer.cs | 59 ++ .../Cli/Rendering/ExtractedCallRenderer.cs | 83 +++ .../Adapters/Cli/Rendering/JsonRenderer.cs | 208 ++++++ .../Cli/Rendering/PoLimitationRenderer.cs | 36 + .../Cli/Rendering/SourceFileMarkup.cs | 25 + .../Cli/Rendering/TranslationEntryRenderer.cs | 606 ++++++++++++++++ .../Adapters/Export/ExporterFactory.cs | 25 + .../Adapters/Export/GenericJsonExporter.cs | 128 ++++ .../Adapters/Export/I18NextJsonExporter.cs | 76 ++ .../Adapters/Export/ITranslationExporter.cs | 11 + .../Adapters/Export/PoExporter.cs | 136 ++++ .../Adapters/Export/PoLimitation.cs | 55 ++ .../Adapters/Resx/ResxFileParser.cs | 116 +++ .../Adapters/Resx/ResxScanner.cs | 91 +++ .../Adapters/Resx/ResxScannerOutput.cs | 15 + .../Adapters/Roslyn/CSharpFileProvider.cs | 28 + .../Adapters/Roslyn/CallInterpreter.cs | 648 +++++++++++++++++ .../Adapters/Roslyn/CallSiteBuilder.cs | 107 +++ .../Roslyn/EnumAttributeInterpreter.cs | 82 +++ .../Adapters/Roslyn/ExtensionsContract.cs | 163 +++++ .../Adapters/Roslyn/FluentChainWalker.cs | 63 ++ .../Adapters/Roslyn/LineMap.cs | 33 + .../Roslyn/LocalizerOperationWalker.cs | 60 ++ .../Adapters/Roslyn/OperationValue.cs | 160 +++++ .../Adapters/Roslyn/RazorSourceProvider.cs | 92 +++ .../Adapters/Roslyn/RoslynScanner.cs | 159 +++++ .../Adapters/Roslyn/RoslynScannerOutput.cs | 14 + .../Adapters/Roslyn/ScanTargets.cs | 23 + .../Adapters/Roslyn/ScannedCallSite.cs | 48 ++ .../Adapters/Roslyn/SourceDocument.cs | 29 + .../Application/LocaleDiscovery.cs | 47 ++ .../Application/ProjectScanResult.cs | 20 + .../Application/ProjectScanner.cs | 42 ++ .../Application/TranslationPipeline.cs | 131 ++++ .../BlazorLocalization.Extractor.csproj | 12 +- .../CONTRIBUTING.md | 260 ++++--- .../Cli/Commands/ExtractCommand.cs | 242 ------- .../Cli/Commands/ExtractSettings.cs | 35 - .../Cli/Commands/InspectCommand.cs | 93 --- .../Cli/Commands/InspectSettings.cs | 20 - .../Cli/Commands/SharedSettings.cs | 29 - .../Cli/InteractiveWizard.cs | 160 ----- .../Cli/Rendering/ConflictRenderer.cs | 64 -- .../Cli/Rendering/ExtractedCallRenderer.cs | 108 --- .../Cli/Rendering/JsonRenderer.cs | 156 ---- .../Cli/Rendering/PoLimitationRenderer.cs | 35 - .../Cli/Rendering/TranslationEntryRenderer.cs | 224 ------ .../Domain/Calls/CallKind.cs | 11 - .../Domain/Calls/ChainedMethodCall.cs | 9 - .../Domain/Calls/ExtractedCall.cs | 21 - .../Domain/Calls/ObjectCreation.cs | 9 - .../Domain/Calls/OverloadResolutionStatus.cs | 18 - .../Domain/Calls/ResolvedArgument.cs | 13 - .../Domain/Calls/SourceLocation.cs | 14 - .../Domain/ConflictStrategy.cs | 12 +- .../Domain/DefinitionKind.cs | 20 + .../Domain/Entries/LocaleDiscovery.cs | 44 -- .../Domain/Entries/MergedTranslationEntry.cs | 135 ---- .../Domain/Entries/SourceReference.cs | 15 - .../Domain/Entries/TranslationEntry.cs | 18 - .../Domain/ExportFormat.cs | 20 +- .../Domain/MergedTranslation.cs | 71 ++ .../Domain/PathStyle.cs | 13 +- .../Domain/PoLimitation.cs | 47 -- .../Domain/Requests/ExtractRequest.cs | 81 --- .../Domain/Requests/InspectRequest.cs | 29 - .../Domain/ScanResult.cs | 12 - .../Domain/Sites.cs | 7 + .../Domain/SourceFilePath.cs | 29 + .../Domain/TranslationDefinition.cs | 11 + .../Domain/TranslationForm.cs | 28 + .../Domain/TranslationReference.cs | 10 + .../{Entries => }/TranslationSourceText.cs | 2 +- .../Domain/TranslationStatus.cs | 16 + .../Exporters/ExporterFactory.cs | 34 - .../Exporters/GenericJsonExporter.cs | 115 --- .../Exporters/I18NextJsonExporter.cs | 81 --- .../Exporters/ITranslationExporter.cs | 12 - .../Exporters/PoExporter.cs | 134 ---- .../Ports/IScannerOutput.cs | 19 + .../Ports/ScanDiagnostic.cs | 18 + src/BlazorLocalization.Extractor/Program.cs | 34 +- .../Scanning/BuilderSymbolTable.cs | 665 ------------------ .../Scanning/Extractors/ChainInterpreter.cs | 544 -------------- .../Extractors/EnumAttributeExtractor.cs | 120 ---- .../Extractors/LocalizerCallExtractor.cs | 294 -------- .../Scanning/ProjectDiscovery.cs | 83 --- .../Scanning/ProjectScanner.cs | 49 -- .../Providers/CSharpFileSourceProvider.cs | 32 - .../Scanning/Providers/ISourceProvider.cs | 11 - .../Providers/RazorGeneratedSourceProvider.cs | 101 --- .../Scanning/ResxImporter.cs | 206 ------ .../Scanning/Scanner.cs | 103 --- .../Scanning/Sources/LineMap.cs | 28 - .../Scanning/Sources/SourceDocument.cs | 8 - .../Scanning/Sources/SourceOrigin.cs | 25 - .../CliSmokeTests.cs | 12 +- .../DomainHelperTests.cs | 86 ++- .../ExporterTests.GenericJson.verified.txt | 26 +- .../ExporterTests.cs | 26 +- .../ExtensionsContractTests.cs | 130 ++++ .../ExtractRequestTests.cs | 21 +- .../InspectRequestTests.cs | 21 +- .../MergeTests.cs | 58 +- .../ModuleInit.cs | 4 +- .../ProjectDiscoveryTests.cs | 2 +- ...tTests.I18NextJson_FullOutput.verified.txt | 49 +- ...ests.I18NextJson_PerLocale_Da.verified.txt | 32 +- ...ts.I18NextJson_PerLocale_EsMx.verified.txt | 11 +- ...eAppExportTests.Po_FullOutput.verified.txt | 235 ++++--- .../SampleAppExportTests.cs | 26 +- .../SampleAppFixture.cs | 30 +- .../SampleAppScannerTests.cs | 185 ++++- .../Components/Pages/Home.razor | 28 + .../SampleBlazorApp/Components/_Imports.razor | 1 + tests/SampleBlazorApp/Program.cs | 1 + .../{ => Components/Pages}/Home.da.resx | 12 + .../{ => Components/Pages}/Home.es-MX.resx | 12 + .../{ => Components/Pages}/Home.resx | 15 + .../Resources/SharedResource.da.resx | 27 + .../Resources/SharedResource.resx | 27 + tests/SampleBlazorApp/SharedResource.cs | 4 + 134 files changed, 5674 insertions(+), 4802 deletions(-) create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractCommand.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractRequest.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractSettings.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectCommand.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectRequest.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectSettings.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Commands/SharedSettings.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/InteractiveWizard.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/ProjectDiscovery.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ConflictRenderer.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ExtractedCallRenderer.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/JsonRenderer.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/PoLimitationRenderer.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/SourceFileMarkup.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/TranslationEntryRenderer.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Export/ExporterFactory.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Export/GenericJsonExporter.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Export/I18NextJsonExporter.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Export/ITranslationExporter.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Export/PoExporter.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Export/PoLimitation.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Resx/ResxFileParser.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Resx/ResxScanner.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Resx/ResxScannerOutput.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/CSharpFileProvider.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/CallInterpreter.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/ExtensionsContract.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/FluentChainWalker.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/LineMap.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/LocalizerOperationWalker.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/OperationValue.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/RazorSourceProvider.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/RoslynScanner.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/RoslynScannerOutput.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/ScanTargets.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/ScannedCallSite.cs create mode 100644 src/BlazorLocalization.Extractor/Adapters/Roslyn/SourceDocument.cs create mode 100644 src/BlazorLocalization.Extractor/Application/LocaleDiscovery.cs create mode 100644 src/BlazorLocalization.Extractor/Application/ProjectScanResult.cs create mode 100644 src/BlazorLocalization.Extractor/Application/ProjectScanner.cs create mode 100644 src/BlazorLocalization.Extractor/Application/TranslationPipeline.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Commands/ExtractCommand.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Commands/ExtractSettings.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Commands/InspectCommand.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Commands/InspectSettings.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Commands/SharedSettings.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/InteractiveWizard.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Rendering/ConflictRenderer.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Rendering/ExtractedCallRenderer.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Rendering/JsonRenderer.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Rendering/PoLimitationRenderer.cs delete mode 100644 src/BlazorLocalization.Extractor/Cli/Rendering/TranslationEntryRenderer.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Calls/CallKind.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Calls/ChainedMethodCall.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Calls/ExtractedCall.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Calls/ObjectCreation.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Calls/OverloadResolutionStatus.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Calls/ResolvedArgument.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Calls/SourceLocation.cs create mode 100644 src/BlazorLocalization.Extractor/Domain/DefinitionKind.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Entries/LocaleDiscovery.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Entries/MergedTranslationEntry.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Entries/SourceReference.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Entries/TranslationEntry.cs create mode 100644 src/BlazorLocalization.Extractor/Domain/MergedTranslation.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/PoLimitation.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Requests/ExtractRequest.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/Requests/InspectRequest.cs delete mode 100644 src/BlazorLocalization.Extractor/Domain/ScanResult.cs create mode 100644 src/BlazorLocalization.Extractor/Domain/Sites.cs create mode 100644 src/BlazorLocalization.Extractor/Domain/SourceFilePath.cs create mode 100644 src/BlazorLocalization.Extractor/Domain/TranslationDefinition.cs create mode 100644 src/BlazorLocalization.Extractor/Domain/TranslationForm.cs create mode 100644 src/BlazorLocalization.Extractor/Domain/TranslationReference.cs rename src/BlazorLocalization.Extractor/Domain/{Entries => }/TranslationSourceText.cs (96%) create mode 100644 src/BlazorLocalization.Extractor/Domain/TranslationStatus.cs delete mode 100644 src/BlazorLocalization.Extractor/Exporters/ExporterFactory.cs delete mode 100644 src/BlazorLocalization.Extractor/Exporters/GenericJsonExporter.cs delete mode 100644 src/BlazorLocalization.Extractor/Exporters/I18NextJsonExporter.cs delete mode 100644 src/BlazorLocalization.Extractor/Exporters/ITranslationExporter.cs delete mode 100644 src/BlazorLocalization.Extractor/Exporters/PoExporter.cs create mode 100644 src/BlazorLocalization.Extractor/Ports/IScannerOutput.cs create mode 100644 src/BlazorLocalization.Extractor/Ports/ScanDiagnostic.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/BuilderSymbolTable.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Extractors/ChainInterpreter.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Extractors/EnumAttributeExtractor.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Extractors/LocalizerCallExtractor.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/ProjectDiscovery.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/ProjectScanner.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Providers/CSharpFileSourceProvider.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Providers/ISourceProvider.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Providers/RazorGeneratedSourceProvider.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/ResxImporter.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Scanner.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Sources/LineMap.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Sources/SourceDocument.cs delete mode 100644 src/BlazorLocalization.Extractor/Scanning/Sources/SourceOrigin.cs create mode 100644 tests/BlazorLocalization.Extractor.Tests/ExtensionsContractTests.cs rename tests/SampleBlazorApp/Resources/{ => Components/Pages}/Home.da.resx (78%) rename tests/SampleBlazorApp/Resources/{ => Components/Pages}/Home.es-MX.resx (74%) rename tests/SampleBlazorApp/Resources/{ => Components/Pages}/Home.resx (67%) create mode 100644 tests/SampleBlazorApp/Resources/SharedResource.da.resx create mode 100644 tests/SampleBlazorApp/Resources/SharedResource.resx create mode 100644 tests/SampleBlazorApp/SharedResource.cs diff --git a/BlazorLocalization.sln b/BlazorLocalization.sln index 60ef09e..44d1e3a 100644 --- a/BlazorLocalization.sln +++ b/BlazorLocalization.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorLocalization.Extensio EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorLocalization.TranslationProvider.Crowdin.Tests", "tests\BlazorLocalization.TranslationProvider.Crowdin.Tests\BlazorLocalization.TranslationProvider.Crowdin.Tests.csproj", "{DAB8B6E9-1B23-40A8-9E68-E04ED1565417}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleBlazorApp", "tests\SampleBlazorApp\SampleBlazorApp.csproj", "{EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -101,16 +103,29 @@ Global {DAB8B6E9-1B23-40A8-9E68-E04ED1565417}.Release|x64.Build.0 = Release|Any CPU {DAB8B6E9-1B23-40A8-9E68-E04ED1565417}.Release|x86.ActiveCfg = Release|Any CPU {DAB8B6E9-1B23-40A8-9E68-E04ED1565417}.Release|x86.Build.0 = Release|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|x64.ActiveCfg = Debug|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|x64.Build.0 = Debug|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Debug|x86.Build.0 = Debug|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|Any CPU.Build.0 = Release|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|x64.ActiveCfg = Release|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|x64.Build.0 = Release|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|x86.ActiveCfg = Release|Any CPU + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {70DCB212-441A-4D50-82E8-026D52A368C8} = {0AB3BF05-4346-4AA6-1389-037BE0695223} - {8F4BE7F1-CC56-4383-8C1D-9F3CAA050B67} = {0AB3BF05-4346-4AA6-1389-037BE0695223} - {DAB8B6E9-1B23-40A8-9E68-E04ED1565417} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {3CE76414-FCC9-4F89-8DCD-283FEB0A57A7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {510A06EC-13E0-41F4-BF30-B5A27589FC78} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {6069D306-E1C1-40CF-8188-F11F19FAC9BA} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {70DCB212-441A-4D50-82E8-026D52A368C8} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {8F4BE7F1-CC56-4383-8C1D-9F3CAA050B67} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {DAB8B6E9-1B23-40A8-9E68-E04ED1565417} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {EB1814F7-5FA8-4827-A7E4-575FE44F8A2F} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/README.md b/README.md index f72d283..bf56390 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ See [Examples](docs/Examples.md) for plurals, ordinals, enum display names, and | Package | Version | Install | |---------|:-------:|--------:| | [**BlazorLocalization.Extensions**](https://www.nuget.org/packages/BlazorLocalization.Extensions)
Caches translations, supports plurals and inline translations, pluggable translation providers | [![NuGet](https://img.shields.io/nuget/v/BlazorLocalization.Extensions.svg)](https://www.nuget.org/packages/BlazorLocalization.Extensions) | `dotnet add package BlazorLocalization.Extensions` | -| [**BlazorLocalization.Extractor**](https://www.nuget.org/packages/BlazorLocalization.Extractor)
CLI tool (`blazor-loc`) β€” Roslyn-based scanner that extracts source strings from `.razor`, `.cs`, and `.resx` files | [![NuGet](https://img.shields.io/nuget/v/BlazorLocalization.Extractor.svg)](https://www.nuget.org/packages/BlazorLocalization.Extractor) | `dotnet tool install -g BlazorLocalization.Extractor` | +| [**BlazorLocalization.Extractor**](https://www.nuget.org/packages/BlazorLocalization.Extractor)
CLI tool (`blazor-loc`) β€” inspect translation health and extract source strings from `.razor`, `.cs`, and `.resx` files | [![NuGet](https://img.shields.io/nuget/v/BlazorLocalization.Extractor.svg)](https://www.nuget.org/packages/BlazorLocalization.Extractor) | `dotnet tool install -g BlazorLocalization.Extractor` | Translation providers: @@ -109,24 +109,43 @@ Built on [Microsoft's `IStringLocalizer`](https://learn.microsoft.com/en-us/aspn --- -## 🎬 String Extraction +## 🎬 CLI Tool β€” Inspect & Extract -Already using `IStringLocalizer`? The Extractor scans your `.razor`, `.cs`, and `.resx` files and exports every translation string β€” no matter which localization backend you use. - -![blazor-loc interactive wizard demo](https://raw.githubusercontent.com/linckez/BlazorLocalization/main/docs/assets/blazor-loc.svg) +A Roslyn-based CLI that understands both BlazorLocalization's `Translation()` API and Microsoft's built-in `IStringLocalizer["key"]` + `.resx` β€” no code changes or adoption required. Point it at your project and go. ```bash dotnet tool install -g BlazorLocalization.Extractor +``` -# Interactive wizard β€” run with no arguments -blazor-loc +### Inspect: Translation Health Audit + +```bash +blazor-loc inspect ./src +``` + +You get a table of every translation key, where it's used, its source text, which locales have it, and whether anything is wrong. Spot duplicate keys with conflicting values, `IStringLocalizer` calls the scanner couldn't resolve, and `.resx` entries that no code references anymore β€” across hundreds of files in seconds. + +### Extract: Keep Translations in Sync + +Without tooling, keeping your translation platform in sync with your code means manually copying strings β€” every key, every language, every time something changes. There's no built-in way to get translation strings out of a .NET project and into Crowdin, Lokalise, a database, or anywhere else. -# Or go direct +```bash blazor-loc extract ./src -f po -o ./translations ``` -Upload the generated files to Crowdin, Lokalise, or any translation management system. -See [Extractor CLI](docs/Extractor.md) for recipes, CI integration, and export formats. +One command scans your entire codebase and exports every source string to PO, i18next JSON, or generic JSON. Run it in CI on every merge and your translation platform always has the latest strings. + +### Interactive Wizard + +Run with no arguments for a guided walkthrough: + +![blazor-loc interactive wizard demo](https://raw.githubusercontent.com/linckez/BlazorLocalization/main/docs/assets/blazor-loc.svg) + +```bash +blazor-loc +``` + +See [Extractor CLI](docs/Extractor.md) for all recipes, CI integration, and export formats. --- @@ -141,7 +160,7 @@ See [Extractor CLI](docs/Extractor.md) for recipes, CI integration, and export f | Named placeholders | βœ— | βœ— | βœ“ β€” via SmartFormat | | External provider support | βœ— | βœ— | βœ“ β€” pluggable | | Merge-conflict-free | βœ— β€” XML | βœ— β€” PO files | βœ“ β€” with OTA providers. File-based providers are opt-in | -| Automated string extraction | Manual | Manual | Roslyn-based CLI | +| Translation audit + extraction | Manual | Manual | Roslyn-based CLI β€” inspect and export | | Reusable definitions | βœ— | βœ— | βœ“ β€” define once, use anywhere | | Standard `IStringLocalizer` | βœ“ | βœ“ | βœ“ | | Battle-tested | βœ“ β€” 20+ years | βœ“ | Production use, actively maintained | @@ -155,7 +174,7 @@ See [Extractor CLI](docs/Extractor.md) for recipes, CI integration, and export f | Topic | Description | |-------|-------------| | [Examples](docs/Examples.md) | `Translation()` usage β€” simple, placeholders, plurals, ordinals, select, inline translations, reusable definitions | -| [Extractor CLI](docs/Extractor.md) | Install, interactive wizard, common recipes, CI integration, export formats | +| [Extractor CLI](docs/Extractor.md) | Install, inspect & extract commands, interactive wizard, CI integration, export formats | | [Configuration](docs/Configuration.md) | Cache settings, `appsettings.json` binding, multiple providers, code-only config | | [Crowdin Provider](docs/Providers/Crowdin.md) | Over-the-air translations from Crowdin β€” distribution hash, export formats, error handling | | [JSON File Provider](docs/Providers/JsonFile.md) | Load translations from flat JSON files on disk | diff --git a/docs/Extractor.md b/docs/Extractor.md index 864dd89..54619bc 100644 --- a/docs/Extractor.md +++ b/docs/Extractor.md @@ -2,9 +2,10 @@ # Extractor CLI -`blazor-loc` scans your `.razor`, `.cs`, and `.resx` files and exports source strings to translation files. Upload the output to Crowdin, Lokalise, or any translation management system. +`blazor-loc` is a Roslyn-based CLI that understands both BlazorLocalization's `Translation()` API and Microsoft's built-in `IStringLocalizer["key"]` + `.resx`. No code changes or adoption required β€” point it at any `IStringLocalizer` project and go. -It works with any `IStringLocalizer` codebase β€” regardless of which localization backend you use. +- **`inspect`** β€” Translation health audit. See every translation key, where it's used, what's missing, what conflicts, and how complete each locale is. +- **`extract`** β€” Scan your codebase and export every source string. Run it in CI on every merge to keep your translation platform in sync. ## Install @@ -28,7 +29,9 @@ Run with no arguments to launch the interactive wizard. It walks you through pro blazor-loc ``` -## Common Recipes +## Extract + +Without tooling, keeping translations in sync means manually copying strings between your code and your translation platform β€” every key, every language, every time something changes. `extract` scans your entire codebase and exports every source string in one command. Run it locally or in CI on every merge. Extract to Crowdin i18next JSON (the default format): @@ -84,19 +87,46 @@ Narrow to specific locales: blazor-loc extract ./src -f i18next -o ./translations -l da -l es-MX ``` -Debug what the scanner detects (raw calls + merged entries): +## Inspect + +Point `inspect` at your project and get a translation health audit β€” the full picture across every file, locale, and pattern in seconds. ```bash blazor-loc inspect ./src ``` -Output as JSON (pipeable to other tools): +### What you see + +**Translation entries** β€” one row per unique key, showing where it's used in your code, its source text, which form it takes (simple, plural, select...), and which locales have a translation. Spot a key that's missing `de` when every other row has it. + +**Conflicts** β€” same key used with different source texts in different places. Almost always a bug. The table shows exactly which files disagree and what each one says. + +**Extraction warnings** β€” the handful of calls the scanner couldn't confidently resolve. Things like `Loc[someVar ? Loc["..."] : Loc["..."]]` or mangled expressions that somehow made it through code review. By default, only these problem cases surface β€” not the hundreds of healthy calls. + +**Locale coverage** β€” per-language summary: how many keys each locale has, what percentage that covers, and any keys that only exist in one locale but not the source. At a glance you see that `es-MX` is at 97.6% but `vi` is at 85.7%. + +**Cross-reference summary** β€” one line bridging code and data: how many keys resolved, how many are missing, how many `.resx` entries have no matching code reference. + +### Options + +See full key/value tables per language (instead of the default summary): + +```bash +blazor-loc inspect ./src --show-resx-locales +``` + +See every line of code where a translation was found (including all healthy ones): ```bash -blazor-loc inspect ./src --json +blazor-loc inspect ./src --show-extracted-calls ``` -`inspect` dumps every detected `IStringLocalizer` call with its key, source text, plural forms, and file location β€” useful for verifying the scanner found what you expected. +Output as JSON (auto-enabled when stdout is piped): + +```bash +blazor-loc inspect ./src --json +blazor-loc inspect ./src | jq '.translationEntries[] | select(.status == "Missing")' +``` ## Export Formats diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractCommand.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractCommand.cs new file mode 100644 index 0000000..2ea6a29 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractCommand.cs @@ -0,0 +1,252 @@ +using BlazorLocalization.Extractor.Adapters.Cli.Rendering; +using BlazorLocalization.Extractor.Adapters.Export; +using BlazorLocalization.Extractor.Application; +using BlazorLocalization.Extractor.Domain; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Commands; + +/// +/// Extracts translation entries from Blazor projects and exports them in the specified format. +/// +internal sealed class ExtractCommand : Command +{ + protected override int Execute(CommandContext context, ExtractSettings settings, CancellationToken cancellationToken) + { + // 1. Resolve paths + var (projectDirs, resolveErrors) = ProjectDiscovery.ResolveAll(settings.Paths); + foreach (var err in resolveErrors) + Console.Error.WriteLine(err); + if (resolveErrors.Count > 0) + return 1; + + // 2. Build request + var localeFilter = settings.Locales is { Length: > 0 } + ? new HashSet(settings.Locales, StringComparer.OrdinalIgnoreCase) + : null; + + var request = new ExtractRequest( + ProjectDirs: projectDirs, + Format: settings.Format, + Output: OutputTarget.FromRawOutput(settings.Output), + LocaleFilter: localeFilter, + SourceOnly: settings.SourceOnly, + PathStyle: settings.PathStyle, + Verbose: settings.Verbose, + ExitOnDuplicateKey: settings.ExitOnDuplicateKey, + OnDuplicateKey: settings.OnDuplicateKey); + + // 3. Validate + var errors = request.Validate(); + if (errors.Count > 0) + { + foreach (var error in errors) + Console.Error.WriteLine(error); + return 1; + } + + // 4. Execute + return request.Output switch + { + OutputTarget.StdoutTarget => ExecuteStdout(request, cancellationToken), + OutputTarget.FileTarget f => ExecuteFile(request, f.Path, cancellationToken), + OutputTarget.DirTarget d => ExecuteDir(request, d.Path, cancellationToken), + _ => 1 + }; + } + + private static int ExecuteStdout(ExtractRequest request, CancellationToken cancellationToken) + { + var projectDir = request.ProjectDirs[0]; + var scan = ProjectScanner.Scan(projectDir, cancellationToken); + var entries = ApplyConflictStrategy(scan.MergeResult, request); + entries = FilterUnresolvableReferences(entries); + + WarnMissingLocales(entries, request); + + if (request.LocaleFilter is { Count: 1 }) + { + var locale = request.LocaleFilter.First(); + entries = LocaleDiscovery.EntriesForLocale(entries, locale); + } + + var exporter = ExporterFactory.Create(request.Format); + Console.Write(exporter.Export(entries, request.PathStyle)); + + foreach (var conflict in scan.MergeResult.Conflicts) + Console.Error.WriteLine($"Warning: duplicate key '{conflict.Key}' with {conflict.Values.Count} different values"); + + foreach (var invalid in scan.MergeResult.InvalidEntries) + Console.Error.WriteLine($"Warning: invalid translation skipped β€” {invalid.Reason} at {string.Join(", ", invalid.Sites.Select(s => s.File.FileName))}"); + + return request.ExitOnDuplicateKey && scan.MergeResult.Conflicts.Count > 0 ? 1 : 0; + } + + private static int ExecuteFile(ExtractRequest request, string filePath, CancellationToken cancellationToken) + { + var projectDir = request.ProjectDirs[0]; + var scan = ProjectScanner.Scan(projectDir, cancellationToken); + var entries = ApplyConflictStrategy(scan.MergeResult, request); + entries = FilterUnresolvableReferences(entries); + + var dir = Path.GetDirectoryName(filePath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + var exporter = ExporterFactory.Create(request.Format); + System.IO.File.WriteAllText(filePath, exporter.Export(entries, request.PathStyle)); + + if (request.Verbose) + AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]"); + + ReportConflicts(scan.MergeResult.Conflicts, scan.ProjectName); + ReportInvalidEntries(scan.MergeResult.InvalidEntries, scan.ProjectName); + return request.ExitOnDuplicateKey && scan.MergeResult.Conflicts.Count > 0 ? 1 : 0; + } + + private static int ExecuteDir(ExtractRequest request, string outputDir, CancellationToken cancellationToken) + { + Directory.CreateDirectory(outputDir); + var exporter = ExporterFactory.Create(request.Format); + var ext = ExporterFactory.GetFileExtension(request.Format); + var hasConflicts = false; + var summaryRows = new List<(string Project, int Entries, int Conflicts)>(); + + foreach (var projectDir in request.ProjectDirs) + { + ProjectScanResult scan; + + if (AnsiConsole.Profile.Capabilities.Interactive) + { + scan = null!; + AnsiConsole.Status() + .Spinner(Spinner.Known.Dots) + .Start($"Scanning [blue]{Markup.Escape(Path.GetFileName(projectDir))}[/]...", _ => + { + scan = ProjectScanner.Scan(projectDir, cancellationToken); + }); + } + else + { + scan = ProjectScanner.Scan(projectDir, cancellationToken); + } + + var entries = ApplyConflictStrategy(scan.MergeResult, request); + entries = FilterUnresolvableReferences(entries); + if (scan.MergeResult.Conflicts.Count > 0) hasConflicts = true; + + if (request.Verbose) + AnsiConsole.MarkupLine($"[dim]{Markup.Escape(scan.ProjectName)}: {entries.Count} translation(s)[/]"); + + var filePath = Path.Combine(outputDir, $"{scan.ProjectName}{ext}"); + System.IO.File.WriteAllText(filePath, exporter.Export(entries, request.PathStyle)); + if (request.Verbose) + AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]"); + + if (!request.SourceOnly) + ExportPerLocaleFiles(entries, request, outputDir, scan.ProjectName, exporter, ext); + + summaryRows.Add((scan.ProjectName, entries.Count, scan.MergeResult.Conflicts.Count)); + ReportConflicts(scan.MergeResult.Conflicts, scan.ProjectName); + ReportInvalidEntries(scan.MergeResult.InvalidEntries, scan.ProjectName); + + if (request.Format is ExportFormat.Po) + PoLimitationRenderer.Render(PoLimitation.Detect(entries), scan.ProjectName); + } + + if (summaryRows.Count > 0) + { + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule("[blue]Summary[/]").LeftJustified()); + AnsiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .AddColumn("Project") + .AddColumn("Entries") + .AddColumn("Conflicts"); + + foreach (var (project, entries, conflicts) in summaryRows) + { + var conflictMarkup = conflicts > 0 ? $"[yellow]{conflicts}[/]" : $"[green]{conflicts}[/]"; + table.AddRow(Markup.Escape(project), entries.ToString(), conflictMarkup); + } + + AnsiConsole.Write(table); + } + + return hasConflicts && request.ExitOnDuplicateKey ? 1 : 0; + } + + private static IReadOnlyList ApplyConflictStrategy( + MergeResult mergeResult, ExtractRequest request) + { + var entries = mergeResult.Entries; + if (mergeResult.Conflicts.Count > 0 && request.OnDuplicateKey is ConflictStrategy.Skip) + { + var conflictKeys = mergeResult.Conflicts.Select(c => c.Key).ToHashSet(); + entries = entries.Where(e => !conflictKeys.Contains(e.Key)).ToList(); + } + return entries; + } + + private static IReadOnlyList FilterUnresolvableReferences( + IReadOnlyList entries) + { + return entries.Where(e => e.IsKeyLiteral || e.SourceText is not null).ToList(); + } + + private static void ExportPerLocaleFiles( + IReadOnlyList entries, + ExtractRequest request, + string outputDir, + string projectName, + ITranslationExporter exporter, + string ext) + { + var locales = LocaleDiscovery.DiscoverLocales(entries, request.LocaleFilter); + + if (request.LocaleFilter is not null) + WarnMissingLocales(entries, request); + + foreach (var locale in locales) + { + var localeEntries = LocaleDiscovery.EntriesForLocale(entries, locale); + var filePath = Path.Combine(outputDir, $"{projectName}.{locale}{ext}"); + System.IO.File.WriteAllText(filePath, exporter.Export(localeEntries, request.PathStyle)); + if (request.Verbose) + AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]"); + } + } + + private static void WarnMissingLocales(IReadOnlyList entries, ExtractRequest request) + { + if (request.LocaleFilter is null) return; + + var discovered = LocaleDiscovery.DiscoverLocales(entries); + foreach (var requested in request.LocaleFilter.Where(r => + !discovered.Contains(r, StringComparer.OrdinalIgnoreCase))) + { + Console.Error.WriteLine($"Warning: locale '{requested}' not found in any translation"); + } + } + + private static void ReportConflicts(IReadOnlyList conflicts, string projectName) + { + ConflictRenderer.Render(conflicts, projectName); + } + + private static void ReportInvalidEntries(IReadOnlyList invalidEntries, string projectName) + { + if (invalidEntries.Count == 0) return; + + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine($"[red]βœ— {invalidEntries.Count} invalid translation(s) skipped in {Markup.Escape(projectName)}[/]"); + foreach (var entry in invalidEntries) + { + var locations = string.Join(", ", entry.Sites.Select(s => s.File.FileName)); + AnsiConsole.MarkupLine($" [red]{Markup.Escape(entry.Reason)}[/] at [dim]{Markup.Escape(locations)}[/]"); + } + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractRequest.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractRequest.cs new file mode 100644 index 0000000..20b659d --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractRequest.cs @@ -0,0 +1,67 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Commands; + +/// +/// Validated request for the extract command. +/// Maps CLI settings into domain types with validation. +/// +public sealed record ExtractRequest( + IReadOnlyList ProjectDirs, + ExportFormat Format, + OutputTarget Output, + HashSet? LocaleFilter, + bool SourceOnly, + PathStyle PathStyle, + bool Verbose, + bool ExitOnDuplicateKey, + ConflictStrategy OnDuplicateKey) +{ + public IReadOnlyList Validate() + { + var errors = new List(); + + if (ProjectDirs.Count == 0) + errors.Add("No projects to scan."); + + if (SourceOnly && LocaleFilter is { Count: > 0 }) + errors.Add("--source-only and --locale cannot be used together."); + + if (Output is OutputTarget.StdoutTarget) + { + if (ProjectDirs.Count > 1) + errors.Add("Stdout output supports only a single project. Use -o for multiple projects."); + } + + if (Output is OutputTarget.FileTarget) + { + if (ProjectDirs.Count > 1) + errors.Add("File output supports only a single project. Use -o for multiple projects."); + } + + return errors; + } +} + +/// +/// Where extract output goes. Classified from the raw -o value. +/// +public abstract record OutputTarget +{ + public sealed record StdoutTarget : OutputTarget; + public sealed record FileTarget(string Path) : OutputTarget; + public sealed record DirTarget(string Path) : OutputTarget; + + /// + /// Classifies the raw -o value: null β†’ stdout, has extension β†’ file, else β†’ directory. + /// + public static OutputTarget FromRawOutput(string? raw) + { + if (raw is null) + return new StdoutTarget(); + + return Path.HasExtension(raw) + ? new FileTarget(raw) + : new DirTarget(raw); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractSettings.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractSettings.cs new file mode 100644 index 0000000..e65c1eb --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/ExtractSettings.cs @@ -0,0 +1,35 @@ +using System.ComponentModel; +using BlazorLocalization.Extractor.Domain; +using Spectre.Console.Cli; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Commands; + +/// +/// CLI settings for the extract command. +/// +public sealed class ExtractSettings : SharedSettings +{ + [Description("Export format: i18next (default, Crowdin i18next JSON), po (GNU Gettext with source refs), json (full-fidelity debug)")] + [CommandOption("-f|--format")] + [DefaultValue(ExportFormat.I18Next)] + public ExportFormat Format { get; init; } = ExportFormat.I18Next; + + [Description("Output path: a file (e.g. out.po) or directory (e.g. ./translations). Auto-detected via extension. Omit to write to stdout")] + [CommandOption("-o|--output")] + public string? Output { get; init; } + + [Description("Print per-project scanning progress to stderr")] + [CommandOption("--verbose")] + [DefaultValue(false)] + public bool Verbose { get; init; } + + [Description("Exit with error code 1 when duplicate translation keys are found (useful for CI pipelines)")] + [CommandOption("--exit-on-duplicate-key")] + [DefaultValue(false)] + public bool ExitOnDuplicateKey { get; init; } + + [Description("Strategy for duplicate translation keys (same key, different source text). 'first' (default) keeps the first-seen source text. 'skip' omits the key entirely")] + [CommandOption("--on-duplicate-key")] + [DefaultValue(ConflictStrategy.First)] + public ConflictStrategy OnDuplicateKey { get; init; } = ConflictStrategy.First; +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectCommand.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectCommand.cs new file mode 100644 index 0000000..0a69ea1 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectCommand.cs @@ -0,0 +1,111 @@ +using BlazorLocalization.Extractor.Adapters.Cli.Rendering; +using BlazorLocalization.Extractor.Adapters.Roslyn; +using BlazorLocalization.Extractor.Application; +using Spectre.Console.Cli; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Commands; + +/// +/// Translation health audit: shows code references, .resx entries, cross-references, and locale coverage. +/// +internal sealed class InspectCommand : Command +{ + protected override int Execute(CommandContext context, InspectSettings settings, CancellationToken cancellationToken) + { + // 1. Resolve paths + var (projectDirs, resolveErrors) = ProjectDiscovery.ResolveAll(settings.Paths); + foreach (var err in resolveErrors) + Console.Error.WriteLine(err); + if (resolveErrors.Count > 0) + return 1; + + // 2. Build request + var localeFilter = settings.Locales is { Length: > 0 } + ? new HashSet(settings.Locales, StringComparer.OrdinalIgnoreCase) + : null; + + var request = new InspectRequest( + ProjectDirs: projectDirs, + JsonOutput: settings.ShouldOutputJson, + LocaleFilter: localeFilter, + ShowResxLocales: settings.ShowResxLocales, + ShowExtractedCalls: settings.ShowExtractedCalls, + PathStyle: settings.PathStyle); + + // 3. Validate + var errors = request.Validate(); + if (errors.Count > 0) + { + foreach (var error in errors) + Console.Error.WriteLine(error); + return 1; + } + + // 4. Execute + foreach (var projectDir in request.ProjectDirs) + { + var scan = ProjectScanner.Scan(projectDir, cancellationToken); + + if (request.LocaleFilter is not null) + { + var discovered = LocaleDiscovery.DiscoverLocales(scan.MergeResult.Entries); + foreach (var requested in request.LocaleFilter.Where(r => + !discovered.Contains(r, StringComparer.OrdinalIgnoreCase))) + { + Console.Error.WriteLine($"Warning: locale '{requested}' not found in any translation"); + } + } + + if (request.JsonOutput) + { + JsonRenderer.RenderInspect( + scan.ProjectName, + scan.MergeResult.Entries, + scan.MergeResult.Conflicts, + scan.MergeResult.InvalidEntries, + request.LocaleFilter, + request.PathStyle); + } + else + { + // 1. Extracted calls (opt-in) β€” downcast to adapter type + if (request.ShowExtractedCalls) + { + var roslynOutput = scan.ScannerOutputs.OfType().FirstOrDefault(); + if (roslynOutput is not null) + ExtractedCallRenderer.Render(roslynOutput.RawCalls, scan.ProjectName, request.PathStyle); + } + + // 2. Translation Entries + TranslationEntryRenderer.Render(scan.MergeResult.Entries, scan.ProjectName, request.PathStyle); + + // 3. Conflicts + TranslationEntryRenderer.RenderConflicts(scan.MergeResult.Conflicts, request.PathStyle); + + // 4. Invalid entries + TranslationEntryRenderer.RenderInvalidEntries(scan.MergeResult.InvalidEntries, request.PathStyle); + + // 5. .resx Files + if (request.ShowResxLocales) + TranslationEntryRenderer.RenderResxLocales(scan.MergeResult.Entries, request.LocaleFilter); + else + TranslationEntryRenderer.RenderResxOverview(scan.MergeResult.Entries, request.LocaleFilter); + + // 6. Anomalies + TranslationEntryRenderer.RenderAnomalies(scan.MergeResult.Entries, request.LocaleFilter); + + // 7. Cross-reference summary + TranslationEntryRenderer.RenderCrossReferenceSummary(scan.MergeResult.Entries, scan.ProjectName); + + // 8. Legend + var hasResxFiles = scan.MergeResult.Entries.Any(e => + e.Definitions.Any(d => d.File.IsResx)); + TranslationEntryRenderer.RenderLegend( + scan.MergeResult.Entries, hasResxFiles, request.ShowExtractedCalls, + scan.MergeResult.InvalidEntries.Count > 0); + } + } + + return 0; + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectRequest.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectRequest.cs new file mode 100644 index 0000000..da46680 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectRequest.cs @@ -0,0 +1,25 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Commands; + +/// +/// Validated request for the inspect command. +/// +public sealed record InspectRequest( + IReadOnlyList ProjectDirs, + bool JsonOutput, + HashSet? LocaleFilter, + bool ShowResxLocales, + bool ShowExtractedCalls, + PathStyle PathStyle) +{ + public IReadOnlyList Validate() + { + var errors = new List(); + + if (ProjectDirs.Count == 0) + errors.Add("No projects to scan."); + + return errors; + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectSettings.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectSettings.cs new file mode 100644 index 0000000..c253620 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/InspectSettings.cs @@ -0,0 +1,30 @@ +using System.ComponentModel; +using Spectre.Console.Cli; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Commands; + +/// +/// CLI settings for the inspect command. +/// +public sealed class InspectSettings : SharedSettings +{ + [Description("Output raw JSON to stdout instead of rendered tables. Auto-enabled when stdout is piped")] + [CommandOption("--json")] + [DefaultValue(false)] + public bool Json { get; init; } + + [Description("Show full .resx tables for each language (default: summary only)")] + [CommandOption("--show-resx-locales")] + [DefaultValue(false)] + public bool ShowResxLocales { get; init; } + + [Description("Show every line of code where a translation was found (default: warnings only)")] + [CommandOption("--show-extracted-calls")] + [DefaultValue(false)] + public bool ShowExtractedCalls { get; init; } + + /// + /// Whether output should be JSON β€” explicitly via --json or auto-detected when stdout is piped. + /// + public bool ShouldOutputJson => Json || Console.IsOutputRedirected; +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/SharedSettings.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/SharedSettings.cs new file mode 100644 index 0000000..bc3f1ce --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Commands/SharedSettings.cs @@ -0,0 +1,29 @@ +using System.ComponentModel; +using BlazorLocalization.Extractor.Domain; +using Spectre.Console.Cli; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Commands; + +/// +/// Shared CLI settings for path discovery and output options. +/// +public class SharedSettings : CommandSettings +{ + [Description("Directories or .csproj files to scan. Repeatable. Defaults to current directory")] + [CommandArgument(0, "[paths]")] + public string[] Paths { get; init; } = ["."]; + + [Description("Path style for source references. 'relative' (default) emits paths relative to the project root. 'absolute' preserves full filesystem paths")] + [CommandOption("--path-style")] + [DefaultValue(PathStyle.Relative)] + public PathStyle PathStyle { get; init; } = PathStyle.Relative; + + [Description("Filter to specific locales. Repeatable (-l da -l es-MX). Omit to include all discovered locales")] + [CommandOption("-l|--locale")] + public string[]? Locales { get; init; } + + [Description("Export source-language strings only; skip per-locale translations")] + [CommandOption("--source-only")] + [DefaultValue(false)] + public bool SourceOnly { get; init; } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/InteractiveWizard.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/InteractiveWizard.cs new file mode 100644 index 0000000..157899d --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/InteractiveWizard.cs @@ -0,0 +1,168 @@ +using System.ComponentModel; +using System.Globalization; +using System.Reflection; +using BlazorLocalization.Extractor.Adapters.Export; +using BlazorLocalization.Extractor.Domain; +using Spectre.Console; + +namespace BlazorLocalization.Extractor.Adapters.Cli; + +/// +/// Interactive wizard that gathers CLI arguments via Spectre.Console prompts +/// when the tool is invoked with no arguments. +/// +internal static class InteractiveWizard +{ + public static string[] Run() + { + WriteBanner(); + AnsiConsole.WriteLine(); + + var command = PromptWithDescriptions("What would you like to do?", new Dictionary + { + ["extract"] = "Scan your projects and export source strings to translation files", + ["inspect"] = "Check your translation setup β€” find missing, conflicting, or unused keys" + }); + + var path = AnsiConsole.Prompt( + new TextPrompt("Project/solution [green]root path[/]:") + .DefaultValue(".")); + + var discovered = ProjectDiscovery.Discover(path); + if (discovered.Count == 0) + { + AnsiConsole.MarkupLine("[red]No projects found at that path.[/]"); + return [command, path]; + } + + var selectedPaths = SelectProjects(discovered, path); + + var args = new List { command }; + foreach (var proj in selectedPaths) + args.Add(proj); + + if (command == "extract") + AddExtractOptions(args); + else if (command == "inspect") + AddInspectOptions(args); + + AnsiConsole.WriteLine(); + return args.ToArray(); + } + + private static void WriteBanner() + { + var blazorPurple = new Color(81, 43, 212); + var lightPurple = new Color(140, 100, 240); + + AnsiConsole.Write(new FigletText("Blazor") { Color = blazorPurple, Justification = Justify.Center }); + AnsiConsole.Write(new FigletText("Localization") { Color = lightPurple, Justification = Justify.Center }); + var version = typeof(InteractiveWizard).Assembly + .GetCustomAttribute() + ?.InformationalVersion.Split('+')[0] ?? "unknown"; + AnsiConsole.Write(new Text($"v{version}", new Style(blazorPurple, decoration: Decoration.Dim)) { Justification = Justify.Center }); + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Text("Scan your projects for translation strings β€” extract, inspect, and export.", new Style(Color.Default, decoration: Decoration.Dim)) { Justification = Justify.Center }); + AnsiConsole.WriteLine(); + } + + private static IReadOnlyList SelectProjects(IReadOnlyList discovered, string root) + { + if (discovered.Count == 1) + { + AnsiConsole.MarkupLine($"Found project: [green]{Path.GetFileName(discovered[0])}[/]"); + return discovered; + } + + var rootFull = Path.GetFullPath(root); + var displayToPath = discovered.ToDictionary( + d => Path.GetRelativePath(rootFull, d), + d => d); + + var sortedKeys = displayToPath.Keys + .OrderBy(k => k, StringComparer.Create(CultureInfo.InvariantCulture, CompareOptions.NumericOrdering)) + .ToList(); + + var prompt = new MultiSelectionPrompt() + .Title("Select [green]projects[/] to scan:") + .PageSize(15) + .InstructionsText("[dim](Press [blue][/] to toggle, [green][/] to accept)[/]") + .AddChoices(sortedKeys); + + foreach (var name in displayToPath.Keys) + prompt.Select(name); + + var selected = AnsiConsole.Prompt(prompt); + return selected.Select(name => displayToPath[name]).ToList(); + } + + private static void AddExtractOptions(List args) + { + var format = PromptEnum("Output [green]format[/]:"); + if (format is ExportFormat.Po) + AnsiConsole.MarkupLine("[yellow]β„Ή PO format has limitations: it cannot represent exact value matches (e.g. 'exactly 0' or 'exactly 42') or ordinal forms (1st, 2nd, 3rd). Affected entries will be flagged below.[/]"); + args.Add("-f"); + args.Add(format.ToString()); + + var outputDir = AnsiConsole.Prompt( + new TextPrompt("Output [green]directory[/]:") + .DefaultValue("./output")); + args.Add("-o"); + args.Add(outputDir); + + var ext = ExporterFactory.GetFileExtension(format); + if (!AnsiConsole.Confirm($"Include per-locale translations? (e.g. MyApp.da[green]{ext}[/], MyApp.es-MX[green]{ext}[/])", true)) + args.Add("--source-only"); + + var onDuplicateKey = PromptEnum("Duplicate translation key [green]strategy[/]:"); + if (onDuplicateKey is not ConflictStrategy.First) + { + args.Add("--on-duplicate-key"); + args.Add(onDuplicateKey.ToString()); + } + + if (AnsiConsole.Confirm("Verbose output?", false)) + args.Add("--verbose"); + } + + private static void AddInspectOptions(List args) + { + var detail = PromptWithDescriptions("How much [green]detail[/]?", new Dictionary + { + ["Standard"] = "Translation health check β€” problems highlighted (recommended)", + ["Everything"] = "Every .resx entry per language and every code location found" + }); + + if (detail == "Everything") + { + args.Add("--show-resx-locales"); + args.Add("--show-extracted-calls"); + } + } + + private static T PromptEnum(string title) where T : struct, Enum + { + var values = Enum.GetValues(); + var prompt = new SelectionPrompt() + .Title(title) + .UseConverter(v => + { + var desc = typeof(T).GetField(v.ToString())? + .GetCustomAttribute()?.Description; + return desc is not null ? $"{v} [dim]β€” {desc}[/]" : v.ToString(); + }) + .AddChoices(values); + + return AnsiConsole.Prompt(prompt); + } + + private static string PromptWithDescriptions(string title, Dictionary choices) + { + var prompt = new SelectionPrompt() + .Title(title) + .UseConverter(key => choices.TryGetValue(key, out var desc) ? $"{key} [dim]β€” {desc}[/]" : key) + .AddChoices(choices.Keys); + + return AnsiConsole.Prompt(prompt); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/ProjectDiscovery.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/ProjectDiscovery.cs new file mode 100644 index 0000000..43cf626 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/ProjectDiscovery.cs @@ -0,0 +1,82 @@ +namespace BlazorLocalization.Extractor.Adapters.Cli; + +/// +/// Discovers .csproj project directories by scanning a root path recursively. +/// Skips common build-output and dependency directories. +/// +internal static class ProjectDiscovery +{ + private static readonly HashSet SkipDirs = new(StringComparer.OrdinalIgnoreCase) + { + "obj", "bin", "node_modules", ".git", ".vs", ".idea" + }; + + /// + /// Resolves one or more input paths (directories or .csproj files) into + /// deduplicated project directories. Returns errors for paths that don't exist. + /// + public static (IReadOnlyList ProjectDirs, IReadOnlyList Errors) ResolveAll(string[] paths) + { + var dirs = new List(); + var errors = new List(); + + foreach (var raw in paths) + { + var expanded = ExpandPath(raw); + + if (expanded.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + if (!System.IO.File.Exists(expanded)) + { + errors.Add($"File not found: {raw}"); + continue; + } + dirs.Add(Path.GetDirectoryName(Path.GetFullPath(expanded))!); + } + else if (Directory.Exists(expanded)) + { + var found = Discover(expanded); + if (found.Count == 0) + errors.Add($"No .csproj projects found in: {raw}"); + else + dirs.AddRange(found); + } + else + { + errors.Add($"Path not found: {raw}"); + } + } + + var deduped = dirs.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + return (deduped, errors); + } + + /// + /// Returns distinct project root directories under . + /// Each returned path is the directory containing a .csproj file. + /// + public static IReadOnlyList Discover(string root) + { + var fullRoot = Path.GetFullPath(ExpandPath(root)); + if (!Directory.Exists(fullRoot)) + return []; + + return Directory.EnumerateFiles(fullRoot, "*.csproj", SearchOption.AllDirectories) + .Where(path => !ShouldSkip(path)) + .Select(path => Path.GetDirectoryName(path)!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + internal static string ExpandPath(string path) => + path.StartsWith('~') + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[1..].TrimStart('/')) + : Environment.ExpandEnvironmentVariables(path); + + private static bool ShouldSkip(string path) + { + var sep = Path.DirectorySeparatorChar; + return SkipDirs.Any(dir => + path.Contains($"{sep}{dir}{sep}", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ConflictRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ConflictRenderer.cs new file mode 100644 index 0000000..168c9f3 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ConflictRenderer.cs @@ -0,0 +1,59 @@ +using BlazorLocalization.Extractor.Domain; +using Spectre.Console; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering; + +/// +/// Renders results as a Spectre.Console panel containing a table. +/// +public static class ConflictRenderer +{ + public static void Render(IReadOnlyList conflicts, string projectName, PathStyle pathStyle = PathStyle.Relative) + { + if (conflicts.Count == 0) + return; + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Default) + .AddColumn("Key") + .AddColumn("Value") + .AddColumn("Locations"); + + foreach (var conflict in conflicts) + { + for (var i = 0; i < conflict.Values.Count; i++) + { + var value = conflict.Values[i]; + var locations = string.Join(", ", value.Sites.Select(s => + s.DisplayMarkup(pathStyle))); + + table.AddRow( + i == 0 ? $"[bold]{Markup.Escape(conflict.Key)}[/]" : "", + $"[dim]\"{Markup.Escape(FormatSourceText(value.SourceText))}\"[/]", + locations); + } + + table.AddEmptyRow(); + } + + var panel = new Panel(table) + .Header($"[yellow]⚠ {conflicts.Count} conflicting key(s) in {Markup.Escape(projectName)}[/]") + .BorderColor(Color.Yellow) + .Padding(1, 0); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + } + + private static string FormatSourceText(TranslationSourceText text) => text switch + { + SingularText s => s.Value, + PluralText p => string.Join(" | ", + new[] { ("zero", p.Zero), ("one", p.One), ("two", p.Two), ("few", p.Few), ("many", p.Many), ("other", (string?)p.Other) } + .Where(t => t.Item2 is not null) + .Select(t => $"{t.Item1}: {t.Item2}")), + SelectText s => string.Join(" | ", s.Cases.Select(c => $"{c.Key}: {c.Value}")), + _ => "(unknown)" + }; +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ExtractedCallRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ExtractedCallRenderer.cs new file mode 100644 index 0000000..aec1c90 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/ExtractedCallRenderer.cs @@ -0,0 +1,83 @@ +using BlazorLocalization.Extractor.Adapters.Roslyn; +using BlazorLocalization.Extractor.Domain; +using Spectre.Console; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering; + +/// +/// Renders results as a Spectre.Console table. +/// Opt-in section of the inspect command (--show-extracted-calls). +/// +internal static class ExtractedCallRenderer +{ + public static void Render(IReadOnlyList calls, string? projectName = null, PathStyle pathStyle = PathStyle.Relative) + { + var header = projectName is not null + ? $"[blue]{Markup.Escape(projectName)}[/] [dim]β€” Extracted Calls ({calls.Count})[/]" + : $"[blue]Extracted Calls[/] [dim]({calls.Count})[/]"; + AnsiConsole.Write(new Rule(header).LeftJustified()); + AnsiConsole.WriteLine(); + + if (calls.Count == 0) + { + AnsiConsole.MarkupLine("[dim]No IStringLocalizer usage detected.[/]"); + return; + } + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Default) + .AddColumn(new TableColumn("#").RightAligned()) + .AddColumn("Type.Method") + .AddColumn("Kind") + .AddColumn("Location") + .AddColumn("Arguments"); + + for (var i = 0; i < calls.Count; i++) + { + var call = calls[i]; + var method = $"{Markup.Escape(call.ContainingTypeName)}.{Markup.Escape(call.MethodName)}"; + var kind = call.CallKind.ToString(); + + var location = call.File.DisplayMarkup(pathStyle, call.Line); + + var argsText = FormatArguments(call.Arguments); + + if (call.Chain is { Count: > 0 }) + argsText += "\n" + FormatChain(call.Chain); + + table.AddRow( + $"[dim]{i + 1}[/]", + $"[bold]{method}[/]", + $"[dim]{Markup.Escape(kind)}[/]", + location, + argsText); + } + + AnsiConsole.Write(table); + } + + private static string FormatArguments(IReadOnlyList args) + { + var parts = new List(); + foreach (var arg in args) + { + var name = arg.ParameterName ?? $"arg{arg.Position}"; + var value = arg.Value.Display(40); + parts.Add($"[dim]{Markup.Escape(name)}[/]=[bold]{Markup.Escape(value)}[/]"); + } + return string.Join("\n", parts); + } + + private static string FormatChain(IReadOnlyList chain) + { + var lines = new List(); + foreach (var link in chain) + { + var argCount = link.Arguments.Count; + var argText = argCount > 0 ? $"{argCount} arg(s)" : ""; + lines.Add($"[dim] .{Markup.Escape(link.MethodName)}({Markup.Escape(argText)})[/]"); + } + return string.Join("\n", lines); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/JsonRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/JsonRenderer.cs new file mode 100644 index 0000000..9cc8f2a --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/JsonRenderer.cs @@ -0,0 +1,208 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Unicode; +using BlazorLocalization.Extractor.Application; +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering; + +/// +/// Renders scan results as JSON to stdout for piped/machine-readable output. +/// +internal static class JsonRenderer +{ + private static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) + }; + + public static void RenderInspect( + string projectName, + IReadOnlyList entries, + IReadOnlyList conflicts, + IReadOnlyList invalidEntries, + HashSet? localeFilter, + PathStyle pathStyle = PathStyle.Relative) + { + var resxEntries = entries + .Where(e => e.Definitions.Any(d => d.File.IsResx)) + .ToList(); + var allLocales = LocaleDiscovery.DiscoverLocales(resxEntries, localeFilter); + var sourceKeys = resxEntries.Select(e => e.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + + Console.WriteLine(JsonSerializer.Serialize(new + { + project = projectName, + + // 1. Translation Entries + translationEntries = entries.Select(e => new + { + key = e.IsKeyLiteral ? e.Key : null, + isDynamic = !e.IsKeyLiteral ? true : (bool?)null, + usage = e.References + .Select(r => new { file = r.File.Display(pathStyle), line = r.Line }) + .ToList() is { Count: > 0 } refs ? refs : null, + form = TranslationFormExtensions.From(e.SourceText)?.ToString(), + sourceText = MapSourceText(e.SourceText), + source = GetSource(e, pathStyle), + status = e.Status.ToString(), + locales = e.InlineTranslations is { Count: > 0 } + ? e.InlineTranslations.Keys.Order(StringComparer.OrdinalIgnoreCase).ToList() + : null + }), + + // 2. Conflicts + conflicts = conflicts.Count > 0 ? conflicts.Select(MapConflict) : null, + + // 3. .resx Files + resxFiles = resxEntries.Count > 0 ? new + { + sourceEntries = resxEntries.Count, + localeCount = allLocales.Count + 1, + unreferenced = resxEntries.Count(e => e.Status == TranslationStatus.Review), + locales = allLocales.Select(locale => + { + var count = resxEntries.Count(e => e.InlineTranslations?.ContainsKey(locale) == true); + var missing = resxEntries.Count - count; + var unique = resxEntries + .Where(e => e.InlineTranslations?.ContainsKey(locale) == true) + .Count(e => !sourceKeys.Contains(e.Key)); + return new + { + locale, + entries = count, + coverage = resxEntries.Count > 0 ? Math.Round((double)count / resxEntries.Count * 100, 1) : 0, + missing = missing > 0 ? missing : (int?)null, + unique = unique > 0 ? unique : (int?)null + }; + }) + } : null, + + // 4. Invalid entries + invalidEntries = invalidEntries.Count > 0 ? invalidEntries.Select(e => new + { + key = e.Key, + reason = e.Reason, + locations = e.Sites.Select(s => new { file = s.File.Display(pathStyle), line = s.Line }) + }) : null, + + // 5. Cross-reference + crossReference = new + { + resolved = entries.Count(e => e.Status == TranslationStatus.Resolved), + review = entries.Count(e => e.Status == TranslationStatus.Review), + missing = entries.Count(e => e.Status == TranslationStatus.Missing), + entries = entries.Select(e => new + { + key = e.Key, + status = e.Status.ToString() + }) + } + }, Options)); + } + + public static void RenderExtract( + string projectName, + IReadOnlyList entries, + IReadOnlyList conflicts) + { + Console.WriteLine(JsonSerializer.Serialize(new + { + project = projectName, + entries = entries.Select(MapEntry), + conflicts = conflicts.Select(MapConflict) + }, Options)); + } + + private static object MapEntry(MergedTranslation entry) => new + { + key = entry.Key, + sourceText = MapSourceText(entry.SourceText), + isKeyLiteral = entry.IsKeyLiteral, + inlineTranslations = entry.InlineTranslations?.ToDictionary( + kvp => kvp.Key, + kvp => MapSourceText(kvp.Value)), + definitions = entry.Definitions.Select(d => new + { + filePath = d.File.Display(PathStyle.Relative), + line = d.Line, + projectName = d.File.ProjectName + }), + references = entry.References.Select(r => new + { + filePath = r.File.Display(PathStyle.Relative), + line = r.Line, + projectName = r.File.ProjectName + }) + }; + + private static object? MapSourceText(TranslationSourceText? text) => text switch + { + SingularText s => new { type = "singular", value = s.Value }, + PluralText p => new + { + type = "plural", + other = p.Other, + zero = p.Zero, + one = p.One, + two = p.Two, + few = p.Few, + many = p.Many, + exactMatches = p.ExactMatches?.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value), + isOrdinal = p.IsOrdinal ? true : (bool?)null + }, + SelectText s => new + { + type = "select", + cases = s.Cases, + otherwise = s.Otherwise + }, + SelectPluralText sp => new + { + type = "selectPlural", + cases = sp.Cases.ToDictionary(kvp => kvp.Key, kvp => MapSourceText(kvp.Value)), + otherwise = sp.Otherwise is not null ? MapSourceText(sp.Otherwise) : null + }, + _ => null + }; + + private static object MapConflict(KeyConflict conflict) => new + { + key = conflict.Key, + values = conflict.Values.Select(v => new + { + sourceText = MapSourceText(v.SourceText), + sites = v.Sites.Select(s => new + { + filePath = s.File.Display(PathStyle.Relative), + line = s.Line + }) + }) + }; + + private static object? GetSource(MergedTranslation entry, PathStyle pathStyle) + { + if (entry.Definitions.Count == 0) return null; + + var def = entry.Definitions[0]; + var kind = def.Kind switch + { + DefinitionKind.InlineTranslation => ".Translation()", + DefinitionKind.ReusableDefinition => def.Context?.Split('.').LastOrDefault() is { } name ? $"{name}()" : "Definition()", + DefinitionKind.EnumAttribute => "[Translation]", + DefinitionKind.ResourceFile => def.File.FileName, + _ => null + }; + + return new + { + kind, + file = def.File.Display(pathStyle), + line = def.Line + }; + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/PoLimitationRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/PoLimitationRenderer.cs new file mode 100644 index 0000000..4384b40 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/PoLimitationRenderer.cs @@ -0,0 +1,36 @@ +using BlazorLocalization.Extractor.Adapters.Export; +using Spectre.Console; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering; + +/// +/// Renders PO format limitations as a Spectre.Console panel containing a table. +/// +public static class PoLimitationRenderer +{ + public static void Render(IReadOnlyList limitations, string projectName) + { + if (limitations.Count == 0) + return; + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Default) + .AddColumn("Key") + .AddColumn("Limitation"); + + foreach (var limitation in limitations) + table.AddRow( + $"[bold]{Markup.Escape(limitation.Key)}[/]", + $"[dim]{Markup.Escape(limitation.Limitation)}[/]"); + + var panel = new Panel(table) + .Header($"[yellow]⚠ {limitations.Count} PO format limitation(s) in {Markup.Escape(projectName)}[/]") + .BorderColor(Color.Yellow) + .Padding(1, 0); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(panel); + AnsiConsole.MarkupLine("[dim] πŸ’‘ Consider i18next JSON or generic JSON for full plural fidelity.[/]"); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/SourceFileMarkup.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/SourceFileMarkup.cs new file mode 100644 index 0000000..39d69e9 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/SourceFileMarkup.cs @@ -0,0 +1,25 @@ +using BlazorLocalization.Extractor.Domain; +using Spectre.Console; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering; + +/// +/// Spectre.Console markup helpers for source file paths. +/// Generates OSC 8 clickable file:/// links for terminals that support them. +/// +internal static class SourceFileMarkup +{ + public static string DisplayMarkup(this SourceFilePath file, PathStyle style, int? line = null) + { + var display = file.Display(style); + var displayWithLine = line is not null ? $"{display}:{line}" : display; + var uri = $"file:///{file.AbsolutePath.TrimStart('/')}"; + return $"[link={Markup.Escape(uri)}][cyan]{Markup.Escape(displayWithLine)}[/][/]"; + } + + public static string DisplayMarkup(this DefinitionSite site, PathStyle style) => + site.File.DisplayMarkup(style, site.Line); + + public static string DisplayMarkup(this ReferenceSite site, PathStyle style) => + site.File.DisplayMarkup(style, site.Line); +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/TranslationEntryRenderer.cs b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/TranslationEntryRenderer.cs new file mode 100644 index 0000000..ff26db9 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Cli/Rendering/TranslationEntryRenderer.cs @@ -0,0 +1,606 @@ +using System.Globalization; +using BlazorLocalization.Extractor.Application; +using BlazorLocalization.Extractor.Domain; +using Spectre.Console; + +namespace BlazorLocalization.Extractor.Adapters.Cli.Rendering; + +/// +/// Renders inspect output: translation entries, .resx overview, anomalies, warnings, and legend. +/// +public static class TranslationEntryRenderer +{ + // ── Translation Entries table ── + + public static void Render( + IReadOnlyList entries, + string? projectName = null, + PathStyle pathStyle = PathStyle.Relative) + { + AnsiConsole.WriteLine(); + var header = projectName is not null + ? $"[blue]{Markup.Escape(projectName)}[/] [dim]β€” Translation Entries ({entries.Count})[/]" + : $"[blue]Translation Entries[/] [dim]({entries.Count})[/]"; + AnsiConsole.Write(new Rule(header).LeftJustified()); + AnsiConsole.WriteLine(); + + if (entries.Count == 0) + { + AnsiConsole.MarkupLine("[dim]No translation entries found.[/]"); + return; + } + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Default) + .ShowRowSeparators() + .AddColumn("Key") + .AddColumn("Usage") + .AddColumn("Form") + .AddColumn("Source Text") + .AddColumn("Source") + .AddColumn("Status") + .AddColumn("Locales"); + + foreach (var entry in entries) + { + var key = entry.IsKeyLiteral + ? $"[bold]{Markup.Escape(entry.Key)}[/]" + : $"[dim]β‰ˆ {Markup.Escape(entry.Key)}[/]"; + + var usage = FormatUsage(entry, pathStyle); + var form = FormatForm(entry.SourceText); + var sourceText = FormatSourceText(entry.SourceText); + var source = FormatSource(entry, pathStyle); + var status = FormatStatus(entry); + var locales = FormatLocales(entry); + + table.AddRow(key, usage, form, sourceText, source, status, locales); + } + + AnsiConsole.Write(table); + } + + // ── Conflicts ── + + public static void RenderConflicts(IReadOnlyList conflicts, PathStyle pathStyle = PathStyle.Relative) + { + if (conflicts.Count == 0) return; + + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule($"[yellow]⚠ Conflicts ({conflicts.Count})[/]").LeftJustified()); + AnsiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Yellow) + .AddColumn("Key") + .AddColumn("Source Text") + .AddColumn("Locations"); + + foreach (var conflict in conflicts) + { + for (var i = 0; i < conflict.Values.Count; i++) + { + var value = conflict.Values[i]; + var locations = string.Join(", ", value.Sites.Select(s => + s.DisplayMarkup(pathStyle))); + + table.AddRow( + i == 0 ? $"[bold]{Markup.Escape(conflict.Key)}[/]" : "", + $"[dim]\"{Markup.Escape(FormatSourceTextBrief(value.SourceText))}\"[/]", + locations); + } + + table.AddEmptyRow(); + } + + AnsiConsole.Write(table); + } + + // ── Invalid Entries ── + + public static void RenderInvalidEntries(IReadOnlyList invalidEntries, PathStyle pathStyle = PathStyle.Relative) + { + if (invalidEntries.Count == 0) return; + + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule($"[red]βœ— Invalid Entries ({invalidEntries.Count})[/]").LeftJustified()); + AnsiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Red) + .AddColumn("Location") + .AddColumn("Reason"); + + foreach (var entry in invalidEntries) + { + var locations = string.Join(", ", entry.Sites.Select(s => + s.DisplayMarkup(pathStyle))); + table.AddRow(locations, $"[red]{Markup.Escape(entry.Reason)}[/]"); + } + + AnsiConsole.Write(table); + } + + // ── .resx Overview ── + + public static void RenderResxOverview( + IReadOnlyList entries, + HashSet? localeFilter) + { + var resxEntries = entries + .Where(e => e.Definitions.Any(d => d.File.IsResx)) + .ToList(); + + if (resxEntries.Count == 0) return; + + var allLocales = LocaleDiscovery.DiscoverLocales(resxEntries, localeFilter); + var sourceKeyCount = resxEntries.Count; + var unreferencedCount = resxEntries.Count(e => e.Status == TranslationStatus.Review); + + AnsiConsole.WriteLine(); + var localeCount = allLocales.Count + 1; // +1 for source + AnsiConsole.Write(new Rule($"[blue].resx Files:[/] [dim]{sourceKeyCount} entries across {localeCount} locales[/]").LeftJustified()); + AnsiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Default) + .AddColumn("Locale") + .AddColumn(new TableColumn("Entries").RightAligned()) + .AddColumn("Coverage") + .AddColumn(new TableColumn("Unique").RightAligned()) + .AddColumn(new TableColumn("Unreferenced").RightAligned()) + .AddColumn("Status"); + + // Source locale row + table.AddRow( + "[bold](source)[/]", + sourceKeyCount.ToString(), + "[dim]β€”[/]", + "[dim]β€”[/]", + unreferencedCount > 0 ? $"[yellow]{unreferencedCount}[/]" : "[dim]β€”[/]", + "[dim]Source locale[/]"); + + // Per-locale rows + foreach (var locale in allLocales) + { + var count = resxEntries.Count(e => e.InlineTranslations?.ContainsKey(locale) == true); + var coverage = sourceKeyCount > 0 ? (double)count / sourceKeyCount * 100 : 0; + var missing = sourceKeyCount - count; + + var coverageStr = $"{coverage:F1}%"; + var coverageColor = coverage >= 100 ? "green" : coverage >= 90 ? "yellow" : "red"; + + var statusParts = new List(); + if (missing > 0) statusParts.Add($"{missing} missing"); + var status = statusParts.Count > 0 ? string.Join(", ", statusParts) : "OK"; + var statusColor = status == "OK" ? "green" : "yellow"; + + table.AddRow( + $"[bold]{Markup.Escape(GetLocaleDisplayName(locale))}[/]", + count.ToString(), + $"[{coverageColor}]{coverageStr}[/]", + "[dim]0[/]", + "[dim]β€”[/]", + $"[{statusColor}]{Markup.Escape(status)}[/]"); + } + + AnsiConsole.Write(table); + } + + // ── Anomalies ── + + public static void RenderAnomalies( + IReadOnlyList entries, + HashSet? localeFilter) + { + // Find resx-sourced entries where a locale exists but source key might be orphaned + var resxEntries = entries + .Where(e => e.Definitions.Any(d => d.File.IsResx)) + .ToList(); + + if (resxEntries.Count == 0) return; + + var sourceKeys = resxEntries.Select(e => e.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + var allLocales = LocaleDiscovery.DiscoverLocales(resxEntries, localeFilter); + + var anomalies = new List<(string Locale, string Key, string File)>(); + foreach (var locale in allLocales) + { + foreach (var entry in resxEntries) + { + if (entry.InlineTranslations?.ContainsKey(locale) != true) continue; + if (sourceKeys.Contains(entry.Key)) continue; + + var file = entry.Definitions + .FirstOrDefault(d => d.File.IsResx) + ?.File.FileName ?? "β€”"; + anomalies.Add((locale, entry.Key, file)); + } + } + + if (anomalies.Count == 0) return; + + var localeCount = anomalies.Select(a => a.Locale).Distinct(StringComparer.OrdinalIgnoreCase).Count(); + + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule($"[yellow]⚠ Anomalies ({anomalies.Count} unique keys across {localeCount} locales)[/]").LeftJustified()); + AnsiConsole.WriteLine(); + + var table = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Yellow) + .AddColumn("Locale") + .AddColumn("Key") + .AddColumn("File"); + + foreach (var (locale, key, file) in anomalies) + table.AddRow( + $"[bold]{Markup.Escape(locale)}[/]", + $"[bold]{Markup.Escape(key)}[/]", + $"[cyan]{Markup.Escape(file)}[/]"); + + AnsiConsole.Write(table); + } + + // ── Per-locale RESX tables (--show-resx-locales) ── + + public static void RenderResxLocales( + IReadOnlyList entries, + HashSet? localeFilter) + { + var resxEntries = entries + .Where(e => e.Definitions.Any(d => d.File.IsResx)) + .ToList(); + + if (resxEntries.Count == 0) return; + + var unreferencedCount = resxEntries.Count(e => e.Status == TranslationStatus.Review); + + // Source locale table + AnsiConsole.WriteLine(); + var sourceHeader = $"[blue].resx Files (source locale)[/] [dim]β€” {resxEntries.Count} entries"; + if (unreferencedCount > 0) sourceHeader += $", {unreferencedCount} unreferenced"; + sourceHeader += "[/]"; + AnsiConsole.Write(new Rule(sourceHeader).LeftJustified()); + AnsiConsole.WriteLine(); + + var sourceTable = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Default) + .AddColumn("Key") + .AddColumn("Value") + .AddColumn("File") + .AddColumn("Status"); + + foreach (var entry in resxEntries) + { + var value = FormatSourceTextBrief(entry.SourceText); + var file = entry.Definitions + .FirstOrDefault(d => d.File.IsResx) + ?.File.FileName ?? "β€”"; + + var status = entry.Status == TranslationStatus.Review + ? "[yellow]Review[/]" + : "[green]Resolved[/]"; + + sourceTable.AddRow( + $"[bold]{Markup.Escape(entry.Key)}[/]", + $"[dim]{Markup.Escape(value)}[/]", + $"[cyan]{Markup.Escape(file)}[/]", + status); + } + + AnsiConsole.Write(sourceTable); + + // Per non-source locale tables + var allLocales = LocaleDiscovery.DiscoverLocales(resxEntries, localeFilter); + var sourceKeys = resxEntries.Select(e => e.Key).ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var locale in allLocales) + { + var localeEntries = resxEntries + .Where(e => e.InlineTranslations?.ContainsKey(locale) == true) + .ToList(); + + if (localeEntries.Count == 0) continue; + + var missing = resxEntries.Count - localeEntries.Count; + var uniqueCount = localeEntries.Count(e => !sourceKeys.Contains(e.Key)); + + var displayName = GetLocaleDisplayName(locale); + var localeHeader = $"[blue].resx Files ({Markup.Escape(displayName)})[/] [dim]β€” {localeEntries.Count} entries"; + if (missing > 0) localeHeader += $", {missing} missing"; + if (uniqueCount > 0) localeHeader += $", {uniqueCount} unique"; + localeHeader += "[/]"; + + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule(localeHeader).LeftJustified()); + AnsiConsole.WriteLine(); + + var localeTable = new Table() + .Border(TableBorder.Rounded) + .BorderColor(Color.Default) + .AddColumn("Key") + .AddColumn("Value") + .AddColumn("File") + .AddColumn("Status"); + + foreach (var entry in localeEntries) + { + var localeText = entry.InlineTranslations![locale]; + var value = FormatSourceTextBrief(localeText); + var file = entry.Definitions + .FirstOrDefault(d => d.File.IsResx) + ?.File.FileName ?? "β€”"; + + var isUnique = !sourceKeys.Contains(entry.Key); + var status = isUnique ? "[yellow]Unique[/]" : "[green]Resolved[/]"; + + localeTable.AddRow( + $"[bold]{Markup.Escape(entry.Key)}[/]", + $"[dim]{Markup.Escape(value)}[/]", + $"[cyan]{Markup.Escape(file)}[/]", + status); + } + + AnsiConsole.Write(localeTable); + } + } + + // ── Cross-Reference Summary ── + + public static void RenderCrossReferenceSummary(IReadOnlyList entries, string? projectName = null) + { + AnsiConsole.WriteLine(); + + var resolved = entries.Count(e => e.Status == TranslationStatus.Resolved); + var review = entries.Count(e => e.Status == TranslationStatus.Review); + var missing = entries.Count(e => e.Status == TranslationStatus.Missing); + + var parts = new List { $"[green]{resolved} resolved[/]" }; + if (review > 0) parts.Add($"[yellow]{review} review[/]"); + if (missing > 0) parts.Add($"[red]{missing} missing[/]"); + + AnsiConsole.MarkupLine($" Cross-reference: {string.Join(", ", parts)}"); + } + + // ── Legend ── + + public static void RenderLegend( + IReadOnlyList entries, + bool hasResxFiles, + bool showedExtractedCalls, + bool hasInvalidEntries = false) + { + AnsiConsole.WriteLine(); + AnsiConsole.Write(new Rule("[dim]Legend[/]").LeftJustified()); + AnsiConsole.WriteLine(); + + // Form β€” only show forms that actually appear + var forms = entries.Select(e => TranslationFormExtensions.From(e.SourceText)).ToHashSet(); + AnsiConsole.MarkupLine(" [dim]Form[/]"); + if (forms.Contains(TranslationForm.Simple)) + AnsiConsole.MarkupLine(" [bold]Simple[/] Single message"); + if (forms.Contains(TranslationForm.Plural)) + AnsiConsole.MarkupLine(" [bold]Plural[/] Branching by count (.One/.Other/...)"); + if (forms.Contains(TranslationForm.Ordinal)) + AnsiConsole.MarkupLine(" [bold]Ordinal[/] Branching by ordinal rank (1st, 2nd, ...)"); + if (forms.Contains(TranslationForm.Select)) + AnsiConsole.MarkupLine(" [bold]Select[/] Branching by category (.When/.Otherwise)"); + if (forms.Contains(TranslationForm.SelectPlural)) + AnsiConsole.MarkupLine(" [bold]Select+Plural[/] Combined category + count branching"); + + // Status β€” only show states that actually appear + var hasMissing = entries.Any(e => e.Status == TranslationStatus.Missing); + var hasReview = entries.Any(e => e.Status == TranslationStatus.Review); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(" [dim]Status[/]"); + AnsiConsole.MarkupLine(" [green]Resolved[/] Fully verified β€” source text and code usage found"); + if (hasReview) + AnsiConsole.MarkupLine(" [yellow]Review[/] Needs manual check\n [dim]β€” = definition found but no usage detected (possibly unused)\n β‰ˆ = dynamic key expression (actual key determined at runtime)[/]"); + if (hasMissing) + AnsiConsole.MarkupLine(" [red]Missing[/] Code references this key but no source text found"); + + // Source β€” only show types that actually appear + var kinds = entries.SelectMany(e => e.Definitions).Select(d => d.Kind).ToHashSet(); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(" [dim]Source[/]"); + if (kinds.Contains(DefinitionKind.InlineTranslation)) + AnsiConsole.MarkupLine(" [bold].Translation()[/] Source text defined inline in code"); + if (kinds.Contains(DefinitionKind.ReusableDefinition)) + AnsiConsole.MarkupLine(" [bold]DefineXxx()[/] Reusable definition (DefineSimple, DefinePlural, ...)"); + if (kinds.Contains(DefinitionKind.EnumAttribute)) + AnsiConsole.MarkupLine(" [bold][[Translation]][/] Enum member attribute"); + if (kinds.Contains(DefinitionKind.ResourceFile)) + AnsiConsole.MarkupLine(" [bold].resx[/] .resx resource file"); + + // .resx Files β€” only when RESX files were found + if (hasResxFiles) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(" [dim].resx Files[/]"); + AnsiConsole.MarkupLine(" [bold]Coverage[/] % of source locale keys present in this locale"); + AnsiConsole.MarkupLine(" [bold]Unique[/] Keys in this locale that don't exist in the source locale"); + AnsiConsole.MarkupLine(" [bold]Unreferenced[/] Source .resx keys with no usage found in your code"); + } + + // Extraction β€” only when the extracted calls table was shown + if (showedExtractedCalls) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(" [dim]Extraction[/]"); + AnsiConsole.MarkupLine(" [green]Detected[/] blazor-loc detected and classified the call"); + } + + // Invalid β€” only when invalid entries were detected + if (hasInvalidEntries) + { + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine(" [dim]Invalid[/]"); + AnsiConsole.MarkupLine(" [red]Empty key[/] Empty Localizer indexer or .Translate(key:\"\") β€” compiles but does nothing"); + } + } + + // ── Formatting helpers ── + + private static string FormatUsage(MergedTranslation entry, PathStyle pathStyle) + { + var lines = entry.References + .Select(r => r.DisplayMarkup(pathStyle)) + .ToList(); + + return lines.Count > 0 ? string.Join("\n", lines) : "[dim]β€”[/]"; + } + + private static string FormatForm(TranslationSourceText? sourceText) + { + var form = TranslationFormExtensions.From(sourceText); + return form switch + { + TranslationForm.Simple => "[green]Simple[/]", + TranslationForm.Plural => "[blue]Plural[/]", + TranslationForm.Ordinal => "[blue]Ordinal[/]", + TranslationForm.Select => "[magenta]Select[/]", + TranslationForm.SelectPlural => "[magenta]Select+Plural[/]", + null => "[dim]β€”[/]", + _ => "[dim]?[/]" + }; + } + + private static string FormatSource(MergedTranslation entry, PathStyle pathStyle) + { + if (entry.Definitions.Count == 0) return "[dim]β€”[/]"; + + var def = entry.Definitions[0]; // first-seen wins (matches SourceText resolution) + var label = def.Kind switch + { + DefinitionKind.InlineTranslation => "[dim].Translation()[/]", + DefinitionKind.ReusableDefinition => $"[dim]{Markup.Escape(FormatDefinitionLabel(def.Context))}[/]", + DefinitionKind.EnumAttribute => "[dim][[Translation]][/]", + DefinitionKind.ResourceFile => $"[dim]{Markup.Escape(def.File.FileName)}[/]", + _ => "[dim]β€”[/]" + }; + + // ResourceFile: the filename IS the location β€” no need for a second line + if (def.Kind == DefinitionKind.ResourceFile) + return label; + + return $"{label}\n {def.DisplayMarkup(pathStyle)}"; + } + + private static string FormatDefinitionLabel(string? context) + { + // Context is e.g. "TranslationDefinitions.DefineSimple" β€” extract "DefineSimple()" + if (context is null) return "Definition()"; + var dot = context.LastIndexOf('.'); + var name = dot >= 0 ? context[(dot + 1)..] : context; + return $"{name}()"; + } + + private static string FormatStatus(MergedTranslation entry) => + entry.Status switch + { + TranslationStatus.Review => "[yellow]Review[/]", + TranslationStatus.Missing => "[red]Missing[/]", + _ => "[green]Resolved[/]" + }; + + private static string FormatLocales(MergedTranslation entry) + { + if (entry.InlineTranslations is not { Count: > 0 }) + return "[dim]β€”[/]"; + + return string.Join(", ", entry.InlineTranslations.Keys.Order(StringComparer.OrdinalIgnoreCase)); + } + + private static string FormatSourceText(TranslationSourceText? text) + { + switch (text) + { + case SingularText s: + return $"\"{Markup.Escape(s.Value)}\""; + + case PluralText p: + var parts = new List(); + if (p.Zero is not null) parts.Add($"zero: \"{Markup.Escape(p.Zero)}\""); + if (p.One is not null) parts.Add($"one: \"{Markup.Escape(p.One)}\""); + if (p.Two is not null) parts.Add($"two: \"{Markup.Escape(p.Two)}\""); + if (p.Few is not null) parts.Add($"few: \"{Markup.Escape(p.Few)}\""); + if (p.Many is not null) parts.Add($"many: \"{Markup.Escape(p.Many)}\""); + parts.Add($"other: \"{Markup.Escape(p.Other)}\""); + if (p.ExactMatches is { Count: > 0 }) + foreach (var (v, m) in p.ExactMatches) + parts.Add($"exactly({v}): \"{Markup.Escape(m)}\""); + return string.Join("\n", parts); + + case SelectText s: + var selectParts = s.Cases.Select(c => $"{Markup.Escape(c.Key)}: \"{Markup.Escape(c.Value)}\"").ToList(); + if (s.Otherwise is not null) + selectParts.Add($"otherwise: \"{Markup.Escape(s.Otherwise)}\""); + return string.Join("\n", selectParts); + + case SelectPluralText sp: + var spParts = new List(); + foreach (var (caseValue, plural) in sp.Cases) + { + spParts.Add($"[magenta]{Markup.Escape(caseValue)}:[/]"); + spParts.Add(FormatPluralInline(plural)); + } + if (sp.Otherwise is not null) + { + spParts.Add("[magenta]otherwise:[/]"); + spParts.Add(FormatPluralInline(sp.Otherwise)); + } + return string.Join("\n", spParts); + + case null: + return "[dim]β€”[/]"; + + default: + return "[dim]?[/]"; + } + } + + private static string FormatSourceTextBrief(TranslationSourceText? text) => + text switch + { + SingularText s => s.Value, + PluralText p => p.One ?? p.Other, + SelectText s => s.Otherwise ?? s.Cases.Values.FirstOrDefault() ?? "β€”", + SelectPluralText sp => sp.Otherwise?.One ?? sp.Otherwise?.Other ?? "β€”", + null => "β€”", + _ => "?" + }; + + private static string FormatPluralInline(PluralText p) + { + var parts = new List(); + if (p.Zero is not null) parts.Add($" zero: \"{Markup.Escape(p.Zero)}\""); + if (p.One is not null) parts.Add($" one: \"{Markup.Escape(p.One)}\""); + if (p.Two is not null) parts.Add($" two: \"{Markup.Escape(p.Two)}\""); + if (p.Few is not null) parts.Add($" few: \"{Markup.Escape(p.Few)}\""); + if (p.Many is not null) parts.Add($" many: \"{Markup.Escape(p.Many)}\""); + parts.Add($" other: \"{Markup.Escape(p.Other)}\""); + if (p.ExactMatches is { Count: > 0 }) + foreach (var (v, m) in p.ExactMatches) + parts.Add($" exactly({v}): \"{Markup.Escape(m)}\""); + return string.Join("\n", parts); + } + + private static string GetLocaleDisplayName(string locale) + { + try + { + var culture = CultureInfo.GetCultureInfo(locale, predefinedOnly: true); + return culture.EnglishName != locale ? locale : locale; + } + catch + { + return locale; + } + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/ExporterFactory.cs b/src/BlazorLocalization.Extractor/Adapters/Export/ExporterFactory.cs new file mode 100644 index 0000000..8a77292 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Export/ExporterFactory.cs @@ -0,0 +1,25 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Export; + +/// +/// Routes an to the matching implementation. +/// +internal static class ExporterFactory +{ + public static ITranslationExporter Create(ExportFormat format) => format switch + { + ExportFormat.I18Next => new I18NextJsonExporter(), + ExportFormat.Json => new GenericJsonExporter(), + ExportFormat.Po => new PoExporter(), + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown export format") + }; + + public static string GetFileExtension(ExportFormat format) => format switch + { + ExportFormat.I18Next => ".i18next.json", + ExportFormat.Json => ".json", + ExportFormat.Po => ".po", + _ => throw new ArgumentOutOfRangeException(nameof(format), format, "Unknown export format") + }; +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/GenericJsonExporter.cs b/src/BlazorLocalization.Extractor/Adapters/Export/GenericJsonExporter.cs new file mode 100644 index 0000000..af1daf6 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Export/GenericJsonExporter.cs @@ -0,0 +1,128 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Unicode; +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Export; + +/// +/// Exports records as a JSON array β€” a 1:1 serialization +/// of the domain model, useful for debugging and downstream tooling that needs full fidelity. +/// +internal sealed class GenericJsonExporter : ITranslationExporter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) + }; + + public string Export(IReadOnlyList entries, PathStyle pathStyle) + { + var dto = entries.Select(e => ToDto(e, pathStyle)).ToList(); + return JsonSerializer.Serialize(dto, JsonOptions); + } + + private static EntryDto ToDto(MergedTranslation entry, PathStyle pathStyle) => new() + { + Key = entry.Key, + SourceText = MapSourceText(entry.SourceText), + InlineTranslations = entry.InlineTranslations?.ToDictionary( + kvp => kvp.Key, + kvp => MapSourceText(kvp.Value)), + Definitions = entry.Definitions.Select(d => new DefinitionDto + { + FilePath = d.File.Display(pathStyle), + Line = d.Line, + ProjectName = d.File.ProjectName, + Context = d.Context + }).ToList(), + References = entry.References.Select(r => new ReferenceDto + { + FilePath = r.File.Display(pathStyle), + Line = r.Line, + ProjectName = r.File.ProjectName + }).ToList() + }; + + private static SourceTextDto? MapSourceText(TranslationSourceText? text) => text switch + { + SingularText s => new SourceTextDto + { + Type = "singular", + Value = s.Value + }, + PluralText p => new SourceTextDto + { + Type = "plural", + Other = p.Other, + Zero = p.Zero, + One = p.One, + Two = p.Two, + Few = p.Few, + Many = p.Many, + ExactMatches = p.ExactMatches?.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value), + IsOrdinal = p.IsOrdinal ? true : null + }, + SelectText s => new SourceTextDto + { + Type = "select", + Cases = s.Cases.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), + Otherwise = s.Otherwise + }, + SelectPluralText sp => new SourceTextDto + { + Type = "selectPlural", + PluralCases = sp.Cases.ToDictionary( + kvp => kvp.Key, + kvp => MapSourceText(kvp.Value)), + OtherwisePlural = sp.Otherwise is not null ? MapSourceText(sp.Otherwise) : null + }, + _ => null + }; + + private sealed class EntryDto + { + public required string Key { get; init; } + public SourceTextDto? SourceText { get; init; } + public Dictionary? InlineTranslations { get; init; } + public required List Definitions { get; init; } + public required List References { get; init; } + } + + private sealed class SourceTextDto + { + public required string Type { get; init; } + public string? Value { get; init; } + public string? Other { get; init; } + public string? Zero { get; init; } + public string? One { get; init; } + public string? Two { get; init; } + public string? Few { get; init; } + public string? Many { get; init; } + public Dictionary? ExactMatches { get; init; } + public bool? IsOrdinal { get; init; } + public Dictionary? Cases { get; init; } + public string? Otherwise { get; init; } + public Dictionary? PluralCases { get; init; } + public SourceTextDto? OtherwisePlural { get; init; } + } + + private sealed class DefinitionDto + { + public required string FilePath { get; init; } + public int Line { get; init; } + public required string ProjectName { get; init; } + public string? Context { get; init; } + } + + private sealed class ReferenceDto + { + public required string FilePath { get; init; } + public int Line { get; init; } + public required string ProjectName { get; init; } + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/I18NextJsonExporter.cs b/src/BlazorLocalization.Extractor/Adapters/Export/I18NextJsonExporter.cs new file mode 100644 index 0000000..da12aea --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Export/I18NextJsonExporter.cs @@ -0,0 +1,76 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Unicode; +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Export; + +/// +/// Exports translations as i18next JSON for Crowdin upload. +/// Plural keys use the _one / _other suffix convention. +/// Key-only entries (no source text) emit an empty string value. +/// +internal sealed class I18NextJsonExporter : ITranslationExporter +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) + }; + + public string Export(IReadOnlyList entries, PathStyle pathStyle) + { + var dict = new Dictionary(); + + foreach (var entry in entries) + EmitEntry(dict, entry.Key, entry.SourceText); + + return JsonSerializer.Serialize(dict, JsonOptions); + } + + private static void EmitEntry(Dictionary dict, string key, TranslationSourceText? sourceText) + { + switch (sourceText) + { + case PluralText p: + EmitPluralText(dict, key, p); + break; + + case SelectText s: + foreach (var (caseValue, message) in s.Cases) + dict[$"{key}_{caseValue}"] = message; + if (s.Otherwise is not null) + dict[key] = s.Otherwise; + break; + + case SelectPluralText sp: + foreach (var (caseValue, plural) in sp.Cases) + EmitPluralText(dict, $"{key}_{caseValue}", plural); + if (sp.Otherwise is not null) + EmitPluralText(dict, key, sp.Otherwise); + break; + + case SingularText s: + dict[key] = s.Value; + break; + + default: + dict[key] = ""; + break; + } + } + + private static void EmitPluralText(Dictionary dict, string key, PluralText p) + { + if (p.Zero is not null) dict[$"{key}_zero"] = p.Zero; + if (p.One is not null) dict[$"{key}_one"] = p.One; + if (p.Two is not null) dict[$"{key}_two"] = p.Two; + if (p.Few is not null) dict[$"{key}_few"] = p.Few; + if (p.Many is not null) dict[$"{key}_many"] = p.Many; + dict[$"{key}_other"] = p.Other; + + if (p.ExactMatches is not null) + foreach (var (value, message) in p.ExactMatches) + dict[$"{key}_exactly_{value}"] = message; + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/ITranslationExporter.cs b/src/BlazorLocalization.Extractor/Adapters/Export/ITranslationExporter.cs new file mode 100644 index 0000000..c6812e8 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Export/ITranslationExporter.cs @@ -0,0 +1,11 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Export; + +/// +/// Serializes records into a format-specific string. +/// +internal interface ITranslationExporter +{ + string Export(IReadOnlyList entries, PathStyle pathStyle); +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/PoExporter.cs b/src/BlazorLocalization.Extractor/Adapters/Export/PoExporter.cs new file mode 100644 index 0000000..67ff21a --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Export/PoExporter.cs @@ -0,0 +1,136 @@ +using System.Text; +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Export; + +/// +/// Exports translations as a GNU Gettext PO template (.pot). +/// #: reference comments carry file path and line number from definition sites. +/// #. extracted comments carry the context (e.g. resx comment or containing type). +/// +internal sealed class PoExporter : ITranslationExporter +{ + public string Export(IReadOnlyList entries, PathStyle pathStyle) + { + var sb = new StringBuilder(); + WriteHeader(sb); + + foreach (var entry in entries) + { + WriteReferenceComments(sb, entry, pathStyle); + WriteEntry(sb, entry); + sb.AppendLine(); + } + + return sb.ToString(); + } + + private static void WriteHeader(StringBuilder sb) + { + sb.AppendLine("msgid \"\""); + sb.AppendLine("msgstr \"\""); + sb.AppendLine("\"MIME-Version: 1.0\\n\""); + sb.AppendLine("\"Content-Type: text/plain; charset=UTF-8\\n\""); + sb.AppendLine("\"Content-Transfer-Encoding: 8bit\\n\""); + sb.AppendLine("\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\""); + sb.AppendLine("\"X-Crowdin-SourceKey: msgstr\\n\""); + sb.AppendLine(); + } + + private static void WriteReferenceComments(StringBuilder sb, MergedTranslation entry, PathStyle pathStyle) + { + foreach (var site in entry.Definitions) + { + sb.AppendLine($"#: {site.File.Display(pathStyle)}:{site.Line}"); + if (site.Context is not null) + sb.AppendLine($"#. {site.Context}"); + } + + // For reference-only entries (no definitions), include reference sites + if (entry.Definitions.Count == 0) + { + foreach (var site in entry.References) + { + sb.AppendLine($"#: {site.File.Display(pathStyle)}:{site.Line}"); + if (site.Context is not null) + sb.AppendLine($"#. {site.Context}"); + } + } + } + + private static void WriteEntry(StringBuilder sb, MergedTranslation entry) + { + switch (entry.SourceText) + { + case PluralText p: + WritePluralEntries(sb, entry.Key, p); + break; + + case SelectText s: + foreach (var (caseValue, message) in s.Cases) + { + sb.AppendLine($"msgid \"{Escape(entry.Key + "_" + caseValue)}\""); + sb.AppendLine($"msgstr \"{Escape(message)}\""); + sb.AppendLine(); + } + if (s.Otherwise is not null) + { + sb.AppendLine($"msgid \"{Escape(entry.Key)}\""); + sb.AppendLine($"msgstr \"{Escape(s.Otherwise)}\""); + } + else + { + sb.AppendLine($"msgid \"{Escape(entry.Key)}\""); + sb.AppendLine("msgstr \"\""); + } + break; + + case SelectPluralText sp: + foreach (var (caseValue, plural) in sp.Cases) + { + WritePluralEntries(sb, $"{entry.Key}_{caseValue}", plural); + sb.AppendLine(); + } + if (sp.Otherwise is not null) + WritePluralEntries(sb, entry.Key, sp.Otherwise); + break; + + case SingularText s: + sb.AppendLine($"msgid \"{Escape(entry.Key)}\""); + sb.AppendLine($"msgstr \"{Escape(s.Value)}\""); + break; + + default: + sb.AppendLine($"msgid \"{Escape(entry.Key)}\""); + sb.AppendLine("msgstr \"\""); + break; + } + } + + private static void WritePluralEntries(StringBuilder sb, string key, PluralText p) + { + if (p.IsOrdinal) + sb.AppendLine("#. ⚠️ ORDINAL β€” use ordinal forms (1st, 2nd, 3rd), not cardinal (1, 2, 3)"); + + sb.AppendLine($"msgid \"{Escape(key)}\""); + sb.AppendLine($"msgid_plural \"{Escape(key)}\""); + sb.AppendLine($"msgstr[0] \"{Escape(p.One ?? p.Other)}\""); + sb.AppendLine($"msgstr[1] \"{Escape(p.Other)}\""); + + if (p.ExactMatches is not null) + { + foreach (var (value, message) in p.ExactMatches) + { + sb.AppendLine(); + sb.AppendLine($"msgid \"{Escape($"{key}_exactly_{value}")}\""); + sb.AppendLine($"msgstr \"{Escape(message)}\""); + } + } + } + + private static string Escape(string text) => + text.Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n"); +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Export/PoLimitation.cs b/src/BlazorLocalization.Extractor/Adapters/Export/PoLimitation.cs new file mode 100644 index 0000000..df343bc --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Export/PoLimitation.cs @@ -0,0 +1,55 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Export; + +/// +/// A PO format limitation detected for a specific key. +/// PO cannot natively represent exact plural matches or ordinal forms. +/// +public sealed record PoLimitation(string Key, string Limitation) +{ + /// + /// Scans entries for patterns that PO format cannot represent faithfully. + /// + public static IReadOnlyList Detect(IReadOnlyList entries) + { + var results = new List(); + + foreach (var entry in entries) + { + CheckSourceText(entry.Key, entry.SourceText, results); + } + + return results; + } + + private static void CheckSourceText(string key, TranslationSourceText? text, List results) + { + switch (text) + { + case PluralText p: + CheckPlural(key, p, results); + break; + + case SelectPluralText sp: + foreach (var (caseValue, plural) in sp.Cases) + CheckPlural($"{key}_{caseValue}", plural, results); + if (sp.Otherwise is not null) + CheckPlural(key, sp.Otherwise, results); + break; + } + } + + private static void CheckPlural(string key, PluralText p, List results) + { + if (p.ExactMatches is { Count: > 0 }) + results.Add(new PoLimitation(key, + "Exact plural matches (=0, =1, etc.) are exported as separate keys (_exactly_N). " + + "PO translators won't see them as part of the plural form.")); + + if (p.IsOrdinal) + results.Add(new PoLimitation(key, + "Ordinal forms (1st, 2nd, 3rd) are exported with a comment only. " + + "PO has no native ordinal concept.")); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Resx/ResxFileParser.cs b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxFileParser.cs new file mode 100644 index 0000000..a1fdde0 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxFileParser.cs @@ -0,0 +1,116 @@ +using System.Globalization; +using System.Xml; +using System.Xml.Linq; + +namespace BlazorLocalization.Extractor.Adapters.Resx; + +/// +/// Parses .resx XML files and groups neutral + culture-specific files by base name. +/// Ported from the existing Extractor's ResxImporter β€” proven XML parsing logic. +/// +internal static class ResxFileParser +{ + /// + /// Parses a single .resx file into a dictionary of key β†’ (value, comment, line). + /// Filters out non-string resource entries (embedded files, images, etc.). + /// + public static IReadOnlyDictionary ParseResx(string resxFilePath) + { + var doc = XDocument.Load(resxFilePath, LoadOptions.SetLineInfo); + var entries = new Dictionary(); + + foreach (var data in doc.Descendants("data")) + { + // Entries with a "type" attribute are embedded resources, not strings + if (data.Attribute("type") is not null) + continue; + + var key = data.Attribute("name")?.Value; + if (key is null) + continue; + + var value = data.Element("value")?.Value; + if (string.IsNullOrEmpty(value)) + continue; + + var comment = data.Element("comment")?.Value; + var line = (data as IXmlLineInfo)?.LineNumber ?? 0; + + entries[key] = (value, comment, line); + } + + return entries; + } + + /// + /// Enumerates .resx files in a directory, excluding bin/ and obj/ folders. + /// + public static IEnumerable EnumerateResxFiles(string root) => + Directory.EnumerateFiles(root, "*.resx", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") + && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) + .Order(); + + /// + /// Groups .resx file paths by base name. For example, Home.resx, Home.da.resx, + /// and Home.es-MX.resx all share the base name Home. + /// + public static Dictionary GroupByBaseName(IEnumerable paths) + { + var groups = new Dictionary(); + + foreach (var path in paths) + { + var (baseName, culture) = ParseResxFileName(path); + if (!groups.TryGetValue(baseName, out var group)) + { + group = new ResxFileGroup(); + groups[baseName] = group; + } + + if (culture is null) + group.NeutralPath = path; + else + group.CulturePaths[culture] = path; + } + + return groups; + } + + /// + /// Parses a .resx file path into its base name and optional culture code. + /// /path/Home.resx β†’ (/path/Home, null); + /// /path/Home.da.resx β†’ (/path/Home, "da"). + /// + private static (string BaseName, string? Culture) ParseResxFileName(string path) + { + var dir = Path.GetDirectoryName(path); + var fileNameNoResx = Path.GetFileNameWithoutExtension(path); + var lastDot = fileNameNoResx.LastIndexOf('.'); + if (lastDot < 0) + return (Combine(dir, fileNameNoResx), null); + + var suffix = fileNameNoResx[(lastDot + 1)..]; + try + { + CultureInfo.GetCultureInfo(suffix, predefinedOnly: true); + return (Combine(dir, fileNameNoResx[..lastDot]), suffix); + } + catch + { + return (Combine(dir, fileNameNoResx), null); + } + + static string Combine(string? dir, string name) => + dir is null ? name : Path.Combine(dir, name); + } + + /// + /// Mutable accumulator for grouping neutral and culture-specific .resx files by base name. + /// + internal sealed class ResxFileGroup + { + public string? NeutralPath { get; set; } + public Dictionary CulturePaths { get; } = new(); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScanner.cs b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScanner.cs new file mode 100644 index 0000000..457a483 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScanner.cs @@ -0,0 +1,91 @@ +using BlazorLocalization.Extractor.Domain; +using BlazorLocalization.Extractor.Ports; + +namespace BlazorLocalization.Extractor.Adapters.Resx; + +/// +/// Orchestrates a .resx-based scan of a project directory. +/// Enumerates .resx files, groups by base name, parses XML, and converts to domain types. +/// +internal static class ResxScanner +{ + /// + /// Scans a project directory for .resx files and returns a port-compliant . + /// Neutral .resx β†’ with source text. + /// Culture-specific .resx β†’ inline translations on the same definition. + /// Culture-only keys (no neutral) β†’ skipped with a warning. + /// + public static ResxScannerOutput Scan(string projectDir) + { + var definitions = new List(); + var diagnostics = new List(); + + var files = ResxFileParser.EnumerateResxFiles(projectDir); + var groups = ResxFileParser.GroupByBaseName(files); + + foreach (var (_, group) in groups.OrderBy(g => g.Key, StringComparer.Ordinal)) + { + ImportGroup(group, projectDir, definitions, diagnostics); + } + + return new ResxScannerOutput(definitions, diagnostics); + } + + private static void ImportGroup( + ResxFileParser.ResxFileGroup group, + string projectDir, + List definitions, + List diagnostics) + { + // Parse neutral file + var neutralEntries = group.NeutralPath is not null + ? ResxFileParser.ParseResx(group.NeutralPath) + : new Dictionary(); + + // Parse all culture files + var cultureEntries = new Dictionary>(); + foreach (var (culture, path) in group.CulturePaths) + cultureEntries[culture] = ResxFileParser.ParseResx(path); + + // Collect all keys across neutral and culture files + var allKeys = new HashSet(neutralEntries.Keys); + foreach (var entries in cultureEntries.Values) + allKeys.UnionWith(entries.Keys); + + foreach (var key in allKeys.Order(StringComparer.Ordinal)) + { + // Neutral β†’ SourceText + DefinitionSite + if (!neutralEntries.TryGetValue(key, out var neutral)) + { + // Culture-only key: no source text available from resx + var cultures = cultureEntries + .Where(e => e.Value.ContainsKey(key)) + .Select(e => e.Key); + diagnostics.Add(new ScanDiagnostic( + DiagnosticLevel.Warning, + $"Key \"{key}\" found in culture file(s) [{string.Join(", ", cultures)}] but not in neutral .resx")); + continue; + } + + var file = new SourceFilePath(group.NeutralPath!, projectDir); + var site = new DefinitionSite(file, neutral.Line, DefinitionKind.ResourceFile, neutral.Comment); + + // Culture files β†’ InlineTranslations + Dictionary? inlineTranslations = null; + foreach (var (culture, entries) in cultureEntries) + { + if (entries.TryGetValue(key, out var cultureEntry)) + { + inlineTranslations ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + inlineTranslations[culture] = new SingularText(cultureEntry.Value); + } + } + + definitions.Add(new TranslationDefinition( + key, + new SingularText(neutral.Value), + site, + inlineTranslations)); + } + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScannerOutput.cs b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScannerOutput.cs new file mode 100644 index 0000000..91398be --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Resx/ResxScannerOutput.cs @@ -0,0 +1,15 @@ +using BlazorLocalization.Extractor.Domain; +using BlazorLocalization.Extractor.Ports; + +namespace BlazorLocalization.Extractor.Adapters.Resx; + +/// +/// Resx adapter's implementation of . +/// Produces definitions only β€” .resx files define source text, they don't reference keys in code. +/// +internal sealed record ResxScannerOutput( + IReadOnlyList Definitions, + IReadOnlyList Diagnostics) : IScannerOutput +{ + public IReadOnlyList References { get; } = []; +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/CSharpFileProvider.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CSharpFileProvider.cs new file mode 100644 index 0000000..cfb7279 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CSharpFileProvider.cs @@ -0,0 +1,28 @@ +using Microsoft.CodeAnalysis.CSharp; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Enumerates .cs files under a project directory and produces +/// s ready for . +/// Skips obj/ and bin/ directories. +/// +internal static class CSharpFileProvider +{ + public static IReadOnlyList GetDocuments(string projectDir) + { + var docs = new List(); + foreach (var path in EnumerateSourceFiles(projectDir)) + { + var tree = CSharpSyntaxTree.ParseText(System.IO.File.ReadAllText(path), path: path); + docs.Add(new SourceDocument(tree, path, projectDir, LineMap: null)); + } + return docs; + } + + private static IEnumerable EnumerateSourceFiles(string root) => + Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") + && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) + .Order(); +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallInterpreter.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallInterpreter.cs new file mode 100644 index 0000000..0a00f6c --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallInterpreter.cs @@ -0,0 +1,648 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Converts (with its fluent chain) into domain types: +/// or . +/// +/// Dispatches by method name on chain links β€” no BuilderSymbolTable needed. +/// The builder type was already confirmed by . +/// +internal static class CallInterpreter +{ + private static readonly HashSet PluralCategoryNames = + ExtensionsContract.PluralCategoryNames; + + /// + /// Classify a scanned call site and produce the appropriate domain type. + /// + public static (TranslationDefinition? Definition, TranslationReference? Reference) Interpret( + ScannedCallSite call, SourceFilePath file) + { + if (!call.IsDefinition) + return InterpretAsReference(call, file); + + return InterpretAsDefinition(call, file); + } + + private static (TranslationDefinition?, TranslationReference?) InterpretAsReference( + ScannedCallSite call, SourceFilePath file) + { + // Try common parameter names for the key, then fall back to position 0 + var keyArg = FindArg(call.Arguments, "key") + ?? FindArg(call.Arguments, "name") + ?? FindArgByPosition(call.Arguments, 0); + + if (keyArg is null) return (null, null); + + string? key = null; + if (keyArg.Value.TryGetString(out var k)) + key = k; + else if (keyArg.Value is OperationValue.Constant { Value: var v }) + key = v?.ToString(); + + if (key is null) + { + // Translation(definition) and Display(enum) calls use pre-defined keys β€” + // the key comes from DefineXxx/[Translation] definitions, not this call site. + // Only indexer/GetString calls with non-literal keys should produce dynamic refs. + if (call.MethodName is ExtensionsContract.Translation or ExtensionsContract.Display) + return (null, null); + + // Non-literal key β€” still a reference, just not resolvable + var syntax = keyArg.Value switch + { + OperationValue.SymbolReference { Symbol: var sym } => sym.Name, + OperationValue.Unrecognized { Syntax: var s } => s, + _ => null + }; + + if (syntax is not null) + { + var site = new ReferenceSite(file, call.Line); + return (null, new TranslationReference(syntax, false, site)); + } + + return (null, null); + } + + var isLiteral = keyArg.Value.IsLiteral; + var context = call.CallKind == CallKind.Indexer ? "IStringLocalizer.this[]" : null; + var refSite = new ReferenceSite(file, call.Line, context); + return (null, new TranslationReference(key, isLiteral, refSite)); + } + + private static (TranslationDefinition?, TranslationReference?) InterpretAsDefinition( + ScannedCallSite call, SourceFilePath file) + { + var keyArg = FindArg(call.Arguments, "key"); + if (keyArg is null || !keyArg.Value.TryGetString(out var key) || key is null) + return (null, null); + + var isFactory = call.MethodName.StartsWith(ExtensionsContract.DefinePrefix, StringComparison.Ordinal); + var kind = isFactory ? DefinitionKind.ReusableDefinition : DefinitionKind.InlineTranslation; + var defSite = new DefinitionSite(file, call.Line, kind, FormatContext(call)); + + // Determine builder kind from return type (captured during CallSiteBuilder via chain presence) + // If no chain: simple translation with just message + // If chain: walk it to determine the form + var chain = call.Chain; + + (TranslationDefinition?, TranslationReference?) result = (null, null); + + if (call.MethodName == ExtensionsContract.Translation) + { + // Check if there's a "message" param β†’ Simple/Singular + var messageArg = FindArg(call.Arguments, "message"); + if (messageArg is not null) + result = InterpretSimple(key, messageArg, chain, defSite); + + // Check if BOTH "select" AND "howMany" β†’ SelectPlural (must check before individual) + else if (FindArg(call.Arguments, "select") is not null + && FindArg(call.Arguments, "howMany") is not null) + { + var ordinalArg = FindArg(call.Arguments, "ordinal"); + var isOrdinal = ordinalArg?.Value is OperationValue.Constant { Value: true }; + result = InterpretSelectPlural(key, isOrdinal, chain, defSite); + } + + // Check if there's a "howMany" param β†’ Plural + else if (FindArg(call.Arguments, "howMany") is not null) + { + var ordinalArg = FindArg(call.Arguments, "ordinal"); + var isOrdinal = ordinalArg?.Value is OperationValue.Constant { Value: true }; + result = InterpretPlural(key, isOrdinal, chain, defSite); + } + + // Check if there's a "select" param β†’ Select + else if (FindArg(call.Arguments, "select") is not null) + { + result = InterpretSelect(key, chain, defSite); + } + } + else if (isFactory) + { + result = InterpretDefinitionFactory(call, key, chain, defSite); + } + + if (result.Item1 is null) + return (null, null); + + // InlineTranslation (.Translation() in Razor/code) is genuinely both definition AND usage. + // ReusableDefinition (DefineXxx factory) is only a definition β€” not a usage. + if (kind == DefinitionKind.InlineTranslation) + { + var refSite = new ReferenceSite(file, call.Line); + return (result.Item1, new TranslationReference(key, true, refSite)); + } + + return (result.Item1, null); + } + + // ─── Simple ────────────────────────────────────────────────────── + + private static (TranslationDefinition?, TranslationReference?) InterpretSimple( + string key, + ScannedArgument messageArg, + IReadOnlyList? chain, + DefinitionSite site) + { + messageArg.Value.TryGetString(out var message); + var sourceText = message is not null + ? new SingularText(message) + : (TranslationSourceText?)null; + + if (sourceText is null) + return (null, null); + + var inlines = CollectSimpleInlineTranslations(chain); + + return (new TranslationDefinition(key, sourceText, site, inlines), null); + } + + private static IReadOnlyDictionary? CollectSimpleInlineTranslations( + IReadOnlyList? chain) + { + if (chain is null or { Count: 0 }) return null; + + Dictionary? result = null; + + foreach (var link in chain) + { + if (link.MethodName != "For") continue; + + var locale = FindChainArgString(link, "locale", 0); + var msg = FindChainArgString(link, "message", 1); + + if (locale is not null && msg is not null) + { + result ??= new(StringComparer.OrdinalIgnoreCase); + result.TryAdd(locale, new SingularText(msg)); + } + } + + return result; + } + + // ─── Plural ────────────────────────────────────────────────────── + + private static (TranslationDefinition?, TranslationReference?) InterpretPlural( + string key, + bool isOrdinal, + IReadOnlyList? chain, + DefinitionSite site) + { + var categories = new Dictionary(); + var exactMatches = new Dictionary(); + var forSections = new List<(string locale, List links)>(); + string? currentLocale = null; + List? currentForLinks = null; + + if (chain is not null) + { + foreach (var link in chain) + { + if (link.MethodName == "For") + { + if (currentLocale is not null && currentForLinks is not null) + forSections.Add((currentLocale, currentForLinks)); + currentLocale = FindChainArgString(link, "locale", 0); + currentForLinks = []; + continue; + } + + if (currentLocale is not null) { currentForLinks?.Add(link); continue; } + + if (link.MethodName == "Exactly") + { + var valStr = FindChainArgString(link, "value", 0); + var msg = FindChainArgString(link, "message", 1); + if (valStr is not null && int.TryParse(valStr, out var exact) && msg is not null) + exactMatches[exact] = msg; + continue; + } + + if (PluralCategoryNames.Contains(link.MethodName)) + { + var msg = FindChainArgString(link, "message", 0); + if (msg is not null) categories[link.MethodName] = msg; + } + } + + if (currentLocale is not null && currentForLinks is not null) + forSections.Add((currentLocale, currentForLinks)); + } + + var sourceText = categories.ContainsKey("Other") + ? BuildPluralText(categories, exactMatches, isOrdinal) + : null; + + if (sourceText is null) return (null, null); + + var inlines = BuildPluralInlineTranslations(forSections, isOrdinal); + return (new TranslationDefinition(key, sourceText, site, inlines), null); + } + + // ─── Select ────────────────────────────────────────────────────── + + private static (TranslationDefinition?, TranslationReference?) InterpretSelect( + string key, + IReadOnlyList? chain, + DefinitionSite site) + { + var cases = new Dictionary(); + string? otherwise = null; + var forSections = new List<(string locale, List links)>(); + string? currentLocale = null; + List? currentForLinks = null; + + if (chain is not null) + { + foreach (var link in chain) + { + if (link.MethodName == "For") + { + if (currentLocale is not null && currentForLinks is not null) + forSections.Add((currentLocale, currentForLinks)); + currentLocale = FindChainArgString(link, "locale", 0); + currentForLinks = []; + continue; + } + + if (currentLocale is not null) { currentForLinks?.Add(link); continue; } + + if (link.MethodName == "When") + { + var selectValue = FindChainArgString(link, "select", 0); + var msg = FindChainArgString(link, "message", 1); + if (selectValue is not null && msg is not null) + cases[StripEnumPrefix(selectValue)] = msg; + } + else if (link.MethodName == "Otherwise") + { + otherwise = FindChainArgString(link, "message", 0); + } + } + + if (currentLocale is not null && currentForLinks is not null) + forSections.Add((currentLocale, currentForLinks)); + } + + if (cases.Count == 0 && otherwise is null) return (null, null); + + var sourceText = new SelectText(cases, otherwise); + var inlines = BuildSelectInlineTranslations(forSections); + return (new TranslationDefinition(key, sourceText, site, inlines), null); + } + + // ─── SelectPlural ──────────────────────────────────────────────── + + private static (TranslationDefinition?, TranslationReference?) InterpretSelectPlural( + string key, + bool isOrdinal, + IReadOnlyList? chain, + DefinitionSite site) + { + var cases = new Dictionary(); + PluralText? otherwisePlural = null; + var forSections = new List<(string locale, List links)>(); + string? currentLocale = null; + List? currentForLinks = null; + + // State for accumulating plural categories within a select case + string? currentSelectCase = null; + var currentCategories = new Dictionary(); + var currentExact = new Dictionary(); + var selectCaseStarted = false; + + void FlushSelectCase() + { + if (!selectCaseStarted || (currentCategories.Count == 0 && currentExact.Count == 0)) return; + var plural = BuildPluralText(currentCategories, currentExact, isOrdinal); + if (currentSelectCase is not null) + cases[currentSelectCase] = plural; + else + otherwisePlural = plural; + } + + if (chain is not null) + { + foreach (var link in chain) + { + // Entering a .For() locale section + if (link.MethodName == "For") + { + FlushSelectCase(); + selectCaseStarted = false; + currentSelectCase = null; + currentCategories = []; + currentExact = []; + + if (currentLocale is not null && currentForLinks is not null) + forSections.Add((currentLocale, currentForLinks)); + currentLocale = FindChainArgString(link, "locale", 0); + currentForLinks = []; + continue; + } + + // Inside a .For() section β€” collect links for later interpretation + if (currentLocale is not null) { currentForLinks?.Add(link); continue; } + + // .When(select) β€” new select case + if (link.MethodName == "When") + { + FlushSelectCase(); + currentSelectCase = StripEnumPrefix(FindChainArgString(link, "select", 0) ?? ""); + currentCategories = []; + currentExact = []; + selectCaseStarted = true; + continue; + } + + // .Otherwise() β€” fallback select case (no arg) + if (link.MethodName == "Otherwise" && link.Arguments.Count == 0) + { + FlushSelectCase(); + currentSelectCase = null; + currentCategories = []; + currentExact = []; + selectCaseStarted = true; + continue; + } + + // .Exactly(value, message) β€” exact match within current case + if (link.MethodName == "Exactly") + { + selectCaseStarted = true; + var valStr = FindChainArgString(link, "value", 0); + var msg = FindChainArgString(link, "message", 1); + if (valStr is not null && int.TryParse(valStr, out var exact) && msg is not null) + currentExact[exact] = msg; + continue; + } + + // Plural category (.One, .Other, etc.) + if (PluralCategoryNames.Contains(link.MethodName)) + { + selectCaseStarted = true; + var msg = FindChainArgString(link, "message", 0); + if (msg is not null) currentCategories[link.MethodName] = msg; + } + } + + FlushSelectCase(); + if (currentLocale is not null && currentForLinks is not null) + forSections.Add((currentLocale, currentForLinks)); + } + + if (cases.Count == 0 && otherwisePlural is null) return (null, null); + + var sourceText = new SelectPluralText(cases, otherwisePlural); + var inlines = BuildSelectPluralInlineTranslations(forSections, isOrdinal); + return (new TranslationDefinition(key, sourceText, site, inlines), null); + } + + private static IReadOnlyDictionary? BuildSelectPluralInlineTranslations( + List<(string locale, List links)> forSections, + bool isOrdinal) + { + if (forSections.Count == 0) return null; + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (locale, links) in forSections) + { + var cases = new Dictionary(); + PluralText? otherwisePlural = null; + + string? currentSelectCase = null; + var currentCategories = new Dictionary(); + var currentExact = new Dictionary(); + var selectCaseStarted = false; + + void FlushCase() + { + if (!selectCaseStarted || (currentCategories.Count == 0 && currentExact.Count == 0)) return; + var plural = BuildPluralText(currentCategories, currentExact, isOrdinal); + if (currentSelectCase is not null) + cases[currentSelectCase] = plural; + else + otherwisePlural = plural; + } + + foreach (var link in links) + { + if (link.MethodName == "When") + { + FlushCase(); + currentSelectCase = StripEnumPrefix(FindChainArgString(link, "select", 0) ?? ""); + currentCategories = []; + currentExact = []; + selectCaseStarted = true; + } + else if (link.MethodName == "Otherwise" && link.Arguments.Count == 0) + { + FlushCase(); + currentSelectCase = null; + currentCategories = []; + currentExact = []; + selectCaseStarted = true; + } + else if (link.MethodName == "Exactly") + { + selectCaseStarted = true; + var valStr = FindChainArgString(link, "value", 0); + var msg = FindChainArgString(link, "message", 1); + if (valStr is not null && int.TryParse(valStr, out var exact) && msg is not null) + currentExact[exact] = msg; + } + else if (PluralCategoryNames.Contains(link.MethodName)) + { + selectCaseStarted = true; + var msg = FindChainArgString(link, "message", 0); + if (msg is not null) currentCategories[link.MethodName] = msg; + } + } + + FlushCase(); + if (cases.Count > 0 || otherwisePlural is not null) + result[locale] = new SelectPluralText(cases, otherwisePlural); + } + + return result.Count > 0 ? result : null; + } + + // ─── Definition Factories ──────────────────────────────────────── + + private static (TranslationDefinition?, TranslationReference?) InterpretDefinitionFactory( + ScannedCallSite call, + string key, + IReadOnlyList? chain, + DefinitionSite site) + { + if (call.MethodName == ExtensionsContract.DefineSimple) + { + var messageArg = FindArg(call.Arguments, "message"); + if (messageArg is null) return (null, null); + messageArg.Value.TryGetString(out var msg); + if (msg is null) return (null, null); + + var inlines = CollectSimpleInlineTranslations(chain); + return (new TranslationDefinition(key, new SingularText(msg), site, inlines), null); + } + + // DefinePlural, DefineSelect, DefineSelectPlural β€” key only, text comes from chain + if (call.MethodName == ExtensionsContract.DefinePlural) + return InterpretPlural(key, false, chain, site); + + if (call.MethodName == ExtensionsContract.DefineSelect) + return InterpretSelect(key, chain, site); + + if (call.MethodName == ExtensionsContract.DefineSelectPlural) + return InterpretSelectPlural(key, false, chain, site); + + return (null, null); + } + + // ─── Shared helpers ────────────────────────────────────────────── + + private static PluralText BuildPluralText( + Dictionary categories, + Dictionary exactMatches, + bool isOrdinal) + { + return new PluralText( + Other: categories.GetValueOrDefault("Other", ""), + Zero: categories.GetValueOrDefault("Zero"), + One: categories.GetValueOrDefault("One"), + Two: categories.GetValueOrDefault("Two"), + Few: categories.GetValueOrDefault("Few"), + Many: categories.GetValueOrDefault("Many"), + ExactMatches: exactMatches.Count > 0 ? exactMatches : null, + IsOrdinal: isOrdinal); + } + + private static IReadOnlyDictionary? BuildPluralInlineTranslations( + List<(string locale, List links)> forSections, + bool isOrdinal) + { + if (forSections.Count == 0) return null; + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (locale, links) in forSections) + { + var categories = new Dictionary(); + var exactMatches = new Dictionary(); + + foreach (var link in links) + { + if (link.MethodName == "Exactly") + { + var valStr = FindChainArgString(link, "value", 0); + var msg = FindChainArgString(link, "message", 1); + if (valStr is not null && int.TryParse(valStr, out var exact) && msg is not null) + exactMatches[exact] = msg; + } + else if (PluralCategoryNames.Contains(link.MethodName)) + { + var msg = FindChainArgString(link, "message", 0); + if (msg is not null) categories[link.MethodName] = msg; + } + } + + if (categories.Count > 0 || exactMatches.Count > 0) + result[locale] = BuildPluralText(categories, exactMatches, isOrdinal); + } + + return result.Count > 0 ? result : null; + } + + private static IReadOnlyDictionary? BuildSelectInlineTranslations( + List<(string locale, List links)> forSections) + { + if (forSections.Count == 0) return null; + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (locale, links) in forSections) + { + var cases = new Dictionary(); + string? otherwise = null; + + foreach (var link in links) + { + if (link.MethodName == "When") + { + var selectValue = FindChainArgString(link, "select", 0); + var msg = FindChainArgString(link, "message", 1); + if (selectValue is not null && msg is not null) + cases[StripEnumPrefix(selectValue)] = msg; + } + else if (link.MethodName == "Otherwise") + { + otherwise = FindChainArgString(link, "message", 0); + } + } + + if (cases.Count > 0 || otherwise is not null) + result[locale] = new SelectText(cases, otherwise); + } + + return result.Count > 0 ? result : null; + } + + // ─── Argument lookup helpers ───────────────────────────────────── + + private static ScannedArgument? FindArg(IReadOnlyList args, string paramName) + => args.FirstOrDefault(a => a.ParameterName == paramName); + + private static ScannedArgument? FindArgByPosition(IReadOnlyList args, int position) + => args.FirstOrDefault(a => a.Position == position); + + /// + /// Extract a string value from a chain link argument by parameter name or position. + /// + private static string? FindChainArgString(FluentChainWalker.ChainLink link, string paramName, int fallbackPosition) + { + foreach (var arg in link.Arguments) + { + var value = arg.Value.Accept(ValueExtractor.Instance, null); + if (value is null) continue; + + var argParam = arg.Parameter?.Name; + if (argParam == paramName || (argParam is null && arg == link.Arguments[fallbackPosition])) + { + if (value.TryGetString(out var s)) return s; + if (value is OperationValue.Constant { Value: var v }) return v?.ToString(); + if (value is OperationValue.SymbolReference { Symbol: var sym }) return sym.Name; + } + } + + // Fallback: try by position + if (fallbackPosition < link.Arguments.Count) + { + var value = link.Arguments[fallbackPosition].Value.Accept(ValueExtractor.Instance, null); + if (value is not null && value.TryGetString(out var s)) return s; + if (value is OperationValue.Constant { Value: var v }) return v?.ToString(); + if (value is OperationValue.SymbolReference { Symbol: var sym }) return sym.Name; + } + + return null; + } + + private static string StripEnumPrefix(string value) + { + var lastDot = value.LastIndexOf('.'); + return lastDot >= 0 ? value[(lastDot + 1)..] : value; + } + + /// + /// Produces the PO #. context comment: .Translation, TranslationDefinitions.DefineSimple, etc. + /// + private static string FormatContext(ScannedCallSite call) + { + if (call.MethodName.StartsWith(ExtensionsContract.DefinePrefix, StringComparison.Ordinal)) + return $"{nameof(BlazorLocalization.Extensions.Translation.Definitions.TranslationDefinitions)}.{call.MethodName}"; + + return $".{call.MethodName}"; + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs new file mode 100644 index 0000000..24bdecc --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs @@ -0,0 +1,107 @@ +using BlazorLocalization.Extractor.Domain; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Converts raw walker hits into structured records. +/// Extracts arguments via and chains via . +/// +internal static class CallSiteBuilder +{ + public static ScannedCallSite Build( + IOperation operation, + ISymbol symbol, + IReadOnlyList arguments, + SourceFilePath file, + int line) + { + var scannedArgs = ExtractArguments(arguments); + + return operation switch + { + IPropertyReferenceOperation propRef when propRef.Property.IsIndexer => + new ScannedCallSite( + MethodName: "this[]", + ContainingTypeName: propRef.Property.ContainingType?.Name ?? "?", + CallKind: CallKind.Indexer, + File: file, + Line: line, + Arguments: scannedArgs, + Chain: null), + + IInvocationOperation invocation => + BuildFromInvocation(invocation, scannedArgs, file, line), + + _ => new ScannedCallSite( + MethodName: symbol.Name, + ContainingTypeName: symbol.ContainingType?.Name ?? "?", + CallKind: CallKind.MethodCall, + File: file, + Line: line, + Arguments: scannedArgs, + Chain: null) + }; + } + + private static ScannedCallSite BuildFromInvocation( + IInvocationOperation invocation, + IReadOnlyList arguments, + SourceFilePath file, + int line) + { + var method = invocation.TargetMethod; + + // Detect builder-returning calls (Translation() β†’ SimpleBuilder, PluralBuilder, etc.) + var returnType = method.ReturnType; + var returnsBuilder = FluentChainWalker.IsBuilderType(returnType as INamedTypeSymbol); + + // Extension method detection: + // - Classic extensions: ReducedFrom is set on the call-site symbol + // - C# 14 extension blocks: IsExtensionMethod may be true + // - Reduced instance calls: the receiver is the first param + var isExtension = method.IsExtensionMethod || + method.ReducedFrom is not null || + (invocation.Instance is null && method.Parameters.Length > 0 && + method.IsStatic); + + // A call is a "definition" if it returns a builder type OR is a DefineXxx factory + var isDefinitionLike = returnsBuilder || + method.Name.StartsWith("Define", StringComparison.Ordinal); + + var callKind = isDefinitionLike ? CallKind.ExtensionMethod : CallKind.MethodCall; + + // Only collect fluent chain for builder-returning calls + var chain = returnsBuilder ? FluentChainWalker.WalkChain(invocation) : null; + if (chain is { Count: 0 }) chain = null; + + return new ScannedCallSite( + MethodName: method.Name, + ContainingTypeName: method.ContainingType?.Name ?? "?", + CallKind: callKind, + File: file, + Line: line, + Arguments: arguments, + Chain: chain); + } + + private static IReadOnlyList ExtractArguments(IReadOnlyList arguments) + { + var result = new List(arguments.Count); + + for (var i = 0; i < arguments.Count; i++) + { + var arg = arguments[i]; + var value = arg.Value.Accept(ValueExtractor.Instance, null) + ?? new OperationValue.Unrecognized(arg.Value.Kind, arg.Value.Syntax.ToString()); + + result.Add(new ScannedArgument( + Position: i, + ParameterName: arg.Parameter?.Name, + Value: value)); + } + + return result; + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs new file mode 100644 index 0000000..8d0cc8d --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs @@ -0,0 +1,82 @@ +using BlazorLocalization.Extractor.Domain; +using Microsoft.CodeAnalysis; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Extracts records from enum members decorated with +/// [Translation] attributes. Uses the Symbol API (not IOperation) because attributes +/// are declarative metadata, not executable operations. +/// +internal static class EnumAttributeInterpreter +{ + /// + /// Inspects a field symbol (enum member) for [Translation] attributes. + /// Returns a and a matching + /// (so the entry gets status=Resolved instead of Review). + /// + public static (TranslationDefinition? Definition, TranslationReference? Reference) TryInterpret( + IFieldSymbol field, + INamedTypeSymbol translationAttribute, + SourceFilePath file, + int line) + { + var matchingAttrs = field.GetAttributes() + .Where(a => SymbolEqualityComparer.Default.Equals(a.AttributeClass, translationAttribute)) + .ToList(); + + if (matchingAttrs.Count == 0) + return (null, null); + + string? customKey = null; + string? sourceText = null; + Dictionary? inlineTranslations = null; + + foreach (var attr in matchingAttrs) + { + // Constructor arg [0] = message + var message = attr.ConstructorArguments is [{ Value: string msg }] ? msg : null; + if (message is null) + continue; + + // Named args: Locale, Key + string? locale = null; + string? key = null; + foreach (var named in attr.NamedArguments) + { + if (named.Key == ExtensionsContract.AttrLocale && named.Value.Value is string loc) + locale = loc; + else if (named.Key == ExtensionsContract.AttrKey && named.Value.Value is string k) + key = k; + } + + if (locale is not null) + { + inlineTranslations ??= new(StringComparer.OrdinalIgnoreCase); + inlineTranslations[locale] = new SingularText(message); + } + else + { + sourceText = message; + customKey ??= key; + } + } + + if (sourceText is null) + return (null, null); + + var enumTypeName = field.ContainingType.Name; + var memberName = field.Name; + var entryKey = customKey ?? $"Enum.{enumTypeName}_{memberName}"; + + var site = new DefinitionSite(file, line, DefinitionKind.EnumAttribute); + + var def = new TranslationDefinition( + entryKey, + new SingularText(sourceText), + site, + inlineTranslations); + + return (def, null); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/ExtensionsContract.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/ExtensionsContract.cs new file mode 100644 index 0000000..6c46d07 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/ExtensionsContract.cs @@ -0,0 +1,163 @@ +using BlazorLocalization.Extensions; +using BlazorLocalization.Extensions.Translation; +using BlazorLocalization.Extensions.Translation.Definitions; +using Microsoft.Extensions.Localization; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Single source of truth for every reference from the Extractor to the Extensions project. +/// Compiler-verified where C# allows it (typeof, nameof); documented string +/// constants where it doesn't (C#14 extension members, parameter names). +/// +/// If anything in the Extensions project is renamed, this file either breaks the build +/// (for typeof/nameof references) or is the one place to update +/// (for string constants). The test ExtensionsContractTests validates the +/// parameter-name constants via reflection. +/// +/// +internal static class ExtensionsContract +{ + // ── Metadata names (for Compilation.GetTypeByMetadataName) ──────── + + /// Fully-qualified name of . + public static readonly string MetaIStringLocalizer = + typeof(IStringLocalizer).FullName!; + + /// Fully-qualified name of . + public static readonly string MetaTranslationAttribute = + typeof(TranslationAttribute).FullName!; + + /// Fully-qualified name of . + public static readonly string MetaTranslationDefinitions = + typeof(TranslationDefinitions).FullName!; + + // ── Assembly reference ──────────────────────────────────────────── + + /// Location of the Extensions assembly, needed as a compilation reference. + public static readonly string ExtensionsAssemblyLocation = + typeof(BlazorLocalization.Extensions.StringLocalizerExtensions).Assembly.Location; + + // ── Extension method names ──────────────────────────────────────── + // C#14 `extension(IStringLocalizer)` block members cannot be referenced + // via nameof β€” the methods live inside the extension block, not on the + // containing static class. These string constants are the minimum fallback. + + /// The .Translation() extension method name. + /// β€” C#14 extension block member. + public const string Translation = "Translation"; + + /// The .Display() extension method name. + /// β€” C#14 extension block member. + public const string Display = "Display"; + + // ── Definition factory method names ─────────────────────────────── + + /// + public const string DefineSimple = nameof(TranslationDefinitions.DefineSimple); + + /// + public const string DefinePlural = nameof(TranslationDefinitions.DefinePlural); + + /// + public const string DefineSelect = nameof(TranslationDefinitions.DefineSelect); + + /// + public const string DefineSelectPlural = nameof(TranslationDefinitions.DefineSelectPlural); + + /// Shared prefix for all Define* factory methods. + public const string DefinePrefix = "Define"; + + // ── Builder / Definition type names (for FluentChainWalker) ────── + + public const string TypeSimpleBuilder = nameof(SimpleBuilder); + public const string TypePluralBuilder = nameof(PluralBuilder); + // Generic types: nameof(SelectBuilder) yields "SelectBuilder" + public const string TypeSelectBuilder = nameof(SelectBuilder); + public const string TypeSelectPluralBuilder = nameof(SelectPluralBuilder); + public const string TypeSimpleDefinition = nameof(SimpleDefinition); + public const string TypePluralDefinition = nameof(PluralDefinition); + public const string TypeSelectDefinition = nameof(SelectDefinition); + public const string TypeSelectPluralDefinition = nameof(SelectPluralDefinition); + + // ── Chain method names ──────────────────────────────────────────── + + /// , , etc. + public const string ChainFor = nameof(SimpleBuilder.For); + + /// + public const string ChainOne = nameof(PluralBuilder.One); + + /// + public const string ChainOther = nameof(PluralBuilder.Other); + + /// + public const string ChainZero = nameof(PluralBuilder.Zero); + + /// + public const string ChainTwo = nameof(PluralBuilder.Two); + + /// + public const string ChainFew = nameof(PluralBuilder.Few); + + /// + public const string ChainMany = nameof(PluralBuilder.Many); + + /// + public const string ChainExactly = nameof(PluralBuilder.Exactly); + + /// + public const string ChainWhen = nameof(SelectBuilder.When); + + /// + public const string ChainOtherwise = nameof(SelectBuilder.Otherwise); + + // ── [Translation] attribute property names ──────────────────────── + + /// + public const string AttrLocale = nameof(TranslationAttribute.Locale); + + /// + public const string AttrKey = nameof(TranslationAttribute.Key); + + // ── Parameter names ────────────────────────────────────────────── + // C# does not support nameof for method parameters. + // These constants match the parameter names in StringLocalizerExtensions + // and the builder chain methods. Validated by ExtensionsContractTests. + + /// The key parameter on + /// and all .Translation() overloads. + public const string ParamKey = "key"; + + /// The name parameter on IStringLocalizer.this[string name]. + public const string ParamName = "name"; + + /// The message parameter on + /// and builder chain methods like . + public const string ParamMessage = "message"; + + /// The howMany parameter on plural .Translation() overloads. + public const string ParamHowMany = "howMany"; + + /// The select parameter on select .Translation() overloads + /// and . + public const string ParamSelect = "select"; + + /// The ordinal parameter on plural .Translation() overloads. + public const string ParamOrdinal = "ordinal"; + + /// The locale parameter on . + public const string ParamLocale = "locale"; + + /// The value parameter on . + public const string ParamValue = "value"; + + // ── Plural category names ──────────────────────────────────────── + // These serve double duty: they match both the builder method names + // (PluralBuilder.Zero, .One, etc.) and the CLDR category identifiers. + + public static readonly HashSet PluralCategoryNames = + [ + ChainZero, ChainOne, ChainTwo, ChainFew, ChainMany, ChainOther + ]; +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/FluentChainWalker.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/FluentChainWalker.cs new file mode 100644 index 0000000..4aa6f57 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/FluentChainWalker.cs @@ -0,0 +1,63 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Walks Parent from a detected .Translation() call upward through the +/// fluent builder chain (.For(), .One(), .Other(), .When(), .Otherwise(), etc.). +/// Returns chain links in source order (innermost first β†’ outermost last). +/// +/// IOperation trees are inverted relative to source reading order. +/// In source we write: .Translation("key", "msg").For("da", "dansk").For("de", "deutsch") +/// But in the IOperation tree, evaluation order rules β€” what executes first is deepest: +/// +/// .For("de", ...) ← outermost (Parent of .For("da")) +/// Instance: .For("da", ...) +/// Instance: .Translation(...) ← deepest child, executes first +/// +/// So to collect the chain from .Translation(), we walk UP via Parent. +/// +internal static class FluentChainWalker +{ + private static readonly HashSet BuilderTypeNames = + [ + ExtensionsContract.TypeSimpleBuilder, + ExtensionsContract.TypePluralBuilder, + ExtensionsContract.TypeSelectBuilder, + ExtensionsContract.TypeSelectPluralBuilder, + ExtensionsContract.TypeSimpleDefinition, + ExtensionsContract.TypePluralDefinition, + ExtensionsContract.TypeSelectDefinition, + ExtensionsContract.TypeSelectPluralDefinition, + ]; + + public record ChainLink(string MethodName, IReadOnlyList Arguments); + + /// + /// Starting from a detected IOperation (e.g. .Translation()), walk Parent + /// collecting each IInvocationOperation whose containing type is a known builder. + /// + public static List WalkChain(IOperation anchor) + { + var links = new List(); + var current = anchor.Parent; + + while (current is IInvocationOperation invocation && + IsBuilderType(invocation.TargetMethod.ContainingType)) + { + links.Add(new ChainLink( + invocation.TargetMethod.Name, + invocation.Arguments)); + current = invocation.Parent; + } + + return links; + } + + internal static bool IsBuilderType(INamedTypeSymbol? type) + { + if (type is null) return false; + return BuilderTypeNames.Contains(type.Name); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/LineMap.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/LineMap.cs new file mode 100644 index 0000000..688e24f --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/LineMap.cs @@ -0,0 +1,33 @@ +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Maps generated C# line numbers back to original Razor line numbers +/// using #line directive positions collected during Razor compilation. +/// +internal sealed class LineMap +{ + private readonly List<(int GeneratedLine, int OriginalLine)> _entries; + + public LineMap(List<(int GeneratedLine, int OriginalLine)> entries) + { + _entries = entries; + } + + /// + /// Given a 1-based line number in generated C#, returns the corresponding + /// 1-based line in the original .razor / .cshtml file. + /// Returns 0 if the line precedes any #line directive. + /// + public int MapToOriginalLine(int generatedLine) + { + var mapped = 0; + foreach (var entry in _entries) + { + if (entry.GeneratedLine > generatedLine) + break; + // #line N means the line AFTER the directive is original line N + mapped = entry.OriginalLine + (generatedLine - entry.GeneratedLine - 1); + } + return mapped; + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/LocalizerOperationWalker.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/LocalizerOperationWalker.cs new file mode 100644 index 0000000..07387ac --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/LocalizerOperationWalker.cs @@ -0,0 +1,60 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// OperationWalker that detects IStringLocalizer usage β€” both indexer access +/// (localizer["key"]) and method invocations (localizer.GetString(), +/// localizer.Translation()) β€” as well as DefineXxx() static factory calls +/// on TranslationDefinitions. +/// +internal sealed class LocalizerOperationWalker( + INamedTypeSymbol targetInterface, + INamedTypeSymbol? definitionFactory = null) : OperationWalker +{ + private readonly List<(IOperation Op, ISymbol Symbol, IReadOnlyList Arguments)> _results = []; + + public IReadOnlyList<(IOperation Op, ISymbol Symbol, IReadOnlyList Arguments)> Results => _results; + + public override void VisitPropertyReference(IPropertyReferenceOperation operation) + { + if (operation.Property.IsIndexer && + IsAssignableTo(operation.Instance?.Type, targetInterface)) + { + _results.Add((operation, operation.Property, operation.Arguments)); + } + + base.VisitPropertyReference(operation); + } + + public override void VisitInvocation(IInvocationOperation operation) + { + var receiverType = operation.Instance?.Type + ?? operation.TargetMethod.Parameters.FirstOrDefault()?.Type; + + if (IsAssignableTo(receiverType, targetInterface)) + { + _results.Add((operation, operation.TargetMethod, operation.Arguments)); + } + else if (definitionFactory is not null + && operation.Instance is null + && SymbolEqualityComparer.Default.Equals( + operation.TargetMethod.ContainingType, definitionFactory)) + { + _results.Add((operation, operation.TargetMethod, operation.Arguments)); + } + + base.VisitInvocation(operation); + } + + internal static bool IsAssignableTo(ITypeSymbol? type, INamedTypeSymbol targetInterface) + { + if (type == null) return false; + if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, targetInterface)) + return true; + return type.AllInterfaces.Any(i => + SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, targetInterface) || + SymbolEqualityComparer.Default.Equals(i, targetInterface)); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/OperationValue.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/OperationValue.cs new file mode 100644 index 0000000..15fd4ce --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/OperationValue.cs @@ -0,0 +1,160 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Structured result from visiting an IOperation argument value. +/// Adapter-internal β€” used by for domain type production +/// and by the inspect command for diagnostic detail. +/// +internal abstract record OperationValue +{ + /// Compile-time constant (literal, const field, nameof result, folded expression). + public sealed record Constant(object? Value, ITypeSymbol? Type) : OperationValue; + + /// Reference to a non-constant symbol (field, local, parameter). + public sealed record SymbolReference(ISymbol Symbol, ITypeSymbol? Type) : OperationValue; + + /// Array initializer with per-element values. + public sealed record ArrayElements(ImmutableArray Items) : OperationValue; + + /// A nameof() expression that resolved to a string constant. + public sealed record NameOf(string Name) : OperationValue; + + /// Anything else β€” the adapter couldn't classify it. + public sealed record Unrecognized(OperationKind Kind, string Syntax) : OperationValue; + + /// + /// Tries to extract a string value. Returns true for Constant(string) and NameOf. + /// This is the adapterβ†’domain bridge: if this returns true, the argument is literal. + /// + public bool TryGetString(out string? value) + { + switch (this) + { + case Constant { Value: string s }: + value = s; + return true; + case NameOf { Name: var n }: + value = n; + return true; + default: + value = null; + return false; + } + } + + /// + /// Tries to extract an int value. Returns true for Constant(int). + /// + public bool TryGetInt(out int value) + { + if (this is Constant { Value: int i }) + { + value = i; + return true; + } + value = 0; + return false; + } + + /// Whether this value is a compile-time constant. + public bool IsLiteral => this is Constant or NameOf; + + /// + /// Human-readable display text for diagnostic output. + /// Same pattern as . + /// + public string Display(int maxLength = 80) => this switch + { + Constant { Value: string s } => $"\"{Truncate(s, maxLength - 2)}\"", + Constant { Value: var v } => v?.ToString() ?? "null", + SymbolReference { Symbol: var s } => s.Name, + NameOf { Name: var n } => $"nameof({n})", + ArrayElements { Items: var items } => $"[{items.Length} elements]", + Unrecognized { Syntax: var s } => Truncate(s, maxLength), + _ => "?" + }; + + private static string Truncate(string text, int maxLength) => + text.Length > maxLength ? text[..(maxLength - 3)] + "..." : text; +} + +/// +/// Extracts from an using Roslyn's +/// visitor dispatch. Called via operation.Accept(ValueExtractor.Instance, null). +/// +internal sealed class ValueExtractor : OperationVisitor +{ + public static readonly ValueExtractor Instance = new(); + + public override OperationValue VisitLiteral(ILiteralOperation operation, object? argument) + => new OperationValue.Constant(operation.ConstantValue.Value, operation.Type); + + public override OperationValue VisitFieldReference(IFieldReferenceOperation operation, object? argument) + { + // Enum members have constant values (0, 1, 2...) but we need the field NAME + // for select/selectPlural interpretation. Preserve as SymbolReference. + if (operation.Field.ContainingType?.TypeKind == TypeKind.Enum) + return new OperationValue.SymbolReference(operation.Field, operation.Field.Type); + + return operation.ConstantValue is { HasValue: true } cv + ? new OperationValue.Constant(cv.Value, operation.Field.Type) + : new OperationValue.SymbolReference(operation.Field, operation.Field.Type); + } + + public override OperationValue VisitLocalReference(ILocalReferenceOperation operation, object? argument) + => operation.ConstantValue is { HasValue: true } cv + ? new OperationValue.Constant(cv.Value, operation.Local.Type) + : new OperationValue.SymbolReference(operation.Local, operation.Local.Type); + + public override OperationValue VisitParameterReference(IParameterReferenceOperation operation, object? argument) + => new OperationValue.SymbolReference(operation.Parameter, operation.Parameter.Type); + + public override OperationValue VisitNameOf(INameOfOperation operation, object? argument) + => operation.ConstantValue is { HasValue: true, Value: string name } + ? new OperationValue.NameOf(name) + : new OperationValue.Unrecognized(operation.Kind, operation.Syntax.ToString()); + + public override OperationValue VisitBinaryOperator(IBinaryOperation operation, object? argument) + => operation.ConstantValue is { HasValue: true } cv + ? new OperationValue.Constant(cv.Value, operation.Type) + : new OperationValue.Unrecognized(operation.Kind, operation.Syntax.ToString()); + + public override OperationValue VisitParenthesized(IParenthesizedOperation operation, object? argument) + => operation.Operand.Accept(this, argument)!; + + public override OperationValue VisitConditional(IConditionalOperation operation, object? argument) + => operation.ConstantValue is { HasValue: true } cv + ? new OperationValue.Constant(cv.Value, operation.Type) + : new OperationValue.Unrecognized(operation.Kind, operation.Syntax.ToString()); + + public override OperationValue VisitInterpolatedString(IInterpolatedStringOperation operation, object? argument) + => operation.ConstantValue is { HasValue: true } cv + ? new OperationValue.Constant(cv.Value, operation.Type) + : new OperationValue.Unrecognized(operation.Kind, operation.Syntax.ToString()); + + public override OperationValue VisitDefaultValue(IDefaultValueOperation operation, object? argument) + => operation.ConstantValue is { HasValue: true } cv + ? new OperationValue.Constant(cv.Value, operation.Type) + : new OperationValue.Unrecognized(operation.Kind, operation.Syntax.ToString()); + + public override OperationValue VisitConversion(IConversionOperation operation, object? argument) + => operation.Operand.Accept(this, argument)!; + + public override OperationValue VisitArrayCreation(IArrayCreationOperation operation, object? argument) + { + if (operation.Initializer is not { } init) + return new OperationValue.Unrecognized(operation.Kind, operation.Syntax.ToString()); + + var items = init.ElementValues + .Select(e => e.Accept(this, argument)!) + .ToImmutableArray(); + return new OperationValue.ArrayElements(items); + } + + public override OperationValue DefaultVisit(IOperation operation, object? argument) + => new OperationValue.Unrecognized(operation.Kind, operation.Syntax.ToString()); +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/RazorSourceProvider.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/RazorSourceProvider.cs new file mode 100644 index 0000000..1a02242 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/RazorSourceProvider.cs @@ -0,0 +1,92 @@ +using System.Xml.Linq; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Compiles .razor and .cshtml files to generated C# and produces +/// s with s for accurate line resolution. +/// +internal static class RazorSourceProvider +{ + public static IReadOnlyList GetDocuments(string projectDir) + { + var rootNamespace = ResolveRootNamespace(projectDir); + var docs = new List(); + + foreach (var path in EnumerateRazorFiles(projectDir)) + { + var generated = CompileRazorToCSharp(path, projectDir, rootNamespace); + if (generated is null) + continue; + + var tree = CSharpSyntaxTree.ParseText(generated, path: path); + var lineMap = BuildLineMap(tree.GetRoot()); + docs.Add(new SourceDocument(tree, path, projectDir, new LineMap(lineMap))); + } + + return docs; + } + + private static IEnumerable EnumerateRazorFiles(string root) => + new[] { "*.razor", "*.cshtml" } + .SelectMany(ext => Directory.EnumerateFiles(root, ext, SearchOption.AllDirectories)) + .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") + && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) + .Order(); + + private static string? CompileRazorToCSharp(string filePath, string projectRoot, string rootNamespace) + { + var fileSystem = RazorProjectFileSystem.Create(projectRoot); + var engine = RazorProjectEngine.Create( + RazorConfiguration.Default, + fileSystem, + builder => builder.SetRootNamespace(rootNamespace)); + + var relativePath = "/" + Path.GetRelativePath(projectRoot, filePath).Replace('\\', '/'); + var item = fileSystem.GetItem(relativePath, fileKind: null); + if (item is null) + return null; + + var codeDocument = engine.Process(item); + return codeDocument.GetCSharpDocument().GeneratedCode; + } + + private static string ResolveRootNamespace(string projectRoot) + { + var csproj = Directory.EnumerateFiles(projectRoot, "*.csproj", SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (csproj is not null) + { + var doc = XDocument.Load(csproj); + var ns = doc.Descendants("RootNamespace").FirstOrDefault()?.Value; + if (!string.IsNullOrWhiteSpace(ns)) + return ns; + } + return Path.GetFileName(projectRoot); + } + + private static List<(int GeneratedLine, int OriginalLine)> BuildLineMap(Microsoft.CodeAnalysis.SyntaxNode root) + { + var map = new List<(int GeneratedLine, int OriginalLine)>(); + + foreach (var trivia in root.DescendantTrivia()) + { + if (!trivia.IsKind(SyntaxKind.LineDirectiveTrivia) || !trivia.HasStructure) + continue; + + if (trivia.GetStructure() is not LineDirectiveTriviaSyntax directive) + continue; + + if (!int.TryParse(directive.Line.Text, out var originalLine)) + continue; + + var generatedLine = trivia.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + map.Add((generatedLine, originalLine)); + } + + return map.OrderBy(m => m.GeneratedLine).ToList(); + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/RoslynScanner.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/RoslynScanner.cs new file mode 100644 index 0000000..2d6c83d --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/RoslynScanner.cs @@ -0,0 +1,159 @@ +using Basic.Reference.Assemblies; +using BlazorLocalization.Extractor.Domain; +using BlazorLocalization.Extractor.Ports; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Orchestrates a Roslyn-based scan of C# source trees. +/// Creates a compilation, resolves scan targets, runs the walker, +/// and feeds results through to produce domain types. +/// +internal static class RoslynScanner +{ + /// + /// Scans the given source documents and returns a port-compliant . + /// Also returns for the inspect command. + /// + public static RoslynScannerOutput Scan(IReadOnlyList documents) + { + var metadataRefs = BuildReferences(); + var trees = documents.Select(d => d.Tree).ToList(); + + var compilation = CSharpCompilation.Create( + "Scan", + trees, + metadataRefs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var target = ScanTargets.ResolveLocalizer(compilation); + var translationAttr = ScanTargets.ResolveTranslationAttribute(compilation); + var definitionFactory = ScanTargets.ResolveDefinitionFactory(compilation); + + var diagnostics = new List(); + if (target is null) + { + diagnostics.Add(new ScanDiagnostic( + DiagnosticLevel.Error, + "IStringLocalizer type not found in compilation references. " + + "Ensure Microsoft.Extensions.Localization is referenced.")); + return new RoslynScannerOutput([], [], diagnostics, []); + } + + var allCalls = new List(); + var definitions = new List(); + var references = new List(); + + foreach (var doc in documents) + { + var tree = doc.Tree; + var file = new SourceFilePath(doc.OriginalFilePath, doc.ProjectDir); + + var model = compilation.GetSemanticModel(tree); + var walker = new LocalizerOperationWalker(target, definitionFactory); + + // Walk method bodies + foreach (var methodSyntax in tree.GetRoot().DescendantNodes().OfType()) + { + var body = methodSyntax switch + { + MethodDeclarationSyntax m => (SyntaxNode?)m.Body ?? m.ExpressionBody, + ConstructorDeclarationSyntax c => (SyntaxNode?)c.Body ?? c.ExpressionBody, + _ => null + }; + if (body is null) continue; + + var blockOp = model.GetOperation(body); + if (blockOp is null) continue; + walker.Visit(blockOp); + } + + // Walk top-level statements (minimal API / global statements) + foreach (var globalStmt in tree.GetRoot().DescendantNodes().OfType()) + { + var op = model.GetOperation(globalStmt.Statement); + if (op is not null) walker.Visit(op); + } + + // Walk property bodies (for Razor component properties and expression-bodied properties) + foreach (var propSyntax in tree.GetRoot().DescendantNodes().OfType()) + { + if (propSyntax.ExpressionBody is not null) + { + var op = model.GetOperation(propSyntax.ExpressionBody); + if (op is not null) walker.Visit(op); + } + else if (propSyntax.AccessorList is not null) + { + foreach (var accessor in propSyntax.AccessorList.Accessors) + { + var body = (SyntaxNode?)accessor.Body ?? accessor.ExpressionBody; + if (body is null) continue; + var op = model.GetOperation(body); + if (op is not null) walker.Visit(op); + } + } + } + + // Walk field initializers (for DefineXxx static factory calls) + foreach (var fieldSyntax in tree.GetRoot().DescendantNodes().OfType()) + { + foreach (var variable in fieldSyntax.Declaration.Variables) + { + if (variable.Initializer is null) continue; + var op = model.GetOperation(variable.Initializer); + if (op is not null) walker.Visit(op); + } + } + + // Convert walker hits to ScannedCallSites + foreach (var (op, symbol, arguments) in walker.Results) + { + var generatedLine = op.Syntax.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + var line = doc.ResolveOriginalLine(generatedLine); + var callSite = CallSiteBuilder.Build(op, symbol, arguments, file, line); + allCalls.Add(callSite); + + // Interpret into domain types + var (def, refr) = CallInterpreter.Interpret(callSite, file); + if (def is not null) definitions.Add(def); + if (refr is not null) references.Add(refr); + } + + // Walk enum members for [Translation] attributes + if (translationAttr is not null) + { + foreach (var enumMember in tree.GetRoot().DescendantNodes().OfType()) + { + var fieldSymbol = model.GetDeclaredSymbol(enumMember); + if (fieldSymbol is null) continue; + + var generatedLine = enumMember.GetLocation().GetLineSpan().StartLinePosition.Line + 1; + var line = doc.ResolveOriginalLine(generatedLine); + var (def, refr) = EnumAttributeInterpreter.TryInterpret(fieldSymbol, translationAttr, file, line); + if (def is not null) definitions.Add(def); + if (refr is not null) references.Add(refr); + } + } + } + + return new RoslynScannerOutput(definitions, references, diagnostics, allCalls); + } + + private static List BuildReferences() + { + var refs = new List(Net100.References.All); + + refs.Add(MetadataReference.CreateFromFile( + typeof(Microsoft.Extensions.Localization.IStringLocalizer).Assembly.Location)); + refs.Add(MetadataReference.CreateFromFile( + typeof(Microsoft.AspNetCore.Components.ComponentBase).Assembly.Location)); + refs.Add(MetadataReference.CreateFromFile( + ExtensionsContract.ExtensionsAssemblyLocation)); + + return refs; + } +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/RoslynScannerOutput.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/RoslynScannerOutput.cs new file mode 100644 index 0000000..57ddce4 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/RoslynScannerOutput.cs @@ -0,0 +1,14 @@ +using BlazorLocalization.Extractor.Domain; +using BlazorLocalization.Extractor.Ports; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Roslyn adapter's implementation of . +/// Also carries adapter-specific for the inspect command. +/// +internal sealed record RoslynScannerOutput( + IReadOnlyList Definitions, + IReadOnlyList References, + IReadOnlyList Diagnostics, + IReadOnlyList RawCalls) : IScannerOutput; diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/ScanTargets.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/ScanTargets.cs new file mode 100644 index 0000000..741aa43 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/ScanTargets.cs @@ -0,0 +1,23 @@ +using Microsoft.CodeAnalysis; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// Central registry of types we scan for. +/// Resolve once per compilation via / . +/// +internal static class ScanTargets +{ + /// + /// Resolves the scan targets against the compilation. + /// Returns null for any type not found in compilation references. + /// + public static INamedTypeSymbol? ResolveLocalizer(Compilation compilation) + => compilation.GetTypeByMetadataName(ExtensionsContract.MetaIStringLocalizer); + + public static INamedTypeSymbol? ResolveTranslationAttribute(Compilation compilation) + => compilation.GetTypeByMetadataName(ExtensionsContract.MetaTranslationAttribute); + + public static INamedTypeSymbol? ResolveDefinitionFactory(Compilation compilation) + => compilation.GetTypeByMetadataName(ExtensionsContract.MetaTranslationDefinitions); +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/ScannedCallSite.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/ScannedCallSite.cs new file mode 100644 index 0000000..80f4610 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/ScannedCallSite.cs @@ -0,0 +1,48 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// How the IStringLocalizer was accessed at this call site. +/// +internal enum CallKind +{ + /// localizer["key"] + Indexer, + + /// localizer.GetString("key") or similar instance method. + MethodCall, + + /// localizer.Translation("key", "msg") β€” extension method, returns a builder. + ExtensionMethod +} + +/// +/// A single extracted argument from a call site, with its resolved value. +/// +internal sealed record ScannedArgument( + int Position, + string? ParameterName, + OperationValue Value); + +/// +/// A raw call site detected by the walker. Adapter-internal β€” never crosses the port boundary. +/// The inspect command consumes this directly for diagnostic detail. +/// +internal sealed record ScannedCallSite( + string MethodName, + string ContainingTypeName, + CallKind CallKind, + SourceFilePath File, + int Line, + IReadOnlyList Arguments, + IReadOnlyList? Chain) +{ + /// + /// Whether this call provides source text (Definition) or just uses a key (Reference). + /// Definitions: .Translation() returning a builder, DefineXxx() factories. + /// References: indexer access, GetString(), or anything without a builder return type. + /// + public bool IsDefinition => CallKind == CallKind.ExtensionMethod && + (MethodName == "Translation" || MethodName.StartsWith("Define", StringComparison.Ordinal)); +} diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/SourceDocument.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/SourceDocument.cs new file mode 100644 index 0000000..cd19a66 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/SourceDocument.cs @@ -0,0 +1,29 @@ +using Microsoft.CodeAnalysis; + +namespace BlazorLocalization.Extractor.Adapters.Roslyn; + +/// +/// A syntax tree paired with its origin metadata. +/// For plain C# files, is null. +/// For Razor-generated C#, maps generated lines back to the .razor source. +/// +internal sealed record SourceDocument( + SyntaxTree Tree, + string OriginalFilePath, + string ProjectDir, + LineMap? LineMap) +{ + /// + /// Resolves a 1-based generated line number to the original source line. + /// For plain C# files (no ), returns the line as-is. + /// For Razor files, maps through #line directives. + /// + public int ResolveOriginalLine(int generatedLine) + { + if (LineMap is null) + return generatedLine; + + var mapped = LineMap.MapToOriginalLine(generatedLine); + return mapped > 0 ? mapped : generatedLine; + } +} diff --git a/src/BlazorLocalization.Extractor/Application/LocaleDiscovery.cs b/src/BlazorLocalization.Extractor/Application/LocaleDiscovery.cs new file mode 100644 index 0000000..fae4697 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Application/LocaleDiscovery.cs @@ -0,0 +1,47 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Application; + +/// +/// Discovers available locales from inline .For() translations across entries. +/// Single source of truth for locale enumeration β€” used by commands, renderers, and exporters. +/// +public static class LocaleDiscovery +{ + /// + /// Returns a sorted, deduplicated list of locale codes present in . + /// When is provided, only matching locales are returned. + /// + public static IReadOnlyList DiscoverLocales( + IReadOnlyList entries, + HashSet? localeFilter = null) + { + var locales = entries + .Where(e => e.InlineTranslations is not null) + .SelectMany(e => e.InlineTranslations!.Keys) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Order(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (localeFilter is not null) + locales = locales.Where(l => localeFilter.Contains(l)).ToList(); + + return locales; + } + + /// + /// Rewrites entries so that the inline translation for + /// becomes the source text. Entries without a translation for that locale are excluded. + /// Used for per-locale file export and single-locale stdout output. + /// + public static IReadOnlyList EntriesForLocale( + IReadOnlyList entries, + string locale) + { + return entries + .Where(e => e.InlineTranslations is not null + && e.InlineTranslations.ContainsKey(locale)) + .Select(e => e with { SourceText = e.InlineTranslations![locale] }) + .ToList(); + } +} diff --git a/src/BlazorLocalization.Extractor/Application/ProjectScanResult.cs b/src/BlazorLocalization.Extractor/Application/ProjectScanResult.cs new file mode 100644 index 0000000..15571ce --- /dev/null +++ b/src/BlazorLocalization.Extractor/Application/ProjectScanResult.cs @@ -0,0 +1,20 @@ +using BlazorLocalization.Extractor.Domain; +using BlazorLocalization.Extractor.Ports; + +namespace BlazorLocalization.Extractor.Application; + +/// +/// The result of scanning a single project through the full pipeline. +/// Carries everything any driving adapter (CLI, API, etc.) needs. +/// +/// Directory name of the scanned project. +/// Merged translations, conflicts, and invalid entries. +/// +/// The raw scanner outputs preserved alongside the merge result. +/// Driving adapters can access diagnostics via SelectMany(o => o.Diagnostics), +/// and adapter-specific data (e.g. raw calls) via pattern matching on concrete types. +/// +public sealed record ProjectScanResult( + string ProjectName, + MergeResult MergeResult, + IReadOnlyList ScannerOutputs); diff --git a/src/BlazorLocalization.Extractor/Application/ProjectScanner.cs b/src/BlazorLocalization.Extractor/Application/ProjectScanner.cs new file mode 100644 index 0000000..f81bdf3 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Application/ProjectScanner.cs @@ -0,0 +1,42 @@ +using BlazorLocalization.Extractor.Adapters.Resx; +using BlazorLocalization.Extractor.Adapters.Roslyn; +using BlazorLocalization.Extractor.Ports; + +namespace BlazorLocalization.Extractor.Application; + +/// +/// Orchestrates a full scan of a single project directory: +/// source providers β†’ Roslyn scanner + Resx scanner β†’ TranslationPipeline β†’ result. +/// The single entry point shared by all driving adapters (CLI, API, etc.). +/// +public static class ProjectScanner +{ + /// + /// Scans end-to-end and returns the merged result + /// along with preserved scanner outputs for downstream access. + /// + public static ProjectScanResult Scan(string projectDir, CancellationToken cancellationToken = default) + { + var projectName = Path.GetFileName(projectDir); + + // Collect source documents from both providers + var csDocs = CSharpFileProvider.GetDocuments(projectDir); + var razorDocs = RazorSourceProvider.GetDocuments(projectDir); + var allDocs = csDocs.Concat(razorDocs).ToList(); + + cancellationToken.ThrowIfCancellationRequested(); + + // Run scanners + var roslynOutput = RoslynScanner.Scan(allDocs); + + cancellationToken.ThrowIfCancellationRequested(); + + var resxOutput = ResxScanner.Scan(projectDir); + + // Merge through pipeline + IScannerOutput[] scannerOutputs = [roslynOutput, resxOutput]; + var mergeResult = TranslationPipeline.Run(scannerOutputs); + + return new ProjectScanResult(projectName, mergeResult, scannerOutputs); + } +} diff --git a/src/BlazorLocalization.Extractor/Application/TranslationPipeline.cs b/src/BlazorLocalization.Extractor/Application/TranslationPipeline.cs new file mode 100644 index 0000000..6b0b48d --- /dev/null +++ b/src/BlazorLocalization.Extractor/Application/TranslationPipeline.cs @@ -0,0 +1,131 @@ +using BlazorLocalization.Extractor.Domain; +using BlazorLocalization.Extractor.Ports; + +namespace BlazorLocalization.Extractor.Application; + +/// +/// The application service. Takes N scanner outputs, merges by key, detects conflicts, +/// and computes cross-reference status. Scanner-agnostic β€” it only speaks domain types. +/// +public static class TranslationPipeline +{ + /// + /// Merges all scanner outputs into a single . + /// + public static MergeResult Run(IReadOnlyList scannerOutputs) + { + var allDefinitions = scannerOutputs.SelectMany(s => s.Definitions).ToList(); + var allReferences = scannerOutputs.SelectMany(s => s.References).ToList(); + var allDiagnostics = scannerOutputs.SelectMany(s => s.Diagnostics).ToList(); + + return Merge(allDefinitions, allReferences, allDiagnostics); + } + + private static MergeResult Merge( + List definitions, + List references, + List diagnostics) + { + var builders = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var def in definitions) + { + if (!builders.TryGetValue(def.Key, out var builder)) + { + builder = new MergeBuilder(); + builders[def.Key] = builder; + } + + builder.AddDefinition(def); + } + + foreach (var reference in references) + { + if (!builders.TryGetValue(reference.Key, out var builder)) + { + builder = new MergeBuilder(); + builders[reference.Key] = builder; + } + + builder.AddReference(reference); + } + + var entries = new List(); + var conflicts = new List(); + var invalid = new List(); + + foreach (var (key, builder) in builders.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(key)) + { + invalid.Add(new InvalidEntry(key, "Empty key", builder.DefinitionSites)); + continue; + } + + entries.Add(new MergedTranslation( + key, + builder.SourceText, + builder.DefinitionSites, + builder.ReferenceSites, + builder.MergedInlineTranslations, + builder.IsKeyLiteral)); + + if (builder.HasConflict) + conflicts.Add(new KeyConflict(key, builder.ConflictingValues)); + } + + return new MergeResult(entries, conflicts, invalid, diagnostics); + } + + private sealed class MergeBuilder + { + private TranslationSourceText? _sourceText; + private readonly List _definitionSites = []; + private readonly List _referenceSites = []; + private readonly Dictionary> _valueMap = new(); + private Dictionary? _inlineMap; + private bool _allKeysLiteral = true; + + public TranslationSourceText? SourceText => _sourceText; + public List DefinitionSites => _definitionSites; + public List ReferenceSites => _referenceSites; + public bool IsKeyLiteral => _allKeysLiteral; + + public IReadOnlyDictionary? MergedInlineTranslations => + _inlineMap is { Count: > 0 } ? _inlineMap : null; + + public void AddDefinition(TranslationDefinition def) + { + _definitionSites.Add(def.Site); + _sourceText ??= def.SourceText; // first-seen wins + + if (_valueMap.TryGetValue(def.SourceText, out var sites)) + sites.Add(def.Site); + else + _valueMap[def.SourceText] = [def.Site]; + + MergeInlineTranslations(def.InlineTranslations); + } + + public void AddReference(TranslationReference reference) + { + _referenceSites.Add(reference.Site); + if (!reference.IsLiteral) + _allKeysLiteral = false; + } + + private void MergeInlineTranslations(IReadOnlyDictionary? inlineTranslations) + { + if (inlineTranslations is null) return; + + _inlineMap ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (locale, text) in inlineTranslations) + _inlineMap.TryAdd(locale, text); // first-seen wins + } + + public bool HasConflict => _valueMap.Count > 1; + + public IReadOnlyList ConflictingValues => + _valueMap.Select(kvp => new ConflictingValue(kvp.Key, kvp.Value)).ToList(); + } +} diff --git a/src/BlazorLocalization.Extractor/BlazorLocalization.Extractor.csproj b/src/BlazorLocalization.Extractor/BlazorLocalization.Extractor.csproj index 944b4aa..72c39f4 100644 --- a/src/BlazorLocalization.Extractor/BlazorLocalization.Extractor.csproj +++ b/src/BlazorLocalization.Extractor/BlazorLocalization.Extractor.csproj @@ -7,10 +7,14 @@ enable true blazor-loc - 10.1.3 - $(NoWarn);NU1510 + 10.2.0 + $(NoWarn);NU1510;RS1041;RS1036;RS2008 + + + + BlazorLocalization.Extractor Scans .razor, .cs, and .resx files and exports translation strings as PO, i18next JSON, or generic JSON for Crowdin, Lokalise, or any platform. @@ -26,8 +30,8 @@ - - + + diff --git a/src/BlazorLocalization.Extractor/CONTRIBUTING.md b/src/BlazorLocalization.Extractor/CONTRIBUTING.md index f296334..6834abc 100644 --- a/src/BlazorLocalization.Extractor/CONTRIBUTING.md +++ b/src/BlazorLocalization.Extractor/CONTRIBUTING.md @@ -8,25 +8,109 @@ A Roslyn-based CLI tool (`blazor-loc`) that scans `.razor`, `.cs`, and `.resx` f It's a convenience tool β€” saves context-switching between code and translation platforms. Not a runtime dependency. +## Who Uses This and Why + +Everything this tool produces is for **end users** β€” developers working on their application. Not for maintainers of this repository. Every output format, every CLI flag, every table column must be evaluated from the user's perspective: *does this help them accomplish what they came here to do?* + +There are two commands, each serving a distinct purpose: + +### `extract` β€” Send strings to translators + +The user runs `blazor-loc extract ./src -f i18next -o ./translations` because they want to upload their translation strings to Crowdin, Lokalise, or another translation platform. The output is a file that a translator (or translation platform) will consume. + +**What matters to the translator:** The translation key, the source text (what to translate), and *context* β€” where in the application this string is used, so the translator understands its meaning. "Save" on a toolbar means something different than "Save" on a banking page. + +**What doesn't matter to the translator:** Which `.resx` file the string was imported from, what line number it sits on in an XML file, or any other storage metadata. RESX files are an implementation detail of how the developer stored strings β€” the translator neither knows nor cares. + +This means: in the generic JSON export format (`-f json`), the `sources` array is **translator context** β€” only code call sites (`.razor`, `.cs` files with their line numbers and method context). RESX file locations are excluded because they don't help the translator understand meaning. + +### `inspect` β€” Translation health audit + +The user runs `blazor-loc inspect ./src` because they need to answer questions about their own codebase: + +- *"Did I wire all my translations correctly?"* +- *"Are there keys in my RESX files that nothing in my code references anymore?"* +- *"Did I typo a key name somewhere?"* +- *"Are there gaps in my Danish or Spanish coverage?"* +- *"Am I duplicating the same translation across multiple files?"* + +In a codebase with 900+ translated strings across 10 languages, these questions are humanly impossible to answer by visiting every call site and cross-referencing every RESX file by hand. The inspect command provides a **complete overview** in seconds. + +This serves two kinds of users equally: + +1. **Vanilla Microsoft localization users** (using `IStringLocalizer["key"]` + `.resx` files, never adopted `.Translation()`). They get a full inventory of their call sites vs RESX entries, cross-referenced to find orphans, missing keys, and typos. They may never intend to adopt the Extensions framework β€” that's fine. The tool still helps them audit their translation wiring. + +2. **BlazorLocalization.Extensions adopters** (using `.Translation()`, `.For()`, reusable definitions). They get a complete overview of all translation entries, duplicate detection, culture coverage gaps, and conflict warnings. + +The inspect JSON output (`--json` or piped) is a **dumb 1:1 conversion** of what the console tables show. It exists so users can pipe to `jq`, feed into other tools, or do ad-hoc analysis. It has no special purpose beyond being the machine-readable mirror of the console output. + +### Design litmus test + +Before adding or changing any output, ask: + +1. **Who will read this?** A translator? A developer auditing their codebase? A CI pipeline? +2. **Does this help them do their job?** If the answer is "it's technically accurate but nobody asked for it" β€” don't add it. +3. **Would a non-technical translator understand why this is here?** (For extract output.) +4. **Would a developer skimming 900 entries find this useful or noise?** (For inspect output.) + +If you can't answer these questions, you're making a technical decision disguised as a feature. Stop and think about the user's journey first. + +## Definitions vs References + +The extractor distinguishes two fundamentally different things about a translation key: + +- **Definition** β€” where the source text is authored. A `TranslationDefinition` carries the key, source text, form, and one or more `DefinitionSite`s. +- **Reference** β€” where the key is consumed at runtime. A `TranslationReference` carries the key and one or more `ReferenceSite`s. + +The `DefinitionKind` enum on each `DefinitionSite` records *how* the source text was defined: + +| Kind | Example | Creates Definition? | Creates Reference? | +|------|---------|--------------------|--------------------| +| `InlineTranslation` | `Loc.Translation("Key", "Welcome")` | Yes | Yes β€” the expression runs at render time | +| `ReusableDefinition` | `TranslationDefinitions.DefineSimple("Key", "Save")` | Yes | No β€” a field initializer is storage, not usage | +| `EnumAttribute` | `[Translation("Delayed")] Delayed` | Yes | No β€” a declaration, not consumption | +| `ResourceFile` | `.resx` entry with key-value | Yes | No β€” a data file, not a call site | + +This matters for the inspect command's **Usage** and **Source** columns: + +- **Usage** shows only genuine `ReferenceSite`s β€” places in `*.razor`/`*.cs` where the key is consumed by `Loc["Key"]`, `Loc.Translation(definition: x)`, or `Loc.Display(enum)`. +- **Source** shows the `DefinitionKind` label (e.g. `DefineSimple()`, `[Translation]`, `.Translation()`) plus an indented clickable file reference to where the source text is defined. + +**Status** follows from this: a definition with a real reference (or an `InlineTranslation`, which is inherently both) is `Resolved`. A definition with no reference is `Review` β€” it may be unused. A reference with no definition is `Missing`. + +### Why only .resx? + +The extractor scans `.resx` files as the only file-based translation format. It does not scan JSON, PO, or other translation files β€” even though those are valid provider formats at runtime. + +This is deliberate: the extractor is a **migration aid**. Its purpose is to help teams transition from the `ResourceManager`/RESX infrastructure onto BlazorLocalization's provider-based architecture. RESX is scoped because that's what people are migrating *from*. If we tried to support every possible provider/data source, the tool would have no natural boundary. + ## Hexagonal Architecture -Four folders, strict dependency direction: +Five layers, strict dependency direction: ``` -Domain/ ← Core ring: pure types, enums, business rules. ZERO external dependencies. -Scanning/ ← Input adapter: filesystem & Roslyn β†’ domain types. Depends on Domain only. -Exporters/ ← Output adapter: domain types β†’ file formats. Depends on Domain only. -Cli/ ← Presentation: Spectre.Console commands, wizard, renderers. Depends on everything. +Domain/ ← Core ring: pure types, enums, business rules. ZERO external dependencies. +Ports/ ← Contracts: IScannerOutput, ScanDiagnostic. Depends on Domain only. +Application/ ← Orchestration: pipeline, merge, locale discovery. Depends on Domain + Ports. +Adapters/Roslyn/ ← Input adapter: Roslyn IOperation walking β†’ domain types. +Adapters/Resx/ ← Input adapter: .resx XML parsing β†’ domain types. +Adapters/Export/ ← Output adapter: domain types β†’ file formats (i18next JSON, PO, generic JSON). +Adapters/Cli/ ← Driving adapter: Spectre.Console commands, wizard, renderers. ``` ### Dependency Rule | Layer | May reference | Must NOT reference | |-------|--------------|-------------------| -| `Domain/` | Nothing (BCL only) | Scanning, Exporters, Cli, Spectre, Roslyn | -| `Scanning/` | Domain | Exporters, Cli, Spectre | -| `Exporters/` | Domain | Scanning, Cli, Spectre | -| `Cli/` | Domain, Scanning, Exporters, Spectre | β€” | +| `Domain/` | Nothing (BCL only) | Ports, Application, Adapters, Spectre, Roslyn | +| `Ports/` | Domain | Application, Adapters, Spectre, Roslyn | +| `Application/` | Domain, Ports | Adapters (except `ProjectScanner` β€” see note), Spectre | +| `Adapters/Roslyn/` | Domain, Ports | other Adapters, Spectre | +| `Adapters/Resx/` | Domain, Ports | other Adapters, Spectre, Roslyn | +| `Adapters/Export/` | Domain | Ports, Application, other Adapters, Spectre, Roslyn | +| `Adapters/Cli/` | Domain, Ports, Application, Adapters, Spectre | β€” | + +**Note:** `ProjectScanner` in Application directly references the concrete Roslyn and Resx adapters to compose them. This is a pragmatic trade-off β€” it acts as the composition root. If a second driving adapter (e.g. REST API) is added, this is the one class to extract into a shared composition root. If you're adding a `using` that violates this table, the type is in the wrong layer. @@ -34,23 +118,26 @@ If you're adding a `using` that violates this table, the type is in the wrong la | You're adding... | Put it in... | |-----------------|-------------| -| New export format | `Domain/ExportFormat.cs` (enum member) + `Exporters/` (exporter class) + `ExporterFactory` (mapping). The exhaustive `switch` in `ExporterFactory.Create()` produces a compiler warning if you forget. | -| New source type (e.g. `.cshtml`) | `Scanning/Providers/` β€” implement `ISourceProvider.GetDocuments()` + wire in `ProjectScanner` | +| New export format | `Domain/ExportFormat.cs` (enum member) + `Adapters/Export/` (exporter class) + `ExporterFactory` (mapping). The exhaustive `switch` in `ExporterFactory.Create()` produces a compiler warning if you forget. | +| New source type (e.g. `.cshtml`) | `Adapters/Roslyn/` β€” implement a new source provider like `CSharpFileProvider` / `RazorSourceProvider`, wire in `ProjectScanner` | +| New scanner (e.g. XLIFF) | `Adapters//` β€” implement `IScannerOutput` from `Ports/`, wire in `ProjectScanner` | | New domain type | `Domain/` β€” must have zero external deps. Sealed record. | -| New CLI option | `Cli/Commands/` (settings property) + `Cli/InteractiveWizard.cs` (wizard prompt) | -| New CLI command | `Cli/Commands/` (command + settings classes) + `Domain/Requests/` (request value object) + `Program.cs` (registration) | +| New CLI option | `Adapters/Cli/Commands/` (settings property) + `Adapters/Cli/InteractiveWizard.cs` (wizard prompt) | +| New CLI command | `Adapters/Cli/Commands/` (command + settings classes + request value object) + `Program.cs` (registration) | | New domain enum | `Domain/` β€” with `[Description]` from `System.ComponentModel` if user-facing (read by both `--help` and the wizard automatically) | -| New validation guard | `Domain/Requests/XxxRequest.Validate()` β€” pure, returns error list. Never in commands. | -| Shared scanning logic | `Scanning/ProjectScanner.cs` β€” single pipeline for providers β†’ scanner β†’ resx β†’ merge. | -| Shared locale logic | `Domain/Entries/LocaleDiscovery.cs` β€” locale enumeration, filtering, per-locale entry rewriting. | +| New definition mechanism | `Domain/DefinitionKind.cs` (enum member) + producing adapter + `TranslationEntryRenderer.FormatSource()` (display) | +| Shared scanning logic | `Application/ProjectScanner.cs` β€” single pipeline for providers β†’ scanner β†’ resx β†’ merge. | +| Shared locale logic | `Application/LocaleDiscovery.cs` β€” locale enumeration, filtering, per-locale entry rewriting. | ## Anti-Patterns -- **Enums in `Cli/`** β€” If it defines *what* the tool does (not *how* the user interacts), it belongs in `Domain/`. -- **Infrastructure in presentation** β€” Filesystem scanning, exporter instantiation, project discovery are not CLI concerns. -- **Commands with business logic** β€” Commands orchestrate; domain types enforce rules (e.g. `MergedTranslationEntry.FromRaw()` owns conflict detection). -- **Duplicated pure logic in commands** β€” Path relativization, locale discovery, project resolution, and validation belong in `Domain/` or `Scanning/`, not copy-pasted across commands. -- **Validation in commands** β€” Guards and input validation belong in `Domain/Requests/XxxRequest.Validate()`. Commands only build the request and check the result. +- **Enums in `Adapters/Cli/`** β€” If it defines *what* the tool does (not *how* the user interacts), it belongs in `Domain/`. +- **Infrastructure in presentation** β€” Filesystem scanning, exporter instantiation, project discovery are not CLI concerns. Scanning lives in `Application/`, export logic in `Adapters/Export/`. +- **Commands with business logic** β€” Commands orchestrate; domain types and the pipeline enforce rules. +- **Duplicated pure logic in commands** β€” Path relativization, locale discovery, project resolution belong in `Domain/` or `Application/`, not copy-pasted across commands. +- **Presentation markup in the Domain** β€” Spectre.Console rendering (e.g. `[link=...][cyan]...[/][/]`) belongs in `Adapters/Cli/Rendering/`, not on domain types. `SourceFilePath.Display(PathStyle)` (plain text) is fine in Domain; the markup wrapper `SourceFileMarkup.DisplayMarkup()` is a CLI extension method. +- **Parsing `DefinitionSite.Context` strings for rendering logic** β€” Use `DefinitionSite.Kind` (the `DefinitionKind` enum) as the machine-readable discriminator. `Context` is supplementary human-readable detail, never a switch target. +- **Fake references** β€” Don't create a `TranslationReference` at the same site as a `TranslationDefinition` just to make the entry appear "Resolved". Only `InlineTranslation` (`.Translation()` in code) genuinely defines AND uses in one expression. `ReusableDefinition`, `EnumAttribute`, and `ResourceFile` produce definitions only. - **Manual validation of enum CLI options** β€” Spectre.Console.Cli validates enum-typed properties automatically. Don't add string checks. - **Inline dictionaries in wizard for enum options** β€” Use `PromptEnum()` which reads `[Description]` attributes via reflection. Prevents wizard/enum drift. @@ -62,131 +149,98 @@ This is the most important section in this file. Every Scanner bug we've had tra ### Background -The Scanner interprets fluent builder chains from `BlazorLocalization.Extensions`. It recognises two entry points: `IStringLocalizer.Translation()` calls (inline usage) and `TranslationDefinitions.DefineSimple()` / `DefinePlural()` / `DefineSelect()` / `DefineSelectPlural()` factory calls (reusable definitions). The Extractor has a direct `ProjectReference` to Extensions, so `typeof()`, `nameof()`, and reflection all work against the real assembly at compile time and runtime. Roslyn's compilation also loads the same assembly via `MetadataReference.CreateFromFile()`. +The Roslyn scanner interprets fluent builder chains from `BlazorLocalization.Extensions`. It recognises two entry points: `IStringLocalizer.Translation()` calls (inline usage) and `TranslationDefinitions.DefineSimple()` / `DefinePlural()` / `DefineSelect()` / `DefineSelectPlural()` factory calls (reusable definitions). The scanner walks Roslyn `IOperation` trees via `LocalizerOperationWalker` to find these calls, then `CallInterpreter` maps the captured arguments to Domain types. -Both lenses β€” reflection and Roslyn β€” describe the same binary on disk. `typeof(PluralBuilder).FullName!` is the bridge key that connects them. +The Extractor has a direct `ProjectReference` to Extensions. All references to Extensions types, methods, and parameters are consolidated in a single file: **`Adapters/Roslyn/ExtensionsContract.cs`**. This file uses `typeof().FullName!` for metadata names, `nameof()` for method and property names, and documented string constants (with `` back-links) for the few things C# can't verify at compile time (C#14 extension block members, parameter names). -### Rules - -**1. No string literals for identity.** +### ExtensionsContract.cs β€” the single coupling surface -Every type, method, and parameter the Scanner matches against must be derived from the Extensions assembly: +Every reference from the Extractor to the Extensions project flows through this file: -| What | How | Guarantor | -|------|-----|-----------| -| Builder types | `typeof(PluralBuilder)` β†’ `compilation.GetTypeByMetadataName(typeof(PluralBuilder).FullName!)` | Compiler β€” rename breaks build | -| Definition types | `typeof(SimpleDefinition)` β†’ `compilation.GetTypeByMetadataName(typeof(SimpleDefinition).FullName!)` | Compiler β€” rename breaks build | -| Methods | `nameof(PluralBuilder.For)` β†’ `roslynType.GetMembers(nameof(...))` | Compiler β€” rename breaks build | -| Parameter names | `typeof(PluralBuilder).GetMethod(nameof(PluralBuilder.For))!.GetParameters()[0].Name` | Reflection β€” reads the real assembly, auto-tracks renames | +| What | Technique | Guarantor | +|------|-----------|-----------| +| Metadata names (for `GetTypeByMetadataName`) | `typeof(T).FullName!` | Compiler β€” rename/move breaks build | +| Factory method names | `nameof(TranslationDefinitions.DefineSimple)` | Compiler β€” rename breaks build | +| Builder chain methods | `nameof(SimpleBuilder.For)`, `nameof(PluralBuilder.One)`, etc. | Compiler β€” rename breaks build | +| Builder/Definition type names | `nameof(SimpleBuilder)`, `nameof(PluralDefinition)`, etc. | Compiler β€” rename breaks build | +| Attribute property names | `nameof(TranslationAttribute.Locale)`, `nameof(TranslationAttribute.Key)` | Compiler β€” rename breaks build | +| Extension method names (`Translation`, `Display`) | String constants with comment | C#14 `extension` block limitation β€” `nameof` can't reach these | +| Parameter names (`key`, `message`, `howMany`, etc.) | String constants with `` | Validated by `ExtensionsContractTests` via reflection | -**2. Identity, not names.** +If Extensions renames something compiler-verified, the Extractor won't build. If Extensions renames a parameter, `ExtensionsContractTests` fails. The only truly unguarded coupling is the two C#14 extension method names (`"Translation"`, `"Display"`), which are public API and unlikely to change without a major version. -Dispatch on symbol identity (`SymbolEqualityComparer.Default.Equals`), not `.Name` string comparisons. Resolve Roslyn symbols at init time via `typeof()`/`nameof()`, then compare symbol-to-symbol at runtime. +### Rules -**3. Same binary, two lenses.** +**1. All Extensions references go through ExtensionsContract.** -Reflection (`typeof()`) and Roslyn (`GetTypeByMetadataName()`) both describe the Extensions DLL. They always agree because they read the same file on disk. +Never use a bare string literal that matches a type, method, or parameter name from Extensions. Reference the constant in `ExtensionsContract`. If the constant doesn't exist, add it there first. -**4. Fail fast, never silently drift.** +**2. Strings flow out, never in.** -At Scanner initialization, cross-validate Roslyn symbols against reflection. Every `GetTypeByMetadataName()` must return non-null. Every `GetMembers(nameof(...))` must resolve. Parameter counts must match. If anything disagrees, crash immediately β€” not a silent wrong extraction. +Roslyn-derived strings (argument text, expression text, literal values) flow *out* to `TranslationDefinition`, `TranslationReference`, and export formats. They are *never* used as match targets for interpretation logic. The scanner decides what something *is* by method/type name dispatch, then reads what it *contains* as output data. -**5. Strings flow out, never in.** +**3. DefinitionKind is the discriminator.** -Roslyn-derived strings (argument text, expression text, literal values) flow *out* to `ExtractedCall`, `TranslationEntry`, and export formats. They are *never* used as match targets for interpretation logic. The Scanner decides what something *is* by symbol identity, then reads what it *contains* as output data. +The `DefinitionKind` enum (`InlineTranslation`, `ReusableDefinition`, `EnumAttribute`, `ResourceFile`) is the machine-readable discriminator for how a translation was defined. Renderers switch on `Kind`, never parse `Context` strings. Each scanner adapter sets `Kind` explicitly when constructing a `DefinitionSite`. -**6. Two permitted hardcoded strings.** +**4. Only InlineTranslation creates a reference.** -Only two are acceptable: +`.Translation(key, message)` in a Razor/code file genuinely defines source text AND uses the key at runtime β€” so it produces both a `TranslationDefinition` and a `TranslationReference`. All other mechanisms (`DefineXxx`, `[Translation]`, `.resx`) produce only a `TranslationDefinition`. This prevents "phantom resolved" status on unused definitions. -- `"Translation"` β€” entry-point method on `IStringLocalizer` (Microsoft's assembly, not ours) -- `"ToString"` β€” chain terminator (from `System.Object`) +**5. Fail visibly.** -Everything from the Extensions assembly uses `typeof()`/`nameof()`/reflection. +`ScanTargets.ResolveLocalizer()` / `ResolveTranslationAttribute()` / `ResolveDefinitionFactory()` return `null` if the type isn't found in compilation references. Callers must handle the null case β€” skip gracefully or emit a diagnostic, never silently produce wrong output. ### Forbidden Patterns -Every example below has caused a real bug in this project. If you find yourself writing code that looks like these, stop. - -**F1: Hardcoded method name comparison** +**F1: Bare string literal for an Extensions name** ```csharp -// FORBIDDEN β€” "For" is a magic string. Rename For() to ForLocale() and this silently stops matching. -if (call.MethodName == "For") +// FORBIDDEN β€” "For" is a magic string. If renamed in Extensions, silently breaks. +if (link.MethodName == "For") -// CORRECT β€” nameof() breaks at compile time if For() is renamed. -if (SymbolEqualityComparer.Default.Equals(calledMethod, _pluralForSymbol)) -// where _pluralForSymbol was resolved at init via nameof(PluralBuilder.For) +// CORRECT β€” reference ExtensionsContract constant (backed by nameof). +if (link.MethodName == ExtensionsContract.ChainFor) ``` -**F2: Hardcoded parameter name lookup** +**F2: Parsing DefinitionSite.Context for rendering decisions** ```csharp -// FORBIDDEN β€” "message" is a magic string. If the parameter is ever renamed, -// the mapper silently returns null and the localizer breaks at runtime. -var text = FindArgumentValue(call, "message"); +// FORBIDDEN β€” fragile string matching. +if (def.Context?.Contains("DefineSimple") == true) + ShowAsReusable(); -// CORRECT β€” parameter name derived from reflection. Auto-tracks renames. -var paramName = typeof(SimpleBuilder).GetMethod(nameof(SimpleBuilder.For))! - .GetParameters()[1].Name; +// CORRECT β€” machine-readable discriminator. +if (def.Kind == DefinitionKind.ReusableDefinition) + ShowAsReusable(); ``` -**F3: Hardcoded type name comparison** +**F3: Fake references to inflate status** ```csharp -// FORBIDDEN β€” if PluralBuilder is renamed or moved to a different namespace, this silently fails. -if (returnType.Name == "PluralBuilder") - -// CORRECT β€” symbol identity via typeof(). Rename breaks the build. -if (SymbolEqualityComparer.Default.Equals(returnType.OriginalDefinition, _pluralBuilderSymbol)) -``` +// FORBIDDEN β€” creating a TranslationReference at the definition site to make it look "Resolved". +var def = new TranslationDefinition(...); +var fakeRef = new TranslationReference(...); // same file:line as def -**F4: String set for method classification** - -```csharp -// FORBIDDEN β€” a HashSet of magic strings. Add a new CLDR category method and forget to update β†’ silent miss. -private static readonly HashSet PluralCategoryMethods = ["Zero", "One", "Two", "Few", "Many", "Other"]; -if (PluralCategoryMethods.Contains(call.MethodName)) - -// CORRECT β€” resolve each method symbol at init via nameof(), store in a HashSet. -private readonly HashSet _pluralCategorySymbols = new(SymbolEqualityComparer.Default) -{ - Resolve(nameof(PluralBuilder.Zero)), - Resolve(nameof(PluralBuilder.One)), - // ... each one is compile-time checked -}; -if (_pluralCategorySymbols.Contains(calledMethod)) +// CORRECT β€” only InlineTranslation is genuinely both define + use. +// MergedTranslation.Status already handles this: InlineTranslation counts as having a reference. ``` -**F5: String-based parameter value as logic key** +**F4: String-based parameter value as logic key** ```csharp -// FORBIDDEN β€” using the string content of a parameter to decide what type of entry to create. -// This is "strings flow IN" β€” the extracted text is driving interpretation. +// FORBIDDEN β€” using the extracted text to decide what type of entry to create. if (args.Any(a => a.ParameterName == "count")) return BuildPlural(call); -// CORRECT β€” the return type of Translation() already tells you it's plural. -// Symbol identity decides the builder type, not what arguments happen to be present. -if (SymbolEqualityComparer.Default.Equals(returnType, _pluralBuilderSymbol)) - return BuildPlural(call); -``` - -**F6: Hardcoded sentinel values** - -```csharp -// FORBIDDEN β€” "__otherwise__" is a magic string coupling the extractor to SelectBuilder's internal convention. -if (key == "__otherwise__") - -// CORRECT β€” read the sentinel from the source via reflection. -private static readonly string OtherwiseSentinel = (string)typeof(SelectBuilder<>) - .GetField("OtherwiseSentinel", BindingFlags.NonPublic | BindingFlags.Static)! - .GetValue(null)!; +// CORRECT β€” the method name or return type tells you the form. +if (call.MethodName.StartsWith(ExtensionsContract.DefinePrefix)) + return InterpretDefinitionFactory(call, file); ``` ### The Grep Test -If you can `grep -r` the Scanner for any quoted string that matches the name of a type, method, or parameter in the Extensions project, the contract is violated. +If you can `grep -r` the Roslyn adapter for any quoted string that matches a type, method, or parameter name in the Extensions project **and** that string is not defined in `ExtensionsContract.cs`, the contract is violated. ## Build & Run diff --git a/src/BlazorLocalization.Extractor/Cli/Commands/ExtractCommand.cs b/src/BlazorLocalization.Extractor/Cli/Commands/ExtractCommand.cs deleted file mode 100644 index 7868952..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Commands/ExtractCommand.cs +++ /dev/null @@ -1,242 +0,0 @@ -using BlazorLocalization.Extractor.Cli.Rendering; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Domain.Requests; -using BlazorLocalization.Extractor.Exporters; -using BlazorLocalization.Extractor.Scanning; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace BlazorLocalization.Extractor.Cli.Commands; - -/// -/// Extracts translation entries from Blazor projects and exports them in the specified format. -/// -public sealed class ExtractCommand : Command -{ - public override int Execute(CommandContext context, ExtractSettings settings, CancellationToken cancellationToken) - { - // 1. Resolve paths - var (projectDirs, resolveErrors) = ProjectDiscovery.ResolveAll(settings.Paths); - foreach (var err in resolveErrors) - Console.Error.WriteLine(err); - if (resolveErrors.Count > 0) - return 1; - - // 2. Build request - var localeFilter = settings.Locales is { Length: > 0 } - ? new HashSet(settings.Locales, StringComparer.OrdinalIgnoreCase) - : null; - - var request = new ExtractRequest( - ProjectDirs: projectDirs, - Format: settings.Format, - Output: OutputTarget.FromRawOutput(settings.Output), - LocaleFilter: localeFilter, - SourceOnly: settings.SourceOnly, - PathStyle: settings.PathStyle, - Verbose: settings.Verbose, - ExitOnDuplicateKey: settings.ExitOnDuplicateKey, - OnDuplicateKey: settings.OnDuplicateKey); - - // 3. Validate - var errors = request.Validate(); - if (errors.Count > 0) - { - foreach (var error in errors) - Console.Error.WriteLine(error); - return 1; - } - - // 4. Execute - return request.Output switch - { - OutputTarget.StdoutTarget => ExecuteStdout(request), - OutputTarget.FileTarget f => ExecuteFile(request, f.Path), - OutputTarget.DirTarget d => ExecuteDir(request, d.Path), - _ => 1 - }; - } - - private static int ExecuteStdout(ExtractRequest request) - { - // stdout: single project guaranteed by validation - var projectDir = request.ProjectDirs[0]; - var scan = ProjectScanner.Scan(projectDir); - var entries = ApplyConflictStrategy(scan.MergeResult, request); - - if (request.PathStyle is PathStyle.Relative) - entries = entries.Select(e => e.RelativizeSources(projectDir)).ToList(); - - WarnMissingLocales(entries, request); - - // Single locale β†’ export that locale's .For() data - if (request.LocaleFilter is { Count: 1 }) - { - var locale = request.LocaleFilter.First(); - entries = LocaleDiscovery.EntriesForLocale(entries, locale); - } - - var exporter = ExporterFactory.Create(request.Format); - Console.Write(exporter.Export(entries)); - - // Conflicts go to stderr in stdout mode (don't contaminate piped output) - foreach (var conflict in scan.MergeResult.Conflicts) - Console.Error.WriteLine($"Warning: duplicate key '{conflict.Key}' with {conflict.Values.Count} different values"); - - return request.ExitOnDuplicateKey && scan.MergeResult.Conflicts.Count > 0 ? 1 : 0; - } - - private static int ExecuteFile(ExtractRequest request, string filePath) - { - // file: single project guaranteed by validation - var projectDir = request.ProjectDirs[0]; - var scan = ProjectScanner.Scan(projectDir); - var entries = ApplyConflictStrategy(scan.MergeResult, request); - - if (request.PathStyle is PathStyle.Relative) - entries = entries.Select(e => e.RelativizeSources(projectDir)).ToList(); - - var dir = Path.GetDirectoryName(filePath); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - - var exporter = ExporterFactory.Create(request.Format); - File.WriteAllText(filePath, exporter.Export(entries)); - - if (request.Verbose) - AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]"); - - ReportConflicts(scan.MergeResult.Conflicts, scan.ProjectName); - return request.ExitOnDuplicateKey && scan.MergeResult.Conflicts.Count > 0 ? 1 : 0; - } - - private static int ExecuteDir(ExtractRequest request, string outputDir) - { - Directory.CreateDirectory(outputDir); - var exporter = ExporterFactory.Create(request.Format); - var ext = ExporterFactory.GetFileExtension(request.Format); - var hasConflicts = false; - var summaryRows = new List<(string Project, int Entries, int Conflicts)>(); - - foreach (var projectDir in request.ProjectDirs) - { - ProjectScanner.Result scan; - - if (AnsiConsole.Profile.Capabilities.Interactive) - { - scan = null!; - AnsiConsole.Status() - .Spinner(Spinner.Known.Dots) - .Start($"Scanning [blue]{Markup.Escape(Path.GetFileName(projectDir))}[/]...", _ => - { - scan = ProjectScanner.Scan(projectDir); - }); - } - else - { - scan = ProjectScanner.Scan(projectDir); - } - - var entries = ApplyConflictStrategy(scan.MergeResult, request); - if (scan.MergeResult.Conflicts.Count > 0) hasConflicts = true; - - if (request.PathStyle is PathStyle.Relative) - entries = entries.Select(e => e.RelativizeSources(projectDir)).ToList(); - - if (request.Verbose) - AnsiConsole.MarkupLine($"[grey]{Markup.Escape(scan.ProjectName)}: {entries.Count} translation(s)[/]"); - - // Source file - var filePath = Path.Combine(outputDir, $"{scan.ProjectName}{ext}"); - File.WriteAllText(filePath, exporter.Export(entries)); - if (request.Verbose) - AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]"); - - // Per-locale files - if (!request.SourceOnly) - ExportPerLocaleFiles(entries, request, outputDir, scan.ProjectName, exporter, ext); - - summaryRows.Add((scan.ProjectName, entries.Count, scan.MergeResult.Conflicts.Count)); - ReportConflicts(scan.MergeResult.Conflicts, scan.ProjectName); - - if (request.Format is ExportFormat.Po) - PoLimitationRenderer.Render(PoLimitation.Detect(entries), scan.ProjectName); - } - - if (summaryRows.Count > 0) - { - AnsiConsole.WriteLine(); - AnsiConsole.Write(new Rule("[blue]Summary[/]").LeftJustified()); - AnsiConsole.WriteLine(); - - var table = new Table() - .Border(TableBorder.Rounded) - .AddColumn("Project") - .AddColumn("Entries") - .AddColumn("Conflicts"); - - foreach (var (project, entries, conflicts) in summaryRows) - { - var conflictMarkup = conflicts > 0 ? $"[yellow]{conflicts}[/]" : $"[green]{conflicts}[/]"; - table.AddRow(Markup.Escape(project), entries.ToString(), conflictMarkup); - } - - AnsiConsole.Write(table); - } - - return hasConflicts && request.ExitOnDuplicateKey ? 1 : 0; - } - - private static IReadOnlyList ApplyConflictStrategy( - MergeResult mergeResult, ExtractRequest request) - { - var entries = mergeResult.Entries; - if (mergeResult.Conflicts.Count > 0 && request.OnDuplicateKey is ConflictStrategy.Skip) - { - var conflictKeys = mergeResult.Conflicts.Select(c => c.Key).ToHashSet(); - entries = entries.Where(e => !conflictKeys.Contains(e.Key)).ToList(); - } - return entries; - } - - private static void ExportPerLocaleFiles( - IReadOnlyList entries, - ExtractRequest request, - string outputDir, - string projectName, - ITranslationExporter exporter, - string ext) - { - var locales = LocaleDiscovery.DiscoverLocales(entries, request.LocaleFilter); - - if (request.LocaleFilter is not null) - WarnMissingLocales(entries, request); - - foreach (var locale in locales) - { - var localeEntries = LocaleDiscovery.EntriesForLocale(entries, locale); - var filePath = Path.Combine(outputDir, $"{projectName}.{locale}{ext}"); - File.WriteAllText(filePath, exporter.Export(localeEntries)); - if (request.Verbose) - AnsiConsole.MarkupLine($"[green]Wrote {filePath}[/]"); - } - } - - private static void WarnMissingLocales(IReadOnlyList entries, ExtractRequest request) - { - if (request.LocaleFilter is null) return; - - var discovered = LocaleDiscovery.DiscoverLocales(entries); - foreach (var requested in request.LocaleFilter.Where(r => - !discovered.Contains(r, StringComparer.OrdinalIgnoreCase))) - { - Console.Error.WriteLine($"Warning: locale '{requested}' not found in any translation"); - } - } - - private static void ReportConflicts(IReadOnlyList conflicts, string projectName) - { - ConflictRenderer.Render(conflicts, projectName); - } -} diff --git a/src/BlazorLocalization.Extractor/Cli/Commands/ExtractSettings.cs b/src/BlazorLocalization.Extractor/Cli/Commands/ExtractSettings.cs deleted file mode 100644 index c34624b..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Commands/ExtractSettings.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.ComponentModel; -using BlazorLocalization.Extractor.Domain; -using Spectre.Console.Cli; - -namespace BlazorLocalization.Extractor.Cli.Commands; - -/// -/// CLI settings for the extract command. -/// -public sealed class ExtractSettings : SharedSettings -{ - [Description("Export format: i18next (default, Crowdin i18next JSON), po (GNU Gettext with source refs), json (full-fidelity debug)")] - [CommandOption("-f|--format")] - [DefaultValue(ExportFormat.I18Next)] - public ExportFormat Format { get; init; } = ExportFormat.I18Next; - - [Description("Output path: a file (e.g. out.po) or directory (e.g. ./translations). Auto-detected via extension. Omit to write to stdout")] - [CommandOption("-o|--output")] - public string? Output { get; init; } - - [Description("Print per-project scanning progress to stderr")] - [CommandOption("--verbose")] - [DefaultValue(false)] - public bool Verbose { get; init; } - - [Description("Exit with error code 1 when duplicate translation keys are found (useful for CI pipelines)")] - [CommandOption("--exit-on-duplicate-key")] - [DefaultValue(false)] - public bool ExitOnDuplicateKey { get; init; } - - [Description("Strategy for duplicate translation keys (same key, different source text). 'first' (default) keeps the first-seen source text. 'skip' omits the key entirely")] - [CommandOption("--on-duplicate-key")] - [DefaultValue(ConflictStrategy.First)] - public ConflictStrategy OnDuplicateKey { get; init; } = ConflictStrategy.First; -} diff --git a/src/BlazorLocalization.Extractor/Cli/Commands/InspectCommand.cs b/src/BlazorLocalization.Extractor/Cli/Commands/InspectCommand.cs deleted file mode 100644 index 34811d9..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Commands/InspectCommand.cs +++ /dev/null @@ -1,93 +0,0 @@ -using BlazorLocalization.Extractor.Cli.Rendering; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Domain.Requests; -using BlazorLocalization.Extractor.Scanning; -using Spectre.Console; -using Spectre.Console.Cli; - -namespace BlazorLocalization.Extractor.Cli.Commands; - -/// -/// Debug command: shows raw call details and mapped translation entries. -/// -public sealed class InspectCommand : Command -{ - public override int Execute(CommandContext context, InspectSettings settings, CancellationToken cancellationToken) - { - // 1. Resolve paths - var (projectDirs, resolveErrors) = ProjectDiscovery.ResolveAll(settings.Paths); - foreach (var err in resolveErrors) - Console.Error.WriteLine(err); - if (resolveErrors.Count > 0) - return 1; - - // 2. Build request - var localeFilter = settings.Locales is { Length: > 0 } - ? new HashSet(settings.Locales, StringComparer.OrdinalIgnoreCase) - : null; - - var request = new InspectRequest( - ProjectDirs: projectDirs, - JsonOutput: settings.ShouldOutputJson, - LocaleFilter: localeFilter, - SourceOnly: settings.SourceOnly, - PathStyle: settings.PathStyle); - - // 3. Validate - var errors = request.Validate(); - if (errors.Count > 0) - { - foreach (var error in errors) - Console.Error.WriteLine(error); - return 1; - } - - // 4. Execute - foreach (var projectDir in request.ProjectDirs) - { - var scan = ProjectScanner.Scan(projectDir); - - var calls = request.PathStyle is PathStyle.Relative - ? scan.Calls.Select(c => c.RelativizeLocation(projectDir)).ToList() - : scan.Calls; - - var entries = request.PathStyle is PathStyle.Relative - ? scan.MergeResult.Entries.Select(e => e.RelativizeSources(projectDir)).ToList() - : scan.MergeResult.Entries; - - var poLimitations = PoLimitation.Detect(entries); - - if (request.LocaleFilter is not null) - { - var discovered = LocaleDiscovery.DiscoverLocales(entries); - foreach (var requested in request.LocaleFilter.Where(r => - !discovered.Contains(r, StringComparer.OrdinalIgnoreCase))) - { - Console.Error.WriteLine($"Warning: locale '{requested}' not found in any translation"); - } - } - - if (request.JsonOutput) - { - JsonRenderer.RenderInspect(scan.ProjectName, calls, entries, scan.MergeResult.Conflicts, - poLimitations, - request.SourceOnly ? new HashSet(StringComparer.OrdinalIgnoreCase) : request.LocaleFilter); - } - else - { - ExtractedCallRenderer.Render(calls, scan.ProjectName); - TranslationEntryRenderer.Render(entries, scan.ProjectName); - if (!request.SourceOnly) - { - TranslationEntryRenderer.RenderLocaleSummary(entries, request.LocaleFilter); - TranslationEntryRenderer.RenderLocales(entries, request.LocaleFilter); - } - ConflictRenderer.Render(scan.MergeResult.Conflicts, scan.ProjectName); - PoLimitationRenderer.Render(poLimitations, scan.ProjectName); - } - } - - return 0; - } -} diff --git a/src/BlazorLocalization.Extractor/Cli/Commands/InspectSettings.cs b/src/BlazorLocalization.Extractor/Cli/Commands/InspectSettings.cs deleted file mode 100644 index a6223bd..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Commands/InspectSettings.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System.ComponentModel; -using Spectre.Console.Cli; - -namespace BlazorLocalization.Extractor.Cli.Commands; - -/// -/// CLI settings for the inspect command. -/// -public sealed class InspectSettings : SharedSettings -{ - [Description("Output raw JSON to stdout instead of rendered tables. Auto-enabled when stdout is piped")] - [CommandOption("--json")] - [DefaultValue(false)] - public bool Json { get; init; } - - /// - /// Whether output should be JSON β€” explicitly via --json or auto-detected when stdout is piped. - /// - public bool ShouldOutputJson => Json || Console.IsOutputRedirected; -} diff --git a/src/BlazorLocalization.Extractor/Cli/Commands/SharedSettings.cs b/src/BlazorLocalization.Extractor/Cli/Commands/SharedSettings.cs deleted file mode 100644 index c1d113e..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Commands/SharedSettings.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.ComponentModel; -using BlazorLocalization.Extractor.Domain; -using Spectre.Console.Cli; - -namespace BlazorLocalization.Extractor.Cli.Commands; - -/// -/// Shared CLI settings for path discovery and output options. -/// -public class SharedSettings : CommandSettings -{ - [Description("Directories or .csproj files to scan. Repeatable. Defaults to current directory")] - [CommandArgument(0, "[paths]")] - public string[] Paths { get; init; } = ["."]; - - [Description("Path style for source references. 'relative' (default) emits paths relative to the project root. 'absolute' preserves full filesystem paths")] - [CommandOption("--path-style")] - [DefaultValue(PathStyle.Relative)] - public PathStyle PathStyle { get; init; } = PathStyle.Relative; - - [Description("Filter to specific locales. Repeatable (-l da -l es-MX). Omit to include all discovered locales")] - [CommandOption("-l|--locale")] - public string[]? Locales { get; init; } - - [Description("Export source-language entries only; skip per-locale translations")] - [CommandOption("--source-only")] - [DefaultValue(false)] - public bool SourceOnly { get; init; } -} diff --git a/src/BlazorLocalization.Extractor/Cli/InteractiveWizard.cs b/src/BlazorLocalization.Extractor/Cli/InteractiveWizard.cs deleted file mode 100644 index 97c5964..0000000 --- a/src/BlazorLocalization.Extractor/Cli/InteractiveWizard.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.ComponentModel; -using System.Reflection; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Scanning; -using BlazorLocalization.Extractor.Exporters; -using Spectre.Console; - -namespace BlazorLocalization.Extractor.Cli; - -/// -/// Interactive wizard that gathers CLI arguments via Spectre.Console prompts -/// when the tool is invoked with no arguments. -/// -public static class InteractiveWizard -{ - public static string[] Run() - { - WriteBanner(); - AnsiConsole.WriteLine(); - - var command = PromptWithDescriptions("What would you like to do?", new Dictionary - { - ["extract"] = "Scan projects, extract source strings, export to translation file", - ["inspect"] = "Show all detected IStringLocalizer calls (debug view)" - }); - - var path = AnsiConsole.Prompt( - new TextPrompt("Project/solution [green]root path[/]:") - .DefaultValue(".")); - - var discovered = ProjectDiscovery.Discover(path); - if (discovered.Count == 0) - { - AnsiConsole.MarkupLine("[red]No projects found at that path.[/]"); - return [command, path]; - } - - var selectedPaths = SelectProjects(discovered, path); - - var args = new List { command }; - foreach (var proj in selectedPaths) - args.Add(proj); - - AddSharedOptions(args); - - if (command == "extract") - AddExtractOptions(args); - - AnsiConsole.WriteLine(); - return args.ToArray(); - } - - private static void WriteBanner() - { - var blazorPurple = new Color(81, 43, 212); - var lightPurple = new Color(140, 100, 240); - var faintPurple = new Color(180, 150, 255); - - AnsiConsole.Write(new FigletText("Blazor") { Color = blazorPurple, Justification = Justify.Center }); - AnsiConsole.Write(new FigletText("Localization") { Color = lightPurple, Justification = Justify.Center }); - var version = typeof(InteractiveWizard).Assembly - .GetCustomAttribute() - ?.InformationalVersion.Split('+')[0] ?? "unknown"; - AnsiConsole.Write(new Text($"v{version}", new Style(faintPurple, decoration: Decoration.Dim)) { Justification = Justify.Center }); - AnsiConsole.WriteLine(); - AnsiConsole.Write(new Text("Extract and export your Blazor source strings to any external translation provider.", new Style(Color.Grey)) { Justification = Justify.Center }); - AnsiConsole.WriteLine(); - } - - private static IReadOnlyList SelectProjects(IReadOnlyList discovered, string root) - { - if (discovered.Count == 1) - { - AnsiConsole.MarkupLine($"Found project: [green]{Path.GetFileName(discovered[0])}[/]"); - return discovered; - } - - var rootFull = Path.GetFullPath(root); - var displayToPath = discovered.ToDictionary( - d => Path.GetRelativePath(rootFull, d), - d => d); - - var prompt = new MultiSelectionPrompt() - .Title("Select [green]projects[/] to scan:") - .PageSize(15) - .InstructionsText("[grey](Press [blue][/] to toggle, [green][/] to accept)[/]") - .AddChoices(displayToPath.Keys); - - foreach (var name in displayToPath.Keys) - prompt.Select(name); - - var selected = AnsiConsole.Prompt(prompt); - return selected.Select(name => displayToPath[name]).ToList(); - } - - private static void AddSharedOptions(List args) - { - var paths = PromptEnum("Path style in [green]output[/]:"); - if (paths is not PathStyle.Relative) - { - args.Add("--path-style"); - args.Add(paths.ToString()); - } - } - - private static void AddExtractOptions(List args) - { - var format = PromptEnum("Output [green]format[/]:"); - if (format is ExportFormat.Po) - AnsiConsole.MarkupLine("[yellow]β„Ή PO format has limitations: it cannot represent exact value matches (e.g. 'exactly 0' or 'exactly 42') or ordinal forms (1st, 2nd, 3rd). Affected entries will be flagged below.[/]"); - args.Add("-f"); - args.Add(format.ToString()); - - var outputDir = AnsiConsole.Prompt( - new TextPrompt("Output [green]directory[/]:") - .DefaultValue("./output")); - args.Add("-o"); - args.Add(outputDir); - - var ext = ExporterFactory.GetFileExtension(format); - if (!AnsiConsole.Confirm($"Include per-locale translations? (e.g. MyApp.da[green]{ext}[/], MyApp.es-MX[green]{ext}[/])", true)) - args.Add("--source-only"); - - var onDuplicateKey = PromptEnum("Duplicate translation key [green]strategy[/]:"); - if (onDuplicateKey is not ConflictStrategy.First) - { - args.Add("--on-duplicate-key"); - args.Add(onDuplicateKey.ToString()); - } - - if (AnsiConsole.Confirm("Verbose output?", false)) - args.Add("--verbose"); - } - - private static T PromptEnum(string title) where T : struct, Enum - { - var values = Enum.GetValues(); - var prompt = new SelectionPrompt() - .Title(title) - .UseConverter(v => - { - var desc = typeof(T).GetField(v.ToString())? - .GetCustomAttribute()?.Description; - return desc is not null ? $"{v} [dim]β€” {desc}[/]" : v.ToString(); - }) - .AddChoices(values); - - return AnsiConsole.Prompt(prompt); - } - - private static string PromptWithDescriptions(string title, Dictionary choices) - { - var prompt = new SelectionPrompt() - .Title(title) - .UseConverter(key => choices.TryGetValue(key, out var desc) ? $"{key} [dim]β€” {desc}[/]" : key) - .AddChoices(choices.Keys); - - return AnsiConsole.Prompt(prompt); - } -} diff --git a/src/BlazorLocalization.Extractor/Cli/Rendering/ConflictRenderer.cs b/src/BlazorLocalization.Extractor/Cli/Rendering/ConflictRenderer.cs deleted file mode 100644 index c8f4025..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Rendering/ConflictRenderer.cs +++ /dev/null @@ -1,64 +0,0 @@ -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; -using Spectre.Console; - -namespace BlazorLocalization.Extractor.Cli.Rendering; - -/// -/// Renders results as a Spectre.Console panel containing a table. -/// -public static class ConflictRenderer -{ - public static void Render(IReadOnlyList conflicts, string projectName) - { - if (conflicts.Count == 0) - return; - - var table = new Table() - .Border(TableBorder.Rounded) - .AddColumn("Key") - .AddColumn("Value") - .AddColumn("Locations"); - - foreach (var conflict in conflicts) - { - for (var i = 0; i < conflict.Values.Count; i++) - { - var value = conflict.Values[i]; - var locations = string.Join(", ", value.Sources.Select(FormatLocation)); - - table.AddRow( - i == 0 ? $"[white]{Markup.Escape(conflict.Key)}[/]" : "", - $"[dim]\"{Markup.Escape(FormatSourceText(value.SourceText))}\"[/]", - locations); - } - - table.AddEmptyRow(); - } - - var panel = new Panel(table) - .Header($"[yellow]⚠ {conflicts.Count} conflicting key(s) in {Markup.Escape(projectName)}[/]") - .BorderColor(Color.Yellow) - .Padding(1, 0); - - AnsiConsole.WriteLine(); - AnsiConsole.Write(panel); - } - - private static string FormatSourceText(TranslationSourceText text) => text switch - { - SingularText s => s.Value, - PluralText p => string.Join(" | ", - new[] { ("zero", p.Zero), ("one", p.One), ("two", p.Two), ("few", p.Few), ("many", p.Many), ("other", (string?)p.Other) } - .Where(t => t.Item2 is not null) - .Select(t => $"{t.Item1}: {t.Item2}")), - SelectText s => string.Join(" | ", s.Cases.Select(c => $"{c.Key}: {c.Value}")), - _ => "(unknown)" - }; - - private static string FormatLocation(SourceReference source) - { - var file = Path.GetFileName(source.FilePath); - return $"[cyan]{Markup.Escape(file)}:{source.Line}[/]"; - } -} diff --git a/src/BlazorLocalization.Extractor/Cli/Rendering/ExtractedCallRenderer.cs b/src/BlazorLocalization.Extractor/Cli/Rendering/ExtractedCallRenderer.cs deleted file mode 100644 index 41092bb..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Rendering/ExtractedCallRenderer.cs +++ /dev/null @@ -1,108 +0,0 @@ -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using Spectre.Console; - -namespace BlazorLocalization.Extractor.Cli.Rendering; - -/// -/// Renders results as a Spectre.Console table. -/// -public static class ExtractedCallRenderer -{ - public static void Render(IReadOnlyList calls, string? projectName = null) - { - var header = projectName is not null - ? $"[blue]{Markup.Escape(projectName)}[/] [grey]β€” Extracted Calls ({calls.Count})[/]" - : $"[blue]Extracted Calls[/] [grey]({calls.Count})[/]"; - AnsiConsole.Write(new Rule(header).LeftJustified()); - AnsiConsole.WriteLine(); - - if (calls.Count == 0) - { - AnsiConsole.MarkupLine("[grey]No IStringLocalizer usage detected.[/]"); - return; - } - - var table = new Table() - .Border(TableBorder.Rounded) - .AddColumn(new TableColumn("#").RightAligned()) - .AddColumn("Type.Method") - .AddColumn("Kind") - .AddColumn("Location") - .AddColumn("Overload") - .AddColumn("Arguments"); - - for (var i = 0; i < calls.Count; i++) - { - var call = calls[i]; - var method = $"{Markup.Escape(call.ContainingTypeName)}.{Markup.Escape(call.MethodName)}"; - var kind = call.CallKind.ToString(); - var file = Path.GetFileName(call.Location.FilePath); - var location = $"{Markup.Escape(file)}:{call.Location.Line}"; - - var (statusMarkup, statusText) = call.OverloadResolution switch - { - OverloadResolutionStatus.Resolved => ("[green]", "Resolved"), - OverloadResolutionStatus.Ambiguous => ("[yellow]", "Ambiguous"), - OverloadResolutionStatus.BestCandidate => ("[red]", "Best-candidate"), - _ => ("[grey]", "Unknown") - }; - - var argsText = FormatArguments(call.Arguments); - - if (call.FluentChain is { Count: > 0 }) - argsText += "\n" + FormatChain(call.FluentChain); - - table.AddRow( - $"[grey]{i + 1}[/]", - $"[white]{method}[/]", - $"[grey]{Markup.Escape(kind)}[/]", - $"[cyan]{location}[/]", - $"{statusMarkup}{Markup.Escape(statusText)}[/]", - argsText); - } - - AnsiConsole.Write(table); - } - - private static string FormatArguments(IReadOnlyList args) - { - var parts = new List(); - foreach (var arg in args) - { - var name = arg.ParameterName ?? $"arg{arg.Position}"; - var value = arg.Value.Length > 40 ? arg.Value[..37] + "..." : arg.Value; - parts.Add($"[grey]{Markup.Escape(name)}[/]=[white]\"{Markup.Escape(value)}\"[/]"); - - if (arg.ObjectCreation is { } oc) - { - var typeName = oc.TypeName.Contains('.') ? oc.TypeName[(oc.TypeName.LastIndexOf('.') + 1)..] : oc.TypeName; - var ctorArgs = string.Join(", ", oc.ConstructorArguments.Select(a => - { - var v = a.Value.Length > 30 ? a.Value[..27] + "..." : a.Value; - return $"\"{Markup.Escape(v)}\""; - })); - parts.Add($" [dim]β†’ new {Markup.Escape(typeName)}({ctorArgs})[/]"); - } - } - - return string.Join("\n", parts); - } - - private static string FormatChain(IReadOnlyList chain) - { - var lines = new List(); - foreach (var call in chain) - { - var args = call.Arguments.Count > 0 - ? string.Join(", ", call.Arguments.Select(a => - { - var v = a.Value.Length > 30 ? a.Value[..27] + "..." : a.Value; - return a.IsLiteral ? $"\"{Markup.Escape(v)}\"" : Markup.Escape(v); - })) - : ""; - lines.Add($"[dim] .{Markup.Escape(call.MethodName)}({args})[/]"); - } - return string.Join("\n", lines); - } -} diff --git a/src/BlazorLocalization.Extractor/Cli/Rendering/JsonRenderer.cs b/src/BlazorLocalization.Extractor/Cli/Rendering/JsonRenderer.cs deleted file mode 100644 index 499a21c..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Rendering/JsonRenderer.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Unicode; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; - -namespace BlazorLocalization.Extractor.Cli.Rendering; - -/// -/// Renders scan results as JSON to stdout for piped/machine-readable output. -/// -public static class JsonRenderer -{ - private static readonly JsonSerializerOptions Options = new() - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) - }; - - public static void RenderInspect( - string projectName, - IReadOnlyList calls, - IReadOnlyList entries, - IReadOnlyList conflicts, - IReadOnlyList poLimitations, - HashSet? localeFilter) - { - var allLocales = LocaleDiscovery.DiscoverLocales(entries, localeFilter); - var totalKeys = entries.Count; - - Console.WriteLine(JsonSerializer.Serialize(new - { - project = projectName, - calls = calls.Select(MapCall), - entries = entries.Select(MapEntry), - conflicts = conflicts.Select(MapConflict), - poLimitations = poLimitations.Count > 0 ? poLimitations.Select(l => new { key = l.Key, limitation = l.Limitation }) : null, - localeCoverage = allLocales.Count > 0 ? allLocales.Select(locale => - { - var count = entries.Count(e => e.InlineTranslations?.ContainsKey(locale) == true); - return new { locale, keys = count, totalKeys }; - }) : null, - localeEntries = allLocales.Count > 0 ? allLocales.ToDictionary( - locale => locale, - locale => entries - .Where(e => e.InlineTranslations?.ContainsKey(locale) == true) - .Select(e => new - { - key = e.Key, - sourceText = MapSourceText(e.InlineTranslations![locale]) - })) : null - }, Options)); - } - - public static void RenderExtract( - string projectName, - IReadOnlyList entries, - IReadOnlyList conflicts) - { - Console.WriteLine(JsonSerializer.Serialize(new - { - project = projectName, - entries = entries.Select(MapEntry), - conflicts = conflicts.Select(MapConflict) - }, Options)); - } - - private static object MapCall(ExtractedCall call) => new - { - type = call.ContainingTypeName, - method = call.MethodName, - kind = call.CallKind.ToString(), - file = call.Location.FilePath, - line = call.Location.Line, - overloadResolution = call.OverloadResolution.ToString(), - arguments = call.Arguments.Select(a => new - { - position = a.Position, - name = a.ParameterName, - value = a.Value - }), - fluentChain = call.FluentChain?.Select(c => new - { - method = c.MethodName, - arguments = c.Arguments.Select(a => new - { - position = a.Position, - name = a.ParameterName, - value = a.Value, - isLiteral = a.IsLiteral - }) - }) - }; - - private static object MapEntry(MergedTranslationEntry entry) => new - { - key = entry.Key, - sourceText = MapSourceText(entry.SourceText), - inlineTranslations = entry.InlineTranslations?.ToDictionary( - kvp => kvp.Key, - kvp => MapSourceText(kvp.Value)), - sources = entry.Sources.Select(MapSource) - }; - - private static object? MapSourceText(TranslationSourceText? text) => text switch - { - SingularText s => new { type = "singular", value = s.Value }, - PluralText p => new - { - type = "plural", - other = p.Other, - zero = p.Zero, - one = p.One, - two = p.Two, - few = p.Few, - many = p.Many, - exactMatches = p.ExactMatches?.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value), - isOrdinal = p.IsOrdinal ? true : (bool?)null - }, - SelectText s => new - { - type = "select", - cases = s.Cases, - otherwise = s.Otherwise - }, - SelectPluralText sp => new - { - type = "selectPlural", - cases = sp.Cases.ToDictionary(kvp => kvp.Key, kvp => MapSourceText(kvp.Value)), - otherwise = sp.Otherwise is not null ? MapSourceText(sp.Otherwise) : null - }, - _ => null - }; - - private static object MapSource(SourceReference source) => new - { - filePath = source.FilePath, - line = source.Line, - projectName = source.ProjectName, - context = source.Context - }; - - private static object MapConflict(KeyConflict conflict) => new - { - key = conflict.Key, - values = conflict.Values.Select(v => new - { - sourceText = MapSourceText(v.SourceText), - sources = v.Sources.Select(MapSource) - }) - }; -} diff --git a/src/BlazorLocalization.Extractor/Cli/Rendering/PoLimitationRenderer.cs b/src/BlazorLocalization.Extractor/Cli/Rendering/PoLimitationRenderer.cs deleted file mode 100644 index 2380c1e..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Rendering/PoLimitationRenderer.cs +++ /dev/null @@ -1,35 +0,0 @@ -using BlazorLocalization.Extractor.Domain; -using Spectre.Console; - -namespace BlazorLocalization.Extractor.Cli.Rendering; - -/// -/// Renders PO format limitations as a Spectre.Console panel containing a table. -/// -public static class PoLimitationRenderer -{ - public static void Render(IReadOnlyList limitations, string projectName) - { - if (limitations.Count == 0) - return; - - var table = new Table() - .Border(TableBorder.Rounded) - .AddColumn("Key") - .AddColumn("Limitation"); - - foreach (var limitation in limitations) - table.AddRow( - $"[white]{Markup.Escape(limitation.Key)}[/]", - $"[dim]{Markup.Escape(limitation.Limitation)}[/]"); - - var panel = new Panel(table) - .Header($"[yellow]⚠ {limitations.Count} PO format limitation(s) in {Markup.Escape(projectName)}[/]") - .BorderColor(Color.Yellow) - .Padding(1, 0); - - AnsiConsole.WriteLine(); - AnsiConsole.Write(panel); - AnsiConsole.MarkupLine("[dim] Use i18next or generic JSON for full fidelity.[/]"); - } -} diff --git a/src/BlazorLocalization.Extractor/Cli/Rendering/TranslationEntryRenderer.cs b/src/BlazorLocalization.Extractor/Cli/Rendering/TranslationEntryRenderer.cs deleted file mode 100644 index b9a24dd..0000000 --- a/src/BlazorLocalization.Extractor/Cli/Rendering/TranslationEntryRenderer.cs +++ /dev/null @@ -1,224 +0,0 @@ -using System.Globalization; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; -using Spectre.Console; - -namespace BlazorLocalization.Extractor.Cli.Rendering; - -/// -/// Renders results as a Spectre.Console table. -/// -public static class TranslationEntryRenderer -{ - public static void Render(IReadOnlyList entries, string? projectName = null) - { - AnsiConsole.WriteLine(); - var header = projectName is not null - ? $"[blue]{Markup.Escape(projectName)}[/] [grey]β€” Translation Entries ({entries.Count})[/]" - : $"[blue]Translation Entries[/] [grey]({entries.Count})[/]"; - AnsiConsole.Write(new Rule(header).LeftJustified()); - AnsiConsole.WriteLine(); - - if (entries.Count == 0) - { - AnsiConsole.MarkupLine("[grey]No translation entries found.[/]"); - return; - } - - var table = new Table() - .Border(TableBorder.Rounded) - .AddColumn("Key") - .AddColumn("Type") - .AddColumn("Text") - .AddColumn("Locales") - .AddColumn("Sources"); - - foreach (var entry in entries) - { - var (typeMarkup, typeLabel) = GetTypeLabel(entry.SourceText); - var text = FormatSourceText(entry.SourceText); - - var locales = entry.InlineTranslations is { Count: > 0 } - ? string.Join(", ", entry.InlineTranslations.Keys.Order(StringComparer.OrdinalIgnoreCase)) - : "[grey]\u2014[/]"; - - var sources = string.Join("\n", entry.Sources.Select(s => - { - var file = Path.GetFileName(s.FilePath); - var line = $"[cyan]{Markup.Escape(file)}:{s.Line}[/]"; - return s.Context is not null - ? $"{line} [dim]({Markup.Escape(s.Context)})[/]" - : line; - })); - - table.AddRow( - $"[white]{Markup.Escape(entry.Key)}[/]", - $"{typeMarkup}{typeLabel}[/]", - text, - locales, - sources); - } - - AnsiConsole.Write(table); - } - - /// - /// Renders a locale coverage summary showing how many keys have .For() translations per locale. - /// - public static void RenderLocaleSummary( - IReadOnlyList entries, - HashSet? localeFilter) - { - var allLocales = LocaleDiscovery.DiscoverLocales(entries, localeFilter); - if (allLocales.Count == 0) return; - - var totalKeys = entries.Count; - - AnsiConsole.WriteLine(); - AnsiConsole.Write(new Rule("[blue]Locale Coverage[/]").LeftJustified()); - AnsiConsole.WriteLine(); - - AnsiConsole.MarkupLine($" [white]Source:[/] [grey]{totalKeys} keys[/]"); - - foreach (var locale in allLocales) - { - var count = entries.Count(e => e.InlineTranslations?.ContainsKey(locale) == true); - var displayName = GetLocaleDisplayName(locale); - var color = count == totalKeys ? "green" : "yellow"; - AnsiConsole.MarkupLine($" [{color}]{Markup.Escape(displayName)}:[/] [grey]{count} of {totalKeys} keys[/]"); - } - } - - /// - /// Renders one table per discovered locale showing actual .For() translated text. - /// - public static void RenderLocales( - IReadOnlyList entries, - HashSet? localeFilter) - { - var allLocales = LocaleDiscovery.DiscoverLocales(entries, localeFilter); - - foreach (var locale in allLocales) - { - var localeEntries = entries - .Where(e => e.InlineTranslations?.ContainsKey(locale) == true) - .ToList(); - - if (localeEntries.Count == 0) continue; - - var displayName = GetLocaleDisplayName(locale); - var header = $"[blue]{Markup.Escape(displayName)}[/] [grey]β€” {localeEntries.Count} of {entries.Count} keys[/]"; - - AnsiConsole.WriteLine(); - AnsiConsole.Write(new Rule(header).LeftJustified()); - AnsiConsole.WriteLine(); - - var table = new Table() - .Border(TableBorder.Rounded) - .AddColumn("Key") - .AddColumn("Type") - .AddColumn("Text"); - - foreach (var entry in localeEntries) - { - var localeText = entry.InlineTranslations![locale]; - var (typeMarkup, typeLabel) = GetTypeLabel(localeText); - var text = FormatSourceText(localeText); - - table.AddRow( - $"[white]{Markup.Escape(entry.Key)}[/]", - $"{typeMarkup}{typeLabel}[/]", - text); - } - - AnsiConsole.Write(table); - } - } - - private static (string Markup, string Label) GetTypeLabel(TranslationSourceText? sourceText) => - sourceText switch - { - SingularText => ("[green]", "Singular"), - PluralText p => ("[blue]", p.IsOrdinal ? "Ordinal" : "Plural"), - SelectText => ("[magenta]", "Select"), - SelectPluralText => ("[magenta]", "Select+Plural"), - null => ("[grey]", "Key-only"), - _ => ("[grey]", "Unknown") - }; - - private static string FormatSourceText(TranslationSourceText? text) - { - switch (text) - { - case SingularText s: - return $"\"{Markup.Escape(s.Value)}\""; - - case PluralText p: - var parts = new List(); - if (p.Zero is not null) parts.Add($"zero: \"{Markup.Escape(p.Zero)}\""); - if (p.One is not null) parts.Add($"one: \"{Markup.Escape(p.One)}\""); - if (p.Two is not null) parts.Add($"two: \"{Markup.Escape(p.Two)}\""); - if (p.Few is not null) parts.Add($"few: \"{Markup.Escape(p.Few)}\""); - if (p.Many is not null) parts.Add($"many: \"{Markup.Escape(p.Many)}\""); - parts.Add($"other: \"{Markup.Escape(p.Other)}\""); - if (p.ExactMatches is { Count: > 0 }) - foreach (var (v, m) in p.ExactMatches) - parts.Add($"exactly({v}): \"{Markup.Escape(m)}\""); - return string.Join("\n", parts); - - case SelectText s: - var selectParts = s.Cases.Select(c => $"{Markup.Escape(c.Key)}: \"{Markup.Escape(c.Value)}\"").ToList(); - if (s.Otherwise is not null) - selectParts.Add($"otherwise: \"{Markup.Escape(s.Otherwise)}\""); - return string.Join("\n", selectParts); - - case SelectPluralText sp: - var spParts = new List(); - foreach (var (caseValue, plural) in sp.Cases) - { - spParts.Add($"[magenta]{Markup.Escape(caseValue)}:[/]"); - spParts.Add(FormatPluralInline(plural)); - } - if (sp.Otherwise is not null) - { - spParts.Add("[magenta]otherwise:[/]"); - spParts.Add(FormatPluralInline(sp.Otherwise)); - } - return string.Join("\n", spParts); - - case null: - return "[grey]\u2014[/]"; - - default: - return "[grey]?[/]"; - } - } - - private static string FormatPluralInline(PluralText p) - { - var parts = new List(); - if (p.Zero is not null) parts.Add($" zero: \"{Markup.Escape(p.Zero)}\""); - if (p.One is not null) parts.Add($" one: \"{Markup.Escape(p.One)}\""); - if (p.Two is not null) parts.Add($" two: \"{Markup.Escape(p.Two)}\""); - if (p.Few is not null) parts.Add($" few: \"{Markup.Escape(p.Few)}\""); - if (p.Many is not null) parts.Add($" many: \"{Markup.Escape(p.Many)}\""); - parts.Add($" other: \"{Markup.Escape(p.Other)}\""); - if (p.ExactMatches is { Count: > 0 }) - foreach (var (v, m) in p.ExactMatches) - parts.Add($" exactly({v}): \"{Markup.Escape(m)}\""); - return string.Join("\n", parts); - } - - private static string GetLocaleDisplayName(string locale) - { - try - { - var culture = CultureInfo.GetCultureInfo(locale, predefinedOnly: true); - return culture.EnglishName != locale ? $"{culture.EnglishName} ({locale})" : locale; - } - catch - { - return locale; - } - } -} diff --git a/src/BlazorLocalization.Extractor/Domain/Calls/CallKind.cs b/src/BlazorLocalization.Extractor/Domain/Calls/CallKind.cs deleted file mode 100644 index 6a87f07..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Calls/CallKind.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Calls; - -/// -/// Distinguishes the syntactic form of a detected . -/// -public enum CallKind -{ - MethodInvocation, - IndexerAccess, - AttributeDeclaration -} diff --git a/src/BlazorLocalization.Extractor/Domain/Calls/ChainedMethodCall.cs b/src/BlazorLocalization.Extractor/Domain/Calls/ChainedMethodCall.cs deleted file mode 100644 index dafdb4f..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Calls/ChainedMethodCall.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Calls; - -/// -/// A single method call in a fluent builder chain following a Translation() invocation, -/// e.g. .One("…"), .For("da"). -/// -public sealed record ChainedMethodCall( - string MethodName, - IReadOnlyList Arguments); diff --git a/src/BlazorLocalization.Extractor/Domain/Calls/ExtractedCall.cs b/src/BlazorLocalization.Extractor/Domain/Calls/ExtractedCall.cs deleted file mode 100644 index de5130f..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Calls/ExtractedCall.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Calls; - -/// -/// A fully resolved method call detected by the scanning engine, -/// carrying its source location, overload resolution outcome, and arguments. -/// -public sealed record ExtractedCall( - string ContainingTypeName, - string MethodName, - CallKind CallKind, - SourceLocation Location, - OverloadResolutionStatus OverloadResolution, - IReadOnlyList Arguments, - IReadOnlyList? FluentChain = null) -{ - /// - /// Returns a copy with file path made relative to . - /// - public ExtractedCall RelativizeLocation(string projectDir) => - this with { Location = Location.Relativize(projectDir) }; -} diff --git a/src/BlazorLocalization.Extractor/Domain/Calls/ObjectCreation.cs b/src/BlazorLocalization.Extractor/Domain/Calls/ObjectCreation.cs deleted file mode 100644 index c38b76f..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Calls/ObjectCreation.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Calls; - -/// -/// A constructor invocation detected inside a method argument, -/// e.g. new PluralSourceText("one", "other"). -/// -public sealed record ObjectCreation( - string TypeName, - IReadOnlyList ConstructorArguments); diff --git a/src/BlazorLocalization.Extractor/Domain/Calls/OverloadResolutionStatus.cs b/src/BlazorLocalization.Extractor/Domain/Calls/OverloadResolutionStatus.cs deleted file mode 100644 index 171439d..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Calls/OverloadResolutionStatus.cs +++ /dev/null @@ -1,18 +0,0 @@ -using System.ComponentModel; - -namespace BlazorLocalization.Extractor.Domain.Calls; - -/// -/// Outcome of Roslyn overload resolution for a method call. -/// -public enum OverloadResolutionStatus -{ - [Description("Roslyn resolved the symbol directly")] - Resolved, - - [Description("Multiple overload candidates matched β€” picked by argument count")] - Ambiguous, - - [Description("Resolution failed (missing types) β€” guessed by argument count")] - BestCandidate -} diff --git a/src/BlazorLocalization.Extractor/Domain/Calls/ResolvedArgument.cs b/src/BlazorLocalization.Extractor/Domain/Calls/ResolvedArgument.cs deleted file mode 100644 index 4a8fc76..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Calls/ResolvedArgument.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Calls; - -/// -/// A resolved argument in a method or constructor call, capturing -/// the raw source value, syntactic argument name, and resolved parameter name. -/// -public sealed record ResolvedArgument( - int Position, - string Value, - bool IsLiteral, - string? ArgumentName, - string? ParameterName, - ObjectCreation? ObjectCreation = null); diff --git a/src/BlazorLocalization.Extractor/Domain/Calls/SourceLocation.cs b/src/BlazorLocalization.Extractor/Domain/Calls/SourceLocation.cs deleted file mode 100644 index ddf5b43..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Calls/SourceLocation.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Calls; - -/// -/// Identifies a source code location by file path and line number. -/// -public sealed record SourceLocation(string FilePath, int Line, string ProjectName) -{ - /// - /// Returns a copy with made relative to , - /// using forward slashes for cross-platform consistency. - /// - public SourceLocation Relativize(string basePath) => - this with { FilePath = Path.GetRelativePath(basePath, FilePath).Replace('\\', '/') }; -} diff --git a/src/BlazorLocalization.Extractor/Domain/ConflictStrategy.cs b/src/BlazorLocalization.Extractor/Domain/ConflictStrategy.cs index d61b055..3c0eb2c 100644 --- a/src/BlazorLocalization.Extractor/Domain/ConflictStrategy.cs +++ b/src/BlazorLocalization.Extractor/Domain/ConflictStrategy.cs @@ -8,11 +8,11 @@ namespace BlazorLocalization.Extractor.Domain; /// public enum ConflictStrategy { - /// Keep the first-seen source text for the duplicate key. - [Description("Keep the first-seen source text for the key")] - First, + /// Keep the first-seen source text for the duplicate key. + [Description("Keep the first-seen source text for the key")] + First, - /// Omit the duplicate key from the export entirely. - [Description("Omit the duplicate key from the export")] - Skip + /// Omit the duplicate key from the export entirely. + [Description("Omit the duplicate key from the export")] + Skip } diff --git a/src/BlazorLocalization.Extractor/Domain/DefinitionKind.cs b/src/BlazorLocalization.Extractor/Domain/DefinitionKind.cs new file mode 100644 index 0000000..9335f21 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Domain/DefinitionKind.cs @@ -0,0 +1,20 @@ +namespace BlazorLocalization.Extractor.Domain; + +/// +/// How a translation's source text was defined. +/// Machine-readable discriminator β€” renderers switch on this, never parse . +/// +public enum DefinitionKind +{ + /// .Translation(key, message) in Razor/code β€” defines source text AND uses the key. + InlineTranslation, + + /// DefineSimple/Plural/Select/SelectPlural factory β€” defines source text only. + ReusableDefinition, + + /// [Translation("...")] on an enum member β€” defines source text only. + EnumAttribute, + + /// A .resx resource file entry β€” defines source text only. + ResourceFile +} diff --git a/src/BlazorLocalization.Extractor/Domain/Entries/LocaleDiscovery.cs b/src/BlazorLocalization.Extractor/Domain/Entries/LocaleDiscovery.cs deleted file mode 100644 index c089d47..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Entries/LocaleDiscovery.cs +++ /dev/null @@ -1,44 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Entries; - -/// -/// Discovers available locales from inline .For() translations across entries. -/// Single source of truth for locale enumeration β€” used by commands, renderers, and exporters. -/// -public static class LocaleDiscovery -{ - /// - /// Returns a sorted, deduplicated list of locale codes present in . - /// When is provided, only matching locales are returned. - /// - public static IReadOnlyList DiscoverLocales( - IReadOnlyList entries, - HashSet? localeFilter = null) - { - var locales = entries - .Where(e => e.InlineTranslations is not null) - .SelectMany(e => e.InlineTranslations!.Keys) - .Distinct(StringComparer.OrdinalIgnoreCase) - .Order(StringComparer.OrdinalIgnoreCase) - .ToList(); - - if (localeFilter is not null) - locales = locales.Where(l => localeFilter.Contains(l)).ToList(); - - return locales; - } - - /// - /// Rewrites entries so that the inline translation for - /// becomes the source text. Entries without a translation for that locale are excluded. - /// Used for per-locale file export and single-locale stdout output. - /// - public static IReadOnlyList EntriesForLocale( - IReadOnlyList entries, - string locale) - { - return entries - .Where(e => e.InlineTranslations is not null && e.InlineTranslations.ContainsKey(locale)) - .Select(e => new MergedTranslationEntry(e.Key, e.InlineTranslations![locale], e.Sources)) - .ToList(); - } -} diff --git a/src/BlazorLocalization.Extractor/Domain/Entries/MergedTranslationEntry.cs b/src/BlazorLocalization.Extractor/Domain/Entries/MergedTranslationEntry.cs deleted file mode 100644 index 834d54b..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Entries/MergedTranslationEntry.cs +++ /dev/null @@ -1,135 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Entries; - -/// -/// A translation entry after per-project deduplication. One entry per unique key, -/// with all source locations merged and conflicting definitions detected. -/// -public sealed record MergedTranslationEntry( - string Key, - TranslationSourceText? SourceText, - IReadOnlyList Sources, - IReadOnlyDictionary? InlineTranslations = null) -{ - /// - /// Returns a copy with all file paths made relative to . - /// - public MergedTranslationEntry RelativizeSources(string projectDir) => - this with { Sources = Sources.Select(s => s.Relativize(projectDir)).ToList() }; - - /// - /// Merges raw records into deduplicated entries. - /// Definitions ( non-null) must agree on source text for the same key. - /// References (null source text) yield to definitions. - /// - public static MergeResult FromRaw(IReadOnlyList entries) - { - var merged = new Dictionary(); - - foreach (var entry in entries) - { - if (!merged.TryGetValue(entry.Key, out var existing)) - { - merged[entry.Key] = new MergedBuilder(entry.SourceText, entry.Source, entry.InlineTranslations); - continue; - } - - existing.AddSource(entry.SourceText, entry.Source, entry.InlineTranslations); - } - - var result = merged.Select(kvp => - new MergedTranslationEntry(kvp.Key, kvp.Value.SourceText, kvp.Value.AllSources, - kvp.Value.MergedInlineTranslations)).ToList(); - - var conflicts = merged - .Where(kvp => kvp.Value.HasConflict) - .Select(kvp => new KeyConflict(kvp.Key, kvp.Value.ConflictingValues)) - .ToList(); - - return new MergeResult(result, conflicts); - } - - private sealed class MergedBuilder - { - /// First-seen definition source text (used for the merged entry). - public TranslationSourceText? SourceText { get; private set; } - - public List AllSources { get; } = []; - - /// All distinct source texts seen for this key, with their locations. - private Dictionary> ValueMap { get; } = new(); - - /// First-seen-per-locale inline translations. - private Dictionary? InlineMap { get; set; } - - public IReadOnlyDictionary? MergedInlineTranslations => - InlineMap is { Count: > 0 } ? InlineMap : null; - - public MergedBuilder( - TranslationSourceText? sourceText, - SourceReference source, - IReadOnlyDictionary? inlineTranslations) - { - SourceText = sourceText; - AllSources.Add(source); - if (sourceText is not null) - ValueMap[sourceText] = [source]; - MergeInlineTranslations(inlineTranslations); - } - - public void AddSource( - TranslationSourceText? sourceText, - SourceReference source, - IReadOnlyDictionary? inlineTranslations) - { - AllSources.Add(source); - - if (sourceText is not null) - { - SourceText ??= sourceText; - - if (ValueMap.TryGetValue(sourceText, out var sources)) - sources.Add(source); - else - ValueMap[sourceText] = [source]; - } - - MergeInlineTranslations(inlineTranslations); - } - - private void MergeInlineTranslations(IReadOnlyDictionary? inlineTranslations) - { - if (inlineTranslations is null) return; - - InlineMap ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var (locale, text) in inlineTranslations) - InlineMap.TryAdd(locale, text); // first-seen wins - } - - public bool HasConflict => ValueMap.Count > 1; - - public IReadOnlyList ConflictingValues => - ValueMap.Select(kvp => new ConflictingValue(kvp.Key, kvp.Value)).ToList(); - } -} - -/// -/// Result of merging raw translation entries: deduplicated entries plus any conflicts found. -/// -public sealed record MergeResult( - IReadOnlyList Entries, - IReadOnlyList Conflicts); - -/// -/// A key with two or more definitions that disagree on source text. -/// Each groups all locations sharing the same source text. -/// -public sealed record KeyConflict( - string Key, - IReadOnlyList Values); - -/// -/// One distinct source text for a conflicting key, with all locations that use it. -/// -public sealed record ConflictingValue( - TranslationSourceText SourceText, - IReadOnlyList Sources); diff --git a/src/BlazorLocalization.Extractor/Domain/Entries/SourceReference.cs b/src/BlazorLocalization.Extractor/Domain/Entries/SourceReference.cs deleted file mode 100644 index c3a8f2b..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Entries/SourceReference.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Entries; - -/// -/// Where in the source code a translation string was found. -/// Carries enough context for translators and auditors to trace back to the original usage. -/// -public sealed record SourceReference(string FilePath, int Line, string ProjectName, string? Context) -{ - /// - /// Returns a copy with made relative to , - /// using forward slashes for cross-platform consistency. - /// - public SourceReference Relativize(string basePath) => - this with { FilePath = Path.GetRelativePath(basePath, FilePath).Replace('\\', '/') }; -} diff --git a/src/BlazorLocalization.Extractor/Domain/Entries/TranslationEntry.cs b/src/BlazorLocalization.Extractor/Domain/Entries/TranslationEntry.cs deleted file mode 100644 index f800ca8..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Entries/TranslationEntry.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Entries; - -/// -/// A translation extracted from source code, ready for downstream export. -/// This is the boundary type between the scanning engine and format-specific exporters. -/// -/// The translation key (e.g. "Home.WelcomeMessage"). -/// The source text shape, or null for reference-only entries (indexer/GetString). -/// File path, line number, and project where the call was found. -/// -/// Per-locale inline translations from .For() calls. Key is the locale (e.g. "da", "es-MX"), -/// value is the source text shape for that locale (same discriminated union as ). -/// -public sealed record TranslationEntry( - string Key, - TranslationSourceText? SourceText, - SourceReference Source, - IReadOnlyDictionary? InlineTranslations = null); diff --git a/src/BlazorLocalization.Extractor/Domain/ExportFormat.cs b/src/BlazorLocalization.Extractor/Domain/ExportFormat.cs index 9ea5ce5..ad67688 100644 --- a/src/BlazorLocalization.Extractor/Domain/ExportFormat.cs +++ b/src/BlazorLocalization.Extractor/Domain/ExportFormat.cs @@ -1,22 +1,16 @@ -using System.ComponentModel; - namespace BlazorLocalization.Extractor.Domain; /// -/// Export format for extracted translation files. -/// [Description] attributes serve as single source of truth for both --help and interactive wizard prompts. +/// The export format for serializing translation data. /// public enum ExportFormat { - /// Crowdin i18next JSON β€” flat key/value pairs, plurals via _one/_other suffixes. - [Description("Crowdin i18next JSON (flat key/value, plurals via _one/_other)")] - I18Next, + /// Full-fidelity JSON array with all metadata. + Json, - /// GNU Gettext PO β€” msgid/msgstr pairs with #: source references and #. translator comments. - [Description("GNU Gettext PO (with source references and translator comments)")] - Po, + /// Flat i18next JSON for Crowdin upload. + I18Next, - /// Generic JSON β€” full-fidelity array with source references, useful for debugging and downstream tooling. - [Description("Generic JSON (full-fidelity debug export with all metadata)")] - Json + /// GNU Gettext PO template (.pot). + Po } diff --git a/src/BlazorLocalization.Extractor/Domain/MergedTranslation.cs b/src/BlazorLocalization.Extractor/Domain/MergedTranslation.cs new file mode 100644 index 0000000..19ee777 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Domain/MergedTranslation.cs @@ -0,0 +1,71 @@ +namespace BlazorLocalization.Extractor.Domain; + +/// +/// Per-key truth after merging all scanner outputs. +/// One per unique key, carrying every definition site, +/// every reference site, and the resolved source text (if any definition provided one). +/// +public sealed record MergedTranslation( + string Key, + TranslationSourceText? SourceText, + IReadOnlyList Definitions, + IReadOnlyList References, + IReadOnlyDictionary? InlineTranslations = null, + bool IsKeyLiteral = true) +{ + /// + /// Computes the cross-reference status from structural presence/absence. + /// No origin labels needed β€” the shape tells the story. + /// + public TranslationStatus Status + { + get + { + if (!IsKeyLiteral) + return TranslationStatus.Review; + + var hasDef = Definitions.Count > 0; + var hasRef = References.Count > 0 + || Definitions.Any(d => d.Kind == DefinitionKind.InlineTranslation); + + return (hasDef, hasRef) switch + { + (true, true) => TranslationStatus.Resolved, + (true, false) => TranslationStatus.Review, + (false, true) => TranslationStatus.Missing, + (false, false) => TranslationStatus.Review // shouldn't happen, but defensive + }; + } + } +} + +/// +/// Result of merging all scanner outputs: deduplicated entries, conflicts, and invalid entries. +/// +public sealed record MergeResult( + IReadOnlyList Entries, + IReadOnlyList Conflicts, + IReadOnlyList InvalidEntries, + IReadOnlyList Diagnostics); + +/// +/// A key with two or more definitions that disagree on source text. +/// +public sealed record KeyConflict( + string Key, + IReadOnlyList Values); + +/// +/// One distinct source text for a conflicting key, with all definition sites that use it. +/// +public sealed record ConflictingValue( + TranslationSourceText SourceText, + IReadOnlyList Sites); + +/// +/// An entry rejected during merge (e.g. empty key). +/// +public sealed record InvalidEntry( + string Key, + string Reason, + IReadOnlyList Sites); diff --git a/src/BlazorLocalization.Extractor/Domain/PathStyle.cs b/src/BlazorLocalization.Extractor/Domain/PathStyle.cs index 60602b2..4f35cfd 100644 --- a/src/BlazorLocalization.Extractor/Domain/PathStyle.cs +++ b/src/BlazorLocalization.Extractor/Domain/PathStyle.cs @@ -1,18 +1,13 @@ -using System.ComponentModel; - namespace BlazorLocalization.Extractor.Domain; /// /// Controls how source file paths are written in export output. -/// [Description] attributes serve as single source of truth for both --help and interactive wizard prompts. /// public enum PathStyle { - /// Paths relative to the project root directory (e.g. Components/Pages/Home.razor). - [Description("Paths relative to project root (recommended)")] - Relative, + /// Paths relative to the project root directory. + Relative, - /// Full absolute filesystem paths. - [Description("Full filesystem paths")] - Absolute + /// Full absolute filesystem paths. + Absolute } diff --git a/src/BlazorLocalization.Extractor/Domain/PoLimitation.cs b/src/BlazorLocalization.Extractor/Domain/PoLimitation.cs deleted file mode 100644 index 438350f..0000000 --- a/src/BlazorLocalization.Extractor/Domain/PoLimitation.cs +++ /dev/null @@ -1,47 +0,0 @@ -using BlazorLocalization.Extractor.Domain.Entries; - -namespace BlazorLocalization.Extractor.Domain; - -/// -/// A PO format limitation detected for a specific translation key. -/// -public sealed record PoLimitation(string Key, string Limitation) -{ - /// - /// Scans merged entries for features that PO cannot fully represent. - /// - public static IReadOnlyList Detect(IReadOnlyList entries) - { - var limitations = new List(); - - foreach (var entry in entries) - DetectInSourceText(limitations, entry.Key, entry.SourceText); - - return limitations; - } - - private static void DetectInSourceText(List limitations, string key, TranslationSourceText? sourceText) - { - switch (sourceText) - { - case PluralText p: - DetectInPlural(limitations, key, p); - break; - case SelectPluralText sp: - foreach (var (caseValue, plural) in sp.Cases) - DetectInPlural(limitations, $"{key}_{caseValue}", plural); - if (sp.Otherwise is not null) - DetectInPlural(limitations, key, sp.Otherwise); - break; - } - } - - private static void DetectInPlural(List limitations, string key, PluralText p) - { - if (p.ExactMatches is { Count: > 0 }) - limitations.Add(new(key, $"Exact matches ({string.Join(", ", p.ExactMatches.Keys.Select(k => $"={k}"))}) exported as separate _exactly_N keys")); - - if (p.IsOrdinal) - limitations.Add(new(key, "Ordinal flag exported as comment only β€” translators may overlook it")); - } -} diff --git a/src/BlazorLocalization.Extractor/Domain/Requests/ExtractRequest.cs b/src/BlazorLocalization.Extractor/Domain/Requests/ExtractRequest.cs deleted file mode 100644 index b74edc3..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Requests/ExtractRequest.cs +++ /dev/null @@ -1,81 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Requests; - -/// -/// Pure value object capturing all resolved inputs for the extract command. -/// Built from CLI settings after path resolution, validated before execution. -/// -public sealed record ExtractRequest( - IReadOnlyList ProjectDirs, - ExportFormat Format, - OutputTarget Output, - HashSet? LocaleFilter, - bool SourceOnly, - PathStyle PathStyle, - bool Verbose, - bool ExitOnDuplicateKey, - ConflictStrategy OnDuplicateKey) -{ - /// - /// Returns an empty list when all inputs are consistent, or a list of human-readable error messages. - /// - public IReadOnlyList Validate() - { - var errors = new List(); - - if (ProjectDirs.Count == 0) - errors.Add("No .csproj projects found. Check that the path contains .NET projects."); - - if (SourceOnly && LocaleFilter is not null) - errors.Add("--source-only and --locale cannot be used together."); - - if (Output is OutputTarget.StdoutTarget) - { - if (ProjectDirs.Count > 1) - errors.Add("Multiple projects found. Use -o, or specify a single .csproj."); - - if (LocaleFilter is { Count: > 1 }) - errors.Add("Stdout supports one locale at a time. Use -o for multiple locales."); - } - - if (Output is OutputTarget.FileTarget && ProjectDirs.Count > 1) - errors.Add("Multiple projects require -o , not a single file."); - - return errors; - } -} - -/// -/// Where extracted output should go: stdout, a single file, or a directory (per-project/per-locale files). -/// -public abstract record OutputTarget -{ - private OutputTarget() { } - - /// Output to stdout in the requested format. - public static readonly OutputTarget Stdout = new StdoutTarget(); - - /// Output to a single file. - public static OutputTarget File(string path) => new FileTarget(path); - - /// Output to a directory (one file per project and/or locale). - public static OutputTarget Dir(string path) => new DirTarget(path); - - public sealed record StdoutTarget : OutputTarget; - public sealed record FileTarget(string Path) : OutputTarget; - public sealed record DirTarget(string Path) : OutputTarget; - - /// - /// Determines the output target from the raw -o value. - /// Pure logic only β€” does not touch the filesystem. - /// - public static OutputTarget FromRawOutput(string? output) - { - if (output is null) - return Stdout; - - if (System.IO.Path.HasExtension(output)) - return File(output); - - return Dir(output); - } -} diff --git a/src/BlazorLocalization.Extractor/Domain/Requests/InspectRequest.cs b/src/BlazorLocalization.Extractor/Domain/Requests/InspectRequest.cs deleted file mode 100644 index 27e505f..0000000 --- a/src/BlazorLocalization.Extractor/Domain/Requests/InspectRequest.cs +++ /dev/null @@ -1,29 +0,0 @@ -namespace BlazorLocalization.Extractor.Domain.Requests; - -/// -/// Pure value object capturing all resolved inputs for the inspect command. -/// Built from CLI settings after path resolution, validated before execution. -/// -public sealed record InspectRequest( - IReadOnlyList ProjectDirs, - bool JsonOutput, - HashSet? LocaleFilter, - bool SourceOnly, - PathStyle PathStyle) -{ - /// - /// Returns an empty list when all inputs are consistent, or a list of human-readable error messages. - /// - public IReadOnlyList Validate() - { - var errors = new List(); - - if (ProjectDirs.Count == 0) - errors.Add("No .csproj projects found. Check that the path contains .NET projects."); - - if (SourceOnly && LocaleFilter is not null) - errors.Add("--source-only and --locale cannot be used together."); - - return errors; - } -} diff --git a/src/BlazorLocalization.Extractor/Domain/ScanResult.cs b/src/BlazorLocalization.Extractor/Domain/ScanResult.cs deleted file mode 100644 index 80b6712..0000000 --- a/src/BlazorLocalization.Extractor/Domain/ScanResult.cs +++ /dev/null @@ -1,12 +0,0 @@ -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; - -namespace BlazorLocalization.Extractor.Domain; - -/// -/// Composite result from : raw Roslyn call data for debug/inspect, -/// plus semantically interpreted records for export. -/// -public sealed record ScanResult( - IReadOnlyList Calls, - IReadOnlyList Entries); diff --git a/src/BlazorLocalization.Extractor/Domain/Sites.cs b/src/BlazorLocalization.Extractor/Domain/Sites.cs new file mode 100644 index 0000000..78bc45b --- /dev/null +++ b/src/BlazorLocalization.Extractor/Domain/Sites.cs @@ -0,0 +1,7 @@ +namespace BlazorLocalization.Extractor.Domain; + +/// Where a translation definition was found. +public sealed record DefinitionSite(SourceFilePath File, int Line, DefinitionKind Kind, string? Context = null); + +/// Where a translation reference (key usage) was found. +public sealed record ReferenceSite(SourceFilePath File, int Line, string? Context = null); diff --git a/src/BlazorLocalization.Extractor/Domain/SourceFilePath.cs b/src/BlazorLocalization.Extractor/Domain/SourceFilePath.cs new file mode 100644 index 0000000..6a5ac4f --- /dev/null +++ b/src/BlazorLocalization.Extractor/Domain/SourceFilePath.cs @@ -0,0 +1,29 @@ +namespace BlazorLocalization.Extractor.Domain; + +/// +/// Immutable wrapper around a source file reference that always carries the absolute path. +/// Presentation (relative vs absolute) is a method call β€” never a mutation. +/// +public sealed record SourceFilePath(string AbsolutePath, string ProjectDir) +{ + /// Path relative to , using forward slashes. + public string RelativePath => + Path.GetRelativePath(ProjectDir, AbsolutePath).Replace('\\', '/'); + + /// File name with extension (e.g. Home.razor). + public string FileName => Path.GetFileName(AbsolutePath); + + /// Project directory name (e.g. MudBlazorServerSample). + public string ProjectName => Path.GetFileName(ProjectDir); + + /// Whether this file is a .resx file. + public bool IsResx => AbsolutePath.EndsWith(".resx", StringComparison.OrdinalIgnoreCase); + + /// Returns the path formatted for export output. + public string Display(PathStyle style) => style switch + { + PathStyle.Relative => RelativePath, + PathStyle.Absolute => AbsolutePath, + _ => RelativePath + }; +} diff --git a/src/BlazorLocalization.Extractor/Domain/TranslationDefinition.cs b/src/BlazorLocalization.Extractor/Domain/TranslationDefinition.cs new file mode 100644 index 0000000..0ba95ee --- /dev/null +++ b/src/BlazorLocalization.Extractor/Domain/TranslationDefinition.cs @@ -0,0 +1,11 @@ +namespace BlazorLocalization.Extractor.Domain; + +/// +/// A place where translation source text IS defined. +/// Produced by scanners (Roslyn adapter from .Translation() calls, Resx adapter from .resx files). +/// +public sealed record TranslationDefinition( + string Key, + TranslationSourceText SourceText, + DefinitionSite Site, + IReadOnlyDictionary? InlineTranslations = null); diff --git a/src/BlazorLocalization.Extractor/Domain/TranslationForm.cs b/src/BlazorLocalization.Extractor/Domain/TranslationForm.cs new file mode 100644 index 0000000..d07fa72 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Domain/TranslationForm.cs @@ -0,0 +1,28 @@ +namespace BlazorLocalization.Extractor.Domain; + +/// +/// Classifies the linguistic form of a for display purposes. +/// Presentation-layer helper β€” not a domain concept. +/// +public enum TranslationForm +{ + Simple, + Plural, + Ordinal, + Select, + SelectPlural +} + +public static class TranslationFormExtensions +{ + public static TranslationForm? From(TranslationSourceText? sourceText) => + sourceText switch + { + SingularText => TranslationForm.Simple, + PluralText p => p.IsOrdinal ? TranslationForm.Ordinal : TranslationForm.Plural, + SelectText => TranslationForm.Select, + SelectPluralText => TranslationForm.SelectPlural, + null => null, + _ => null + }; +} diff --git a/src/BlazorLocalization.Extractor/Domain/TranslationReference.cs b/src/BlazorLocalization.Extractor/Domain/TranslationReference.cs new file mode 100644 index 0000000..edb72d9 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Domain/TranslationReference.cs @@ -0,0 +1,10 @@ +namespace BlazorLocalization.Extractor.Domain; + +/// +/// A place where a translation key IS used (call site). +/// Produced by code scanners (e.g. localizer["key"], localizer.GetString("key")). +/// +public sealed record TranslationReference( + string Key, + bool IsLiteral, + ReferenceSite Site); diff --git a/src/BlazorLocalization.Extractor/Domain/Entries/TranslationSourceText.cs b/src/BlazorLocalization.Extractor/Domain/TranslationSourceText.cs similarity index 96% rename from src/BlazorLocalization.Extractor/Domain/Entries/TranslationSourceText.cs rename to src/BlazorLocalization.Extractor/Domain/TranslationSourceText.cs index 4076d37..ed3f09d 100644 --- a/src/BlazorLocalization.Extractor/Domain/Entries/TranslationSourceText.cs +++ b/src/BlazorLocalization.Extractor/Domain/TranslationSourceText.cs @@ -1,4 +1,4 @@ -namespace BlazorLocalization.Extractor.Domain.Entries; +namespace BlazorLocalization.Extractor.Domain; /// /// The source-language text for a translation key. diff --git a/src/BlazorLocalization.Extractor/Domain/TranslationStatus.cs b/src/BlazorLocalization.Extractor/Domain/TranslationStatus.cs new file mode 100644 index 0000000..d525b0b --- /dev/null +++ b/src/BlazorLocalization.Extractor/Domain/TranslationStatus.cs @@ -0,0 +1,16 @@ +namespace BlazorLocalization.Extractor.Domain; + +/// +/// Health status of a translation key after cross-referencing definitions and references. +/// +public enum TranslationStatus +{ + /// Source text found and code reference confirmed. + Resolved, + + /// Needs manual review (definition-only, or dynamic key that can't be verified). + Review, + + /// Code references this key but no source text was found anywhere. + Missing +} diff --git a/src/BlazorLocalization.Extractor/Exporters/ExporterFactory.cs b/src/BlazorLocalization.Extractor/Exporters/ExporterFactory.cs deleted file mode 100644 index 1de42bc..0000000 --- a/src/BlazorLocalization.Extractor/Exporters/ExporterFactory.cs +++ /dev/null @@ -1,34 +0,0 @@ -using BlazorLocalization.Extractor.Domain; - -namespace BlazorLocalization.Extractor.Exporters; - -/// -/// Maps to the corresponding implementation and file extension. -/// -public static class ExporterFactory -{ - private static readonly Dictionary FileExtensions = new() - { - [ExportFormat.Json] = ".json", - [ExportFormat.I18Next] = ".i18next.json", - [ExportFormat.Po] = ".po" - }; - - /// - /// Creates the exporter implementation for the given . - /// - public static ITranslationExporter Create(ExportFormat format) => - format switch - { - ExportFormat.Json => new GenericJsonExporter(), - ExportFormat.I18Next => new I18NextJsonExporter(), - ExportFormat.Po => new PoExporter(), - _ => throw new InvalidOperationException($"Unknown format: {format}") - }; - - /// - /// Returns the file extension (including leading dot) for the given . - /// - public static string GetFileExtension(ExportFormat format) => - FileExtensions[format]; -} diff --git a/src/BlazorLocalization.Extractor/Exporters/GenericJsonExporter.cs b/src/BlazorLocalization.Extractor/Exporters/GenericJsonExporter.cs deleted file mode 100644 index bf6809f..0000000 --- a/src/BlazorLocalization.Extractor/Exporters/GenericJsonExporter.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Text.Unicode; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; - -namespace BlazorLocalization.Extractor.Exporters; - -/// -/// Exports records as a JSON array of objects β€” a 1:1 serialization -/// of the domain model, useful for debugging and downstream tooling that needs full fidelity. -/// -public sealed class GenericJsonExporter : ITranslationExporter -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) - }; - - public string Export(IReadOnlyList entries) - { - var dto = entries.Select(ToDto).ToList(); - return JsonSerializer.Serialize(dto, JsonOptions); - } - - private static EntryDto ToDto(MergedTranslationEntry entry) => new() - { - Key = entry.Key, - SourceText = MapSourceText(entry.SourceText), - InlineTranslations = entry.InlineTranslations?.ToDictionary( - kvp => kvp.Key, - kvp => MapSourceText(kvp.Value)), - Sources = entry.Sources.Select(s => new SourceDto - { - FilePath = s.FilePath, - Line = s.Line, - ProjectName = s.ProjectName, - Context = s.Context - }).ToList() - }; - - private static SourceTextDto? MapSourceText(TranslationSourceText? text) => text switch - { - SingularText s => new SourceTextDto - { - Type = "singular", - Value = s.Value - }, - PluralText p => new SourceTextDto - { - Type = "plural", - Other = p.Other, - Zero = p.Zero, - One = p.One, - Two = p.Two, - Few = p.Few, - Many = p.Many, - ExactMatches = p.ExactMatches?.ToDictionary(kvp => kvp.Key.ToString(), kvp => kvp.Value), - IsOrdinal = p.IsOrdinal ? true : null - }, - SelectText s => new SourceTextDto - { - Type = "select", - Cases = s.Cases.ToDictionary(kvp => kvp.Key, kvp => kvp.Value), - Otherwise = s.Otherwise - }, - SelectPluralText sp => new SourceTextDto - { - Type = "selectPlural", - PluralCases = sp.Cases.ToDictionary( - kvp => kvp.Key, - kvp => MapSourceText(kvp.Value)), - OtherwisePlural = sp.Otherwise is not null ? MapSourceText(sp.Otherwise) : null - }, - _ => null - }; - - private sealed class EntryDto - { - public required string Key { get; init; } - public SourceTextDto? SourceText { get; init; } - public Dictionary? InlineTranslations { get; init; } - public required List Sources { get; init; } - } - - private sealed class SourceTextDto - { - public required string Type { get; init; } - public string? Value { get; init; } - public string? Other { get; init; } - public string? Zero { get; init; } - public string? One { get; init; } - public string? Two { get; init; } - public string? Few { get; init; } - public string? Many { get; init; } - public Dictionary? ExactMatches { get; init; } - public bool? IsOrdinal { get; init; } - public Dictionary? Cases { get; init; } - public string? Otherwise { get; init; } - public Dictionary? PluralCases { get; init; } - public SourceTextDto? OtherwisePlural { get; init; } - } - - private sealed class SourceDto - { - public required string FilePath { get; init; } - public int Line { get; init; } - public required string ProjectName { get; init; } - public string? Context { get; init; } - } -} diff --git a/src/BlazorLocalization.Extractor/Exporters/I18NextJsonExporter.cs b/src/BlazorLocalization.Extractor/Exporters/I18NextJsonExporter.cs deleted file mode 100644 index 201a0e7..0000000 --- a/src/BlazorLocalization.Extractor/Exporters/I18NextJsonExporter.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Text.Encodings.Web; -using System.Text.Json; -using System.Text.Unicode; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; - -namespace BlazorLocalization.Extractor.Exporters; - -/// -/// Exports records as i18next JSON for Crowdin upload. -/// Plural keys use the _one / _other suffix convention. -/// Key-only entries (no source text) emit an empty string value so Crowdin displays them as untranslated. -/// -/// -/// Crowdin API type: i18next_json. -/// Crowdin i18next JSON format: https://store.crowdin.com/i18next-json -/// -public sealed class I18NextJsonExporter : ITranslationExporter -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) - }; - - public string Export(IReadOnlyList entries) - { - var dict = new Dictionary(); - - foreach (var entry in entries) - EmitEntry(dict, entry.Key, entry.SourceText); - - return JsonSerializer.Serialize(dict, JsonOptions); - } - - private static void EmitEntry(Dictionary dict, string key, TranslationSourceText? sourceText) - { - switch (sourceText) - { - case PluralText p: - EmitPluralText(dict, key, p); - break; - - case SelectText s: - foreach (var (caseValue, message) in s.Cases) - dict[$"{key}_{caseValue}"] = message; - if (s.Otherwise is not null) - dict[key] = s.Otherwise; - break; - - case SelectPluralText sp: - foreach (var (caseValue, plural) in sp.Cases) - EmitPluralText(dict, $"{key}_{caseValue}", plural); - if (sp.Otherwise is not null) - EmitPluralText(dict, key, sp.Otherwise); - break; - - case SingularText s: - dict[key] = s.Value; - break; - - default: - dict[key] = ""; - break; - } - } - - private static void EmitPluralText(Dictionary dict, string key, PluralText p) - { - if (p.Zero is not null) dict[$"{key}_zero"] = p.Zero; - if (p.One is not null) dict[$"{key}_one"] = p.One; - if (p.Two is not null) dict[$"{key}_two"] = p.Two; - if (p.Few is not null) dict[$"{key}_few"] = p.Few; - if (p.Many is not null) dict[$"{key}_many"] = p.Many; - dict[$"{key}_other"] = p.Other; - - if (p.ExactMatches is not null) - foreach (var (value, message) in p.ExactMatches) - dict[$"{key}_exactly_{value}"] = message; - } -} diff --git a/src/BlazorLocalization.Extractor/Exporters/ITranslationExporter.cs b/src/BlazorLocalization.Extractor/Exporters/ITranslationExporter.cs deleted file mode 100644 index 3549b0f..0000000 --- a/src/BlazorLocalization.Extractor/Exporters/ITranslationExporter.cs +++ /dev/null @@ -1,12 +0,0 @@ -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; - -namespace BlazorLocalization.Extractor.Exporters; - -/// -/// Serializes records into a format-specific string. -/// -public interface ITranslationExporter -{ - string Export(IReadOnlyList entries); -} diff --git a/src/BlazorLocalization.Extractor/Exporters/PoExporter.cs b/src/BlazorLocalization.Extractor/Exporters/PoExporter.cs deleted file mode 100644 index 03b6790..0000000 --- a/src/BlazorLocalization.Extractor/Exporters/PoExporter.cs +++ /dev/null @@ -1,134 +0,0 @@ -using System.Text; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; - -namespace BlazorLocalization.Extractor.Exporters; - -/// -/// Exports records as a GNU Gettext PO template (.pot). -/// -/// -/// Crowdin API type: gettext. -/// #: reference comments carry file path and line number. -/// #. extracted comments carry the calling method context. -/// Crowdin GNU Gettext PO format: https://store.crowdin.com/gnu-gettext -/// -public sealed class PoExporter : ITranslationExporter -{ - public string Export(IReadOnlyList entries) - { - var sb = new StringBuilder(); - WriteHeader(sb); - - foreach (var entry in entries) - { - WriteReferenceComments(sb, entry.Sources); - WriteEntry(sb, entry); - sb.AppendLine(); - } - - return sb.ToString(); - } - - private static void WriteHeader(StringBuilder sb) - { - sb.AppendLine("msgid \"\""); - sb.AppendLine("msgstr \"\""); - sb.AppendLine("\"MIME-Version: 1.0\\n\""); - sb.AppendLine("\"Content-Type: text/plain; charset=UTF-8\\n\""); - sb.AppendLine("\"Content-Transfer-Encoding: 8bit\\n\""); - sb.AppendLine("\"Plural-Forms: nplurals=2; plural=(n != 1);\\n\""); - sb.AppendLine("\"X-Crowdin-SourceKey: msgstr\\n\""); - sb.AppendLine(); - } - - private static void WriteReferenceComments(StringBuilder sb, IReadOnlyList sources) - { - foreach (var src in sources) - { - sb.AppendLine($"#: {src.FilePath}:{src.Line}"); - if (src.Context is not null) - sb.AppendLine($"#. {src.Context}"); - } - } - - private static void WriteEntry(StringBuilder sb, MergedTranslationEntry entry) - { - switch (entry.SourceText) - { - case PluralText p: - WritePluralEntries(sb, entry.Key, p); - break; - - case SelectText s: - foreach (var (caseValue, message) in s.Cases) - { - sb.AppendLine($"msgid \"{Escape(entry.Key + "_" + caseValue)}\""); - sb.AppendLine($"msgstr \"{Escape(message)}\""); - sb.AppendLine(); - } - if (s.Otherwise is not null) - { - sb.AppendLine($"msgid \"{Escape(entry.Key)}\""); - sb.AppendLine($"msgstr \"{Escape(s.Otherwise)}\""); - } - else - { - sb.AppendLine($"msgid \"{Escape(entry.Key)}\""); - sb.AppendLine("msgstr \"\""); - } - break; - - case SelectPluralText sp: - foreach (var (caseValue, plural) in sp.Cases) - { - WritePluralEntries(sb, $"{entry.Key}_{caseValue}", plural); - sb.AppendLine(); - } - if (sp.Otherwise is not null) - WritePluralEntries(sb, entry.Key, sp.Otherwise); - break; - - case SingularText s: - sb.AppendLine($"msgid \"{Escape(entry.Key)}\""); - sb.AppendLine($"msgstr \"{Escape(s.Value)}\""); - break; - - default: - sb.AppendLine($"msgid \"{Escape(entry.Key)}\""); - sb.AppendLine("msgstr \"\""); - break; - } - } - - /// - /// Emits a plural entry as native PO one/other, plus separate suffixed keys for exact value - /// matches. Rare CLDR categories (zero, two, few, many) are handled by Crowdin per target language. - /// - private static void WritePluralEntries(StringBuilder sb, string key, PluralText p) - { - if (p.IsOrdinal) - sb.AppendLine("#. ⚠️ ORDINAL β€” use ordinal forms (1st, 2nd, 3rd), not cardinal (1, 2, 3)"); - - sb.AppendLine($"msgid \"{Escape(key)}\""); - sb.AppendLine($"msgid_plural \"{Escape(key)}\""); - sb.AppendLine($"msgstr[0] \"{Escape(p.One ?? p.Other)}\""); - sb.AppendLine($"msgstr[1] \"{Escape(p.Other)}\""); - - if (p.ExactMatches is not null) - { - foreach (var (value, message) in p.ExactMatches) - { - sb.AppendLine(); - sb.AppendLine($"msgid \"{Escape($"{key}_exactly_{value}")}\""); - sb.AppendLine($"msgstr \"{Escape(message)}\""); - } - } - } - - private static string Escape(string text) => - text.Replace("\\", "\\\\") - .Replace("\"", "\\\"") - .Replace("\r", "\\r") - .Replace("\n", "\\n"); -} diff --git a/src/BlazorLocalization.Extractor/Ports/IScannerOutput.cs b/src/BlazorLocalization.Extractor/Ports/IScannerOutput.cs new file mode 100644 index 0000000..8e28da2 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Ports/IScannerOutput.cs @@ -0,0 +1,19 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Ports; + +/// +/// The contract any scanner adapter implements to deliver data to the domain. +/// Roslyn scanner, Resx importer, or any future adapter β€” all produce this shape. +/// +public interface IScannerOutput +{ + /// Places where translation source text is defined. + IReadOnlyList Definitions { get; } + + /// Places where translation keys are used (call sites). + IReadOnlyList References { get; } + + /// Issues the scanner encountered during analysis. + IReadOnlyList Diagnostics { get; } +} diff --git a/src/BlazorLocalization.Extractor/Ports/ScanDiagnostic.cs b/src/BlazorLocalization.Extractor/Ports/ScanDiagnostic.cs new file mode 100644 index 0000000..00f1610 --- /dev/null +++ b/src/BlazorLocalization.Extractor/Ports/ScanDiagnostic.cs @@ -0,0 +1,18 @@ +using BlazorLocalization.Extractor.Domain; + +namespace BlazorLocalization.Extractor.Ports; + +/// +/// A problem a scanner encountered during analysis. +/// +public sealed record ScanDiagnostic( + DiagnosticLevel Level, + string Message, + SourceFilePath? File = null, + int? Line = null); + +public enum DiagnosticLevel +{ + Warning, + Error +} diff --git a/src/BlazorLocalization.Extractor/Program.cs b/src/BlazorLocalization.Extractor/Program.cs index 3588fff..b157007 100644 --- a/src/BlazorLocalization.Extractor/Program.cs +++ b/src/BlazorLocalization.Extractor/Program.cs @@ -1,31 +1,31 @@ -ο»Ώusing System.Reflection; -using BlazorLocalization.Extractor.Cli; -using BlazorLocalization.Extractor.Cli.Commands; +using System.Reflection; +using BlazorLocalization.Extractor.Adapters.Cli; +using BlazorLocalization.Extractor.Adapters.Cli.Commands; using Spectre.Console.Cli; var version = typeof(Program).Assembly - .GetCustomAttribute() - ?.InformationalVersion.Split('+')[0] ?? "unknown"; + .GetCustomAttribute() + ?.InformationalVersion.Split('+')[0] ?? "unknown"; var app = new CommandApp(); app.Configure(config => { - config.SetApplicationName("blazor-loc"); - config.SetApplicationVersion(version); + config.SetApplicationName("blazor-loc"); + config.SetApplicationVersion(version); - config.AddCommand("extract") - .WithDescription("Scan Blazor projects for IStringLocalizer usage, extract source strings, and export to a translation file (i18next JSON, PO, or generic JSON).") - .WithExample("extract", "./src", "-f", "i18next", "-o", "./translations") - .WithExample("extract", "./src", "--format", "po", "--output", "./locale"); + config.AddCommand("extract") + .WithDescription("Scan your projects for translation strings and export to translation files (i18next JSON, PO, or generic JSON).") + .WithExample("extract", "./src", "-f", "i18next", "-o", "./translations") + .WithExample("extract", "./src", "--format", "po", "--output", "./locale"); - config.AddCommand("inspect") - .WithDescription("Show all detected IStringLocalizer calls and the resulting translation entries. Useful for debugging what the scanner sees before exporting.") - .WithExample("inspect", "./src") - .WithExample("inspect", "./src", "./lib/Shared"); + config.AddCommand("inspect") + .WithDescription("Check your translation setup: every key with its status, locale coverage, and potential issues.") + .WithExample("inspect", "./src") + .WithExample("inspect", "./src", "./lib/Shared"); }); if (args.Length == 0) - args = InteractiveWizard.Run(); + args = InteractiveWizard.Run(); -return app.Run(args); \ No newline at end of file +return app.Run(args); diff --git a/src/BlazorLocalization.Extractor/Scanning/BuilderSymbolTable.cs b/src/BlazorLocalization.Extractor/Scanning/BuilderSymbolTable.cs deleted file mode 100644 index 9e18999..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/BuilderSymbolTable.cs +++ /dev/null @@ -1,665 +0,0 @@ -using System.Reflection; -using BlazorLocalization.Extensions.Translation; -using BlazorLocalization.Extensions.Translation.Definitions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using StringLocalizerExtensions = BlazorLocalization.Extensions.StringLocalizerExtensions; - -namespace BlazorLocalization.Extractor.Scanning; - -/// -/// Pre-resolves all BlazorLocalization.Extensions types and methods as Roslyn symbols, -/// using typeof(), nameof(), and reflection. Zero hardcoded strings β€” -/// the compiler and reflection enforce the contract. -/// -/// -/// Roslyn and reflection describe the same Extensions DLL: typeof(PluralBuilder).FullName! -/// is the bridge key for CSharpCompilation.GetTypeByMetadataName(). Parameter names -/// come from MethodInfo.GetParameters(), not string literals. -/// -public sealed class BuilderSymbolTable -{ - // ── Builder type symbols ────────────────────────────────────────── - - public INamedTypeSymbol SimpleBuilder { get; } - public INamedTypeSymbol PluralBuilder { get; } - public INamedTypeSymbol SelectBuilder { get; } - public INamedTypeSymbol SelectPluralBuilder { get; } - - // ── Definition type symbols ────────────────────────────────── - - public INamedTypeSymbol SimpleDefinition { get; } - public INamedTypeSymbol PluralDefinition { get; } - public INamedTypeSymbol SelectDefinition { get; } - public INamedTypeSymbol SelectPluralDefinition { get; } - - // ── Definition factory class ───────────────────────────────── - - public INamedTypeSymbol DefinitionFactoryClass { get; } - - // ── Attribute type symbol ───────────────────────────────────────── - - public INamedTypeSymbol TranslationAttribute { get; } - - /// Property name for TranslationAttribute.Message (constructor parameter). - public string TranslationMessageParam { get; } = nameof(Extensions.TranslationAttribute.Message); - - /// Property name for TranslationAttribute.Locale (named argument). - public string TranslationLocaleParam { get; } = nameof(Extensions.TranslationAttribute.Locale); - - /// Property name for TranslationAttribute.Key (named argument). - public string TranslationKeyParam { get; } = nameof(Extensions.TranslationAttribute.Key); - - // ── Method symbols: PluralBuilder ───────────────────────────────── - - public IMethodSymbol PluralFor { get; } - public IMethodSymbol PluralExactly { get; } - public IMethodSymbol PluralZero { get; } - public IMethodSymbol PluralOne { get; } - public IMethodSymbol PluralTwo { get; } - public IMethodSymbol PluralFew { get; } - public IMethodSymbol PluralMany { get; } - public IMethodSymbol PluralOther { get; } - - /// All CLDR category methods on PluralBuilder: Zero, One, Two, Few, Many, Other. - public HashSet PluralCategoryMethods { get; } - - // ── Method symbols: PluralDefinition ───────────────────────────── - - public IMethodSymbol PluralDefFor { get; } - public IMethodSymbol PluralDefExactly { get; } - public IMethodSymbol PluralDefZero { get; } - public IMethodSymbol PluralDefOne { get; } - public IMethodSymbol PluralDefTwo { get; } - public IMethodSymbol PluralDefFew { get; } - public IMethodSymbol PluralDefMany { get; } - public IMethodSymbol PluralDefOther { get; } - - /// All CLDR category methods on PluralDefinition: Zero, One, Two, Few, Many, Other. - public HashSet PluralDefCategoryMethods { get; } - - // ── Method symbols: SimpleBuilder ───────────────────────────────── - - public IMethodSymbol SimpleFor { get; } - - // ── Method symbols: SimpleDefinition ────────────────────────────── - - public IMethodSymbol SimpleDefFor { get; } - - // ── Method symbols: SelectBuilder<> ─────────────────────────────── - - public IMethodSymbol SelectWhen { get; } - public IMethodSymbol SelectOtherwise { get; } - public IMethodSymbol SelectFor { get; } - - // ── Method symbols: SelectPluralBuilder<> ───────────────────────── - - public IMethodSymbol SelectPluralWhen { get; } - public IMethodSymbol SelectPluralOtherwise { get; } - public IMethodSymbol SelectPluralFor { get; } - public IMethodSymbol SelectPluralExactly { get; } - public IMethodSymbol SelectPluralZero { get; } - public IMethodSymbol SelectPluralOne { get; } - public IMethodSymbol SelectPluralTwo { get; } - public IMethodSymbol SelectPluralFew { get; } - public IMethodSymbol SelectPluralMany { get; } - public IMethodSymbol SelectPluralOther { get; } - - /// All CLDR category methods on SelectPluralBuilder: Zero, One, Two, Few, Many, Other. - public HashSet SelectPluralCategoryMethods { get; } - - // ── Method symbols: SelectDefinition<> ─────────────────────────── - - public IMethodSymbol SelectDefWhen { get; } - public IMethodSymbol SelectDefOtherwise { get; } - public IMethodSymbol SelectDefFor { get; } - - // ── Method symbols: SelectPluralDefinition<> ──────────────────── - - public IMethodSymbol SelectPluralDefWhen { get; } - public IMethodSymbol SelectPluralDefOtherwise { get; } - public IMethodSymbol SelectPluralDefFor { get; } - public IMethodSymbol SelectPluralDefExactly { get; } - public IMethodSymbol SelectPluralDefZero { get; } - public IMethodSymbol SelectPluralDefOne { get; } - public IMethodSymbol SelectPluralDefTwo { get; } - public IMethodSymbol SelectPluralDefFew { get; } - public IMethodSymbol SelectPluralDefMany { get; } - public IMethodSymbol SelectPluralDefOther { get; } - - /// All CLDR category methods on SelectPluralDefinition: Zero, One, Two, Few, Many, Other. - public HashSet SelectPluralDefCategoryMethods { get; } - - // ── Combined category sets (Builder + DefinitionBuilder) ───────── - - /// All CLDR category methods on PluralBuilder and PluralDefinition. - public HashSet AllPluralCategoryMethods { get; } - - /// All CLDR category methods on SelectPluralBuilder and SelectPluralDefinition. - public HashSet AllSelectPluralCategoryMethods { get; } - - // ── Definition factory method symbols ──────────────────────── - - public IMethodSymbol DefineSimple { get; } - public IMethodSymbol DefinePlural { get; } - public IMethodSymbol DefineSelect { get; } - public IMethodSymbol DefineSelectPlural { get; } - - // ── Reflection-derived parameter names ──────────────────────────── - - /// Parameter name for "message" on category methods (e.g. One(string message)). - public string CategoryMessageParam { get; } - - /// Parameter name for "locale" on For(string locale). - public string PluralForLocaleParam { get; } - - /// Parameter name for "value" on Exactly(int value, string message). - public string ExactlyValueParam { get; } - - /// Parameter name for "message" on Exactly(int value, string message). - public string ExactlyMessageParam { get; } - - /// Parameter name for "locale" on SimpleBuilder.For(string locale, string message). - public string SimpleForLocaleParam { get; } - - /// Parameter name for "message" on SimpleBuilder.For(string locale, string message). - public string SimpleForMessageParam { get; } - - /// Parameter name for "select" on SelectBuilder.When(TSelect select, string message). - public string SelectWhenSelectParam { get; } - - /// Parameter name for "message" on SelectBuilder.When(TSelect select, string message). - public string SelectWhenMessageParam { get; } - - /// Parameter name for "message" on SelectBuilder.Otherwise(string message). - public string SelectOtherwiseMessageParam { get; } - - /// Parameter name for "locale" on SelectBuilder.For(string locale). - public string SelectForLocaleParam { get; } - - /// Parameter name for "select" on SelectPluralBuilder.When(TSelect select). - public string SelectPluralWhenSelectParam { get; } - - /// Parameter name for "locale" on SelectPluralBuilder.For(string locale). - public string SelectPluralForLocaleParam { get; } - - // ── Translate() overload parameter names ────────────────────────── - - /// Parameter name for "key" on all Translate() overloads. - public string TranslateKeyParam { get; } - - /// Parameter name for "message" on simple Translate(key, message). - public string TranslateMessageParam { get; } - - /// Parameter name for "howMany" on plural Translate(key, howMany). - public string TranslateHowManyParam { get; } - - /// Parameter name for "ordinal" on plural Translate(key, howMany, ordinal). - public string TranslateOrdinalParam { get; } - - /// Parameter name for "select" on select Translate(key, select). - public string TranslateSelectParam { get; } - - // ── DefineXxx() factory parameter names ───────────────────── - - /// Parameter name for "key" on TranslationDefinitions.DefineSimple(string key, string message). - public string DefKeyParam { get; } - - /// Parameter name for "message" on TranslationDefinitions.DefineSimple(string key, string message). - public string DefSimpleMessageParam { get; } - - // ── Sentinel values ─────────────────────────────────────────────── - - /// The internal sentinel used by SelectBuilder for the otherwise case. - public string OtherwiseSentinel { get; } - - /// Entry-point method name β€” hardcoded because it's our own extension method. - public string TranslateMethodName { get; } = nameof(StringLocalizerExtensions.Translation); - - public BuilderSymbolTable(CSharpCompilation compilation) - { - // ── Type resolution ─────────────────────────────────────────── - SimpleBuilder = ResolveType(compilation); - PluralBuilder = ResolveType(compilation); - SelectBuilder = ResolveType(compilation, typeof(SelectBuilder<>)); - SelectPluralBuilder = ResolveType(compilation, typeof(SelectPluralBuilder<>)); - TranslationAttribute = ResolveType(compilation); - - SimpleDefinition = ResolveType(compilation); - PluralDefinition = ResolveType(compilation); - SelectDefinition = ResolveType(compilation, typeof(SelectDefinition<>)); - SelectPluralDefinition = ResolveType(compilation, typeof(SelectPluralDefinition<>)); - DefinitionFactoryClass = ResolveType(compilation, typeof(Extensions.Translation.Definitions.TranslationDefinitions)); - - // ── PluralBuilder methods ───────────────────────────────────── - PluralFor = ResolveMethod(PluralBuilder, nameof(Extensions.Translation.PluralBuilder.For)); - PluralExactly = ResolveMethod(PluralBuilder, nameof(Extensions.Translation.PluralBuilder.Exactly)); - PluralZero = ResolveMethod(PluralBuilder, nameof(Extensions.Translation.PluralBuilder.Zero)); - PluralOne = ResolveMethod(PluralBuilder, nameof(Extensions.Translation.PluralBuilder.One)); - PluralTwo = ResolveMethod(PluralBuilder, nameof(Extensions.Translation.PluralBuilder.Two)); - PluralFew = ResolveMethod(PluralBuilder, nameof(Extensions.Translation.PluralBuilder.Few)); - PluralMany = ResolveMethod(PluralBuilder, nameof(Extensions.Translation.PluralBuilder.Many)); - PluralOther = ResolveMethod(PluralBuilder, nameof(Extensions.Translation.PluralBuilder.Other)); - - PluralCategoryMethods = new(SymbolEqualityComparer.Default) - { - PluralZero, PluralOne, PluralTwo, PluralFew, PluralMany, PluralOther - }; - - // ── SimpleBuilder methods ───────────────────────────────────── - SimpleFor = ResolveMethod(SimpleBuilder, nameof(Extensions.Translation.SimpleBuilder.For)); - - // ── SelectBuilder<> methods ─────────────────────────────────── - SelectWhen = ResolveMethod(SelectBuilder, nameof(SelectBuilder.When)); - SelectOtherwise = ResolveMethod(SelectBuilder, nameof(SelectBuilder.Otherwise)); - SelectFor = ResolveMethod(SelectBuilder, nameof(SelectBuilder.For)); - - // ── SelectPluralBuilder<> methods ───────────────────────────── - SelectPluralWhen = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.When)); - SelectPluralOtherwise = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.Otherwise)); - SelectPluralFor = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.For)); - SelectPluralExactly = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.Exactly)); - SelectPluralZero = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.Zero)); - SelectPluralOne = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.One)); - SelectPluralTwo = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.Two)); - SelectPluralFew = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.Few)); - SelectPluralMany = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.Many)); - SelectPluralOther = ResolveMethod(SelectPluralBuilder, nameof(SelectPluralBuilder.Other)); - - SelectPluralCategoryMethods = new(SymbolEqualityComparer.Default) - { - SelectPluralZero, SelectPluralOne, SelectPluralTwo, - SelectPluralFew, SelectPluralMany, SelectPluralOther - }; - - // ── PluralDefinition methods ────────────────────────────────── - PluralDefFor = ResolveMethod(PluralDefinition, nameof(Extensions.Translation.Definitions.PluralDefinition.For)); - PluralDefExactly = ResolveMethod(PluralDefinition, nameof(Extensions.Translation.Definitions.PluralDefinition.Exactly)); - PluralDefZero = ResolveMethod(PluralDefinition, nameof(Extensions.Translation.Definitions.PluralDefinition.Zero)); - PluralDefOne = ResolveMethod(PluralDefinition, nameof(Extensions.Translation.Definitions.PluralDefinition.One)); - PluralDefTwo = ResolveMethod(PluralDefinition, nameof(Extensions.Translation.Definitions.PluralDefinition.Two)); - PluralDefFew = ResolveMethod(PluralDefinition, nameof(Extensions.Translation.Definitions.PluralDefinition.Few)); - PluralDefMany = ResolveMethod(PluralDefinition, nameof(Extensions.Translation.Definitions.PluralDefinition.Many)); - PluralDefOther = ResolveMethod(PluralDefinition, nameof(Extensions.Translation.Definitions.PluralDefinition.Other)); - - PluralDefCategoryMethods = new(SymbolEqualityComparer.Default) - { - PluralDefZero, PluralDefOne, PluralDefTwo, PluralDefFew, PluralDefMany, PluralDefOther - }; - - // ── SimpleDefinition methods ────────────────────────────────── - SimpleDefFor = ResolveMethod(SimpleDefinition, nameof(Extensions.Translation.Definitions.SimpleDefinition.For)); - - // ── SelectDefinition<> methods ──────────────────────────────── - SelectDefWhen = ResolveMethod(SelectDefinition, nameof(SelectDefinition.When)); - SelectDefOtherwise = ResolveMethod(SelectDefinition, nameof(SelectDefinition.Otherwise)); - SelectDefFor = ResolveMethod(SelectDefinition, nameof(SelectDefinition.For)); - - // ── SelectPluralDefinition<> methods ────────────────────────── - SelectPluralDefWhen = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.When)); - SelectPluralDefOtherwise = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.Otherwise)); - SelectPluralDefFor = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.For)); - SelectPluralDefExactly = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.Exactly)); - SelectPluralDefZero = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.Zero)); - SelectPluralDefOne = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.One)); - SelectPluralDefTwo = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.Two)); - SelectPluralDefFew = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.Few)); - SelectPluralDefMany = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.Many)); - SelectPluralDefOther = ResolveMethod(SelectPluralDefinition, nameof(SelectPluralDefinition.Other)); - - SelectPluralDefCategoryMethods = new(SymbolEqualityComparer.Default) - { - SelectPluralDefZero, SelectPluralDefOne, SelectPluralDefTwo, - SelectPluralDefFew, SelectPluralDefMany, SelectPluralDefOther - }; - - // ── Combined category sets ──────────────────────────────────── - AllPluralCategoryMethods = new(PluralCategoryMethods, SymbolEqualityComparer.Default); - AllPluralCategoryMethods.UnionWith(PluralDefCategoryMethods); - - AllSelectPluralCategoryMethods = new(SelectPluralCategoryMethods, SymbolEqualityComparer.Default); - AllSelectPluralCategoryMethods.UnionWith(SelectPluralDefCategoryMethods); - - // ── Definition factory methods (each has a unique name) ────── - DefineSimple = ResolveMethod(DefinitionFactoryClass, nameof(Extensions.Translation.Definitions.TranslationDefinitions.DefineSimple)); - DefinePlural = ResolveMethod(DefinitionFactoryClass, nameof(Extensions.Translation.Definitions.TranslationDefinitions.DefinePlural)); - DefineSelect = ResolveMethod(DefinitionFactoryClass, nameof(Extensions.Translation.Definitions.TranslationDefinitions.DefineSelect)); - DefineSelectPlural = ResolveMethod(DefinitionFactoryClass, nameof(Extensions.Translation.Definitions.TranslationDefinitions.DefineSelectPlural)); - - // ── Reflection-derived parameter names ──────────────────────── - CategoryMessageParam = ReflectParam(nameof(Extensions.Translation.PluralBuilder.One), 0); - PluralForLocaleParam = ReflectParam(nameof(Extensions.Translation.PluralBuilder.For), 0); - ExactlyValueParam = ReflectParam(nameof(Extensions.Translation.PluralBuilder.Exactly), 0); - ExactlyMessageParam = ReflectParam(nameof(Extensions.Translation.PluralBuilder.Exactly), 1); - - SimpleForLocaleParam = ReflectParam(nameof(Extensions.Translation.SimpleBuilder.For), 0); - SimpleForMessageParam = ReflectParam(nameof(Extensions.Translation.SimpleBuilder.For), 1); - - SelectWhenSelectParam = ReflectParam(typeof(SelectBuilder<>), nameof(SelectBuilder.When), 0); - SelectWhenMessageParam = ReflectParam(typeof(SelectBuilder<>), nameof(SelectBuilder.When), 1); - SelectOtherwiseMessageParam = ReflectParam(typeof(SelectBuilder<>), nameof(SelectBuilder.Otherwise), 0); - SelectForLocaleParam = ReflectParam(typeof(SelectBuilder<>), nameof(SelectBuilder.For), 0); - - SelectPluralWhenSelectParam = ReflectParam(typeof(SelectPluralBuilder<>), nameof(SelectPluralBuilder.When), 0); - SelectPluralForLocaleParam = ReflectParam(typeof(SelectPluralBuilder<>), nameof(SelectPluralBuilder.For), 0); - - // ── Translate() parameter names (via reflection on StringLocalizerExtensions) ── - // The simple overload: Translate(this IStringLocalizer, string key, string message, object? replaceWith) - var simpleTranslate = typeof(StringLocalizerExtensions).GetMethods() - .First(m => m.Name == nameof(StringLocalizerExtensions.Translation) - && !m.IsGenericMethod - && m.GetParameters().Any(p => p.ParameterType == typeof(string) && p.Position == 2)); - TranslateKeyParam = simpleTranslate.GetParameters()[1].Name!; // skip 'this' param at [0] - TranslateMessageParam = simpleTranslate.GetParameters()[2].Name!; - - // The plural overload: Translate(this IStringLocalizer, string key, int howMany, bool ordinal, object? replaceWith) - var pluralTranslate = typeof(StringLocalizerExtensions).GetMethods() - .First(m => m.Name == nameof(StringLocalizerExtensions.Translation) - && !m.IsGenericMethod - && m.GetParameters().Any(p => p.ParameterType == typeof(int))); - TranslateHowManyParam = pluralTranslate.GetParameters()[2].Name!; - - // The ordinal parameter on the plural overload: Translate(this IStringLocalizer, string key, int howMany, bool ordinal, object? replaceWith) - TranslateOrdinalParam = pluralTranslate.GetParameters()[3].Name!; - - // The select overload β€” generic, no int param - var selectTranslate = typeof(StringLocalizerExtensions).GetMethods() - .First(m => m.Name == nameof(StringLocalizerExtensions.Translation) - && m.IsGenericMethod - && !m.GetParameters().Any(p => p.ParameterType == typeof(int))); - TranslateSelectParam = selectTranslate.GetParameters()[2].Name!; - - // ── DefineXxx() factory parameter names (via reflection on TranslationDefinitions) ── - var defineSimpleReflect = typeof(Extensions.Translation.Definitions.TranslationDefinitions).GetMethod(nameof(Extensions.Translation.Definitions.TranslationDefinitions.DefineSimple))!; - DefKeyParam = defineSimpleReflect.GetParameters()[0].Name!; - DefSimpleMessageParam = defineSimpleReflect.GetParameters()[1].Name!; - - // ── Sentinel values ─────────────────────────────────────────── - OtherwiseSentinel = (string)typeof(SelectBuilder<>) - .GetField("OtherwiseSentinel", BindingFlags.NonPublic | BindingFlags.Static)! - .GetValue(null)!; - - // ── Cross-validation ────────────────────────────────────────── - CrossValidate(); - CrossValidateTranslationAttribute(); - CrossValidateDefinitionBuilders(); - CrossValidateFactoryParams(); - } - - /// - /// Determines the builder type for a Translate() call's return type. - /// Returns null for unrecognized return types (e.g. indexer access). - /// - public BuilderKind? ClassifyReturnType(ITypeSymbol returnType) - { - var original = returnType.OriginalDefinition; - - if (SymbolEqualityComparer.Default.Equals(original, SimpleBuilder)) - return BuilderKind.Simple; - if (SymbolEqualityComparer.Default.Equals(original, PluralBuilder)) - return BuilderKind.Plural; - if (SymbolEqualityComparer.Default.Equals(original, SelectBuilder)) - return BuilderKind.Select; - if (SymbolEqualityComparer.Default.Equals(original, SelectPluralBuilder)) - return BuilderKind.SelectPlural; - - if (SymbolEqualityComparer.Default.Equals(original, SimpleDefinition)) - return BuilderKind.Simple; - if (SymbolEqualityComparer.Default.Equals(original, PluralDefinition)) - return BuilderKind.Plural; - if (SymbolEqualityComparer.Default.Equals(original, SelectDefinition)) - return BuilderKind.Select; - if (SymbolEqualityComparer.Default.Equals(original, SelectPluralDefinition)) - return BuilderKind.SelectPlural; - - return null; - } - - /// - /// Checks whether a chain method symbol matches any known builder method. - /// Uses so constructed generics - /// (e.g. SelectBuilder<TestCategory>.When) match the open definition. - /// - public bool IsMethod(IMethodSymbol symbol, IMethodSymbol expected) => - SymbolEqualityComparer.Default.Equals(symbol.OriginalDefinition, expected); - - /// - /// Checks whether a chain method symbol is a CLDR plural category method - /// on either PluralBuilder or SelectPluralBuilder. - /// - public bool IsPluralCategory(IMethodSymbol symbol) => - PluralCategoryMethods.Contains(symbol.OriginalDefinition) - || SelectPluralCategoryMethods.Contains(symbol.OriginalDefinition); - - /// - /// Checks whether an attribute on an enum field is [Translation]. - /// - public bool IsTranslationAttribute(INamedTypeSymbol? attributeClass) => - SymbolEqualityComparer.Default.Equals(attributeClass, TranslationAttribute); - - // ── Combined matchers (Builder + DefinitionBuilder) ────────────── - - public bool IsSimpleFor(IMethodSymbol symbol) => - IsMethod(symbol, SimpleFor) || IsMethod(symbol, SimpleDefFor); - - public bool IsPluralFor(IMethodSymbol symbol) => - IsMethod(symbol, PluralFor) || IsMethod(symbol, PluralDefFor); - - public bool IsPluralExactly(IMethodSymbol symbol) => - IsMethod(symbol, PluralExactly) || IsMethod(symbol, PluralDefExactly); - - public bool IsSelectWhen(IMethodSymbol symbol) => - IsMethod(symbol, SelectWhen) || IsMethod(symbol, SelectDefWhen); - - public bool IsSelectOtherwise(IMethodSymbol symbol) => - IsMethod(symbol, SelectOtherwise) || IsMethod(symbol, SelectDefOtherwise); - - public bool IsSelectFor(IMethodSymbol symbol) => - IsMethod(symbol, SelectFor) || IsMethod(symbol, SelectDefFor); - - public bool IsSelectPluralWhen(IMethodSymbol symbol) => - IsMethod(symbol, SelectPluralWhen) || IsMethod(symbol, SelectPluralDefWhen); - - public bool IsSelectPluralOtherwise(IMethodSymbol symbol) => - IsMethod(symbol, SelectPluralOtherwise) || IsMethod(symbol, SelectPluralDefOtherwise); - - public bool IsSelectPluralFor(IMethodSymbol symbol) => - IsMethod(symbol, SelectPluralFor) || IsMethod(symbol, SelectPluralDefFor); - - public bool IsSelectPluralExactly(IMethodSymbol symbol) => - IsMethod(symbol, SelectPluralExactly) || IsMethod(symbol, SelectPluralDefExactly); - - /// - /// Checks whether a method symbol is one of the TranslationDefinitions.DefineXxx() factory methods. - /// - public bool IsDefinitionFactory(IMethodSymbol symbol) => - IsMethod(symbol, DefineSimple) || IsMethod(symbol, DefinePlural) - || IsMethod(symbol, DefineSelect) || IsMethod(symbol, DefineSelectPlural); - - // ── Private helpers ─────────────────────────────────────────────── - - private static INamedTypeSymbol ResolveType(CSharpCompilation compilation) => - compilation.GetTypeByMetadataName(typeof(T).FullName!) - ?? throw new InvalidOperationException( - $"Roslyn cannot resolve {typeof(T).FullName}. " + - "Is BlazorLocalization.Extensions included in the compilation references?"); - - private static INamedTypeSymbol ResolveType(CSharpCompilation compilation, Type openGenericType) => - compilation.GetTypeByMetadataName(openGenericType.FullName!) - ?? throw new InvalidOperationException( - $"Roslyn cannot resolve {openGenericType.FullName}. " + - "Is BlazorLocalization.Extensions included in the compilation references?"); - - private static IMethodSymbol ResolveMethod(INamedTypeSymbol type, string methodName) - { - var methods = type.GetMembers(methodName).OfType().ToList(); - return methods.Count switch - { - 1 => methods[0], - 0 => throw new InvalidOperationException( - $"Roslyn cannot find method '{methodName}' on {type.ToDisplayString()}."), - _ => throw new InvalidOperationException( - $"Expected 1 method '{methodName}' on {type.ToDisplayString()}, found {methods.Count}.") - }; - } - - private static string ReflectParam(string methodName, int position) => - typeof(T).GetMethod(methodName)!.GetParameters()[position].Name!; - - private static string ReflectParam(Type type, string methodName, int position) => - type.GetMethod(methodName)!.GetParameters()[position].Name!; - - /// - /// Cross-validates Roslyn symbols against .NET reflection. Fails fast if they disagree. - /// - private void CrossValidate() - { - ValidateParams(PluralFor, typeof(Extensions.Translation.PluralBuilder), nameof(Extensions.Translation.PluralBuilder.For)); - ValidateParams(PluralExactly, typeof(Extensions.Translation.PluralBuilder), nameof(Extensions.Translation.PluralBuilder.Exactly)); - ValidateParams(PluralOne, typeof(Extensions.Translation.PluralBuilder), nameof(Extensions.Translation.PluralBuilder.One)); - - ValidateParams(SimpleFor, typeof(Extensions.Translation.SimpleBuilder), nameof(Extensions.Translation.SimpleBuilder.For)); - - ValidateParams(SelectWhen, typeof(SelectBuilder<>), nameof(SelectBuilder.When)); - ValidateParams(SelectOtherwise, typeof(SelectBuilder<>), nameof(SelectBuilder.Otherwise)); - ValidateParams(SelectFor, typeof(SelectBuilder<>), nameof(SelectBuilder.For)); - - ValidateParams(SelectPluralWhen, typeof(SelectPluralBuilder<>), nameof(SelectPluralBuilder.When)); - ValidateParams(SelectPluralOtherwise, typeof(SelectPluralBuilder<>), nameof(SelectPluralBuilder.Otherwise)); - ValidateParams(SelectPluralFor, typeof(SelectPluralBuilder<>), nameof(SelectPluralBuilder.For)); - ValidateParams(SelectPluralExactly, typeof(SelectPluralBuilder<>), nameof(SelectPluralBuilder.Exactly)); - ValidateParams(SelectPluralOne, typeof(SelectPluralBuilder<>), nameof(SelectPluralBuilder.One)); - } - - private static void ValidateParams(IMethodSymbol roslynMethod, Type reflectionType, string methodName) - { - var reflectMethod = reflectionType.GetMethod(methodName) - ?? throw new InvalidOperationException( - $"Reflection cannot find '{methodName}' on {reflectionType.FullName}."); - - var roslynParams = roslynMethod.Parameters; - var reflectParams = reflectMethod.GetParameters(); - - if (roslynParams.Length != reflectParams.Length) - throw new InvalidOperationException( - $"Parameter count mismatch for {reflectionType.Name}.{methodName}: " + - $"Roslyn={roslynParams.Length}, Reflection={reflectParams.Length}."); - - for (var i = 0; i < roslynParams.Length; i++) - { - if (roslynParams[i].Name != reflectParams[i].Name) - throw new InvalidOperationException( - $"Parameter name mismatch for {reflectionType.Name}.{methodName}[{i}]: " + - $"Roslyn='{roslynParams[i].Name}', Reflection='{reflectParams[i].Name}'."); - } - } - - /// - /// Like but resolves the correct overload by parameter count and - /// generic status. - /// - private static void ValidateFactoryParams(IMethodSymbol roslynMethod, Type reflectionType, string methodName, int paramCount, bool isGeneric) - { - var reflectMethod = reflectionType.GetMethods() - .FirstOrDefault(m => m.Name == methodName && m.GetParameters().Length == paramCount && m.IsGenericMethod == isGeneric) - ?? throw new InvalidOperationException( - $"Reflection cannot find '{methodName}({paramCount} params, generic={isGeneric})' on {reflectionType.FullName}."); - - var roslynParams = roslynMethod.Parameters; - var reflectParams = reflectMethod.GetParameters(); - - if (roslynParams.Length != reflectParams.Length) - throw new InvalidOperationException( - $"Parameter count mismatch for {reflectionType.Name}.{methodName}: " + - $"Roslyn={roslynParams.Length}, Reflection={reflectParams.Length}."); - - for (var i = 0; i < roslynParams.Length; i++) - { - if (roslynParams[i].Name != reflectParams[i].Name) - throw new InvalidOperationException( - $"Parameter name mismatch for {reflectionType.Name}.{methodName}[{i}]: " + - $"Roslyn='{roslynParams[i].Name}', Reflection='{reflectParams[i].Name}'."); - } - } - - /// - /// Verifies that the Roslyn-resolved TranslationAttribute has the expected properties. - /// - private void CrossValidateTranslationAttribute() - { - var reflectionProps = typeof(Extensions.TranslationAttribute) - .GetProperties(BindingFlags.Public | BindingFlags.Instance) - .Select(p => p.Name) - .ToHashSet(); - - var roslynProps = TranslationAttribute - .GetMembers() - .OfType() - .Where(p => p.DeclaredAccessibility == Accessibility.Public) - .Select(p => p.Name) - .ToHashSet(); - - var expected = new[] { TranslationMessageParam, TranslationLocaleParam, TranslationKeyParam }; - foreach (var name in expected) - { - if (!reflectionProps.Contains(name)) - throw new InvalidOperationException( - $"Reflection cannot find property '{name}' on {typeof(Extensions.TranslationAttribute).FullName}."); - if (!roslynProps.Contains(name)) - throw new InvalidOperationException( - $"Roslyn cannot find property '{name}' on {TranslationAttribute.ToDisplayString()}."); - } - } - - /// - /// Cross-validates definition Roslyn symbols against .NET reflection. - /// - private void CrossValidateDefinitionBuilders() - { - ValidateParams(SimpleDefFor, typeof(Extensions.Translation.Definitions.SimpleDefinition), nameof(Extensions.Translation.Definitions.SimpleDefinition.For)); - - ValidateParams(PluralDefFor, typeof(Extensions.Translation.Definitions.PluralDefinition), nameof(Extensions.Translation.Definitions.PluralDefinition.For)); - ValidateParams(PluralDefExactly, typeof(Extensions.Translation.Definitions.PluralDefinition), nameof(Extensions.Translation.Definitions.PluralDefinition.Exactly)); - ValidateParams(PluralDefOne, typeof(Extensions.Translation.Definitions.PluralDefinition), nameof(Extensions.Translation.Definitions.PluralDefinition.One)); - - ValidateParams(SelectDefWhen, typeof(SelectDefinition<>), nameof(SelectDefinition.When)); - ValidateParams(SelectDefOtherwise, typeof(SelectDefinition<>), nameof(SelectDefinition.Otherwise)); - ValidateParams(SelectDefFor, typeof(SelectDefinition<>), nameof(SelectDefinition.For)); - - ValidateParams(SelectPluralDefWhen, typeof(SelectPluralDefinition<>), nameof(SelectPluralDefinition.When)); - ValidateParams(SelectPluralDefOtherwise, typeof(SelectPluralDefinition<>), nameof(SelectPluralDefinition.Otherwise)); - ValidateParams(SelectPluralDefFor, typeof(SelectPluralDefinition<>), nameof(SelectPluralDefinition.For)); - ValidateParams(SelectPluralDefExactly, typeof(SelectPluralDefinition<>), nameof(SelectPluralDefinition.Exactly)); - ValidateParams(SelectPluralDefOne, typeof(SelectPluralDefinition<>), nameof(SelectPluralDefinition.One)); - - ValidateParams(DefineSimple, typeof(Extensions.Translation.Definitions.TranslationDefinitions), nameof(Extensions.Translation.Definitions.TranslationDefinitions.DefineSimple)); - ValidateParams(DefinePlural, typeof(Extensions.Translation.Definitions.TranslationDefinitions), nameof(Extensions.Translation.Definitions.TranslationDefinitions.DefinePlural)); - } - - /// - /// Verifies that TranslationDefinitions.DefineSimple factory param names match StringLocalizerExtensions.Translation - /// param names, so shared chain interpretation logic works for both code paths. - /// - private void CrossValidateFactoryParams() - { - if (DefKeyParam != TranslateKeyParam) - throw new InvalidOperationException( - $"TranslationDefinitions.DefineSimple 'key' param '{DefKeyParam}' doesn't match " + - $"StringLocalizerExtensions.Translation 'key' param '{TranslateKeyParam}'."); - if (DefSimpleMessageParam != TranslateMessageParam) - throw new InvalidOperationException( - $"TranslationDefinitions.DefineSimple 'message' param '{DefSimpleMessageParam}' doesn't match " + - $"StringLocalizerExtensions.Translation 'message' param '{TranslateMessageParam}'."); - } -} - -/// -/// Classifies which builder type a Translate() overload returns. -/// -public enum BuilderKind -{ - Simple, - Plural, - Select, - SelectPlural -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Extractors/ChainInterpreter.cs b/src/BlazorLocalization.Extractor/Scanning/Extractors/ChainInterpreter.cs deleted file mode 100644 index 2b2c6aa..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Extractors/ChainInterpreter.cs +++ /dev/null @@ -1,544 +0,0 @@ -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Scanning.Sources; -using Microsoft.CodeAnalysis; - -namespace BlazorLocalization.Extractor.Scanning.Extractors; - -/// -/// A chain link that preserves the for identity-based dispatch. -/// -internal sealed record ResolvedChainLink( - string MethodName, - IReadOnlyList Arguments, - IMethodSymbol? Symbol); - -/// -/// Interprets fluent builder chains into records -/// using symbol identity from . Zero string-based dispatch. -/// -/// -/// All methods are static β€” the is passed as a parameter. -/// -internal static class ChainInterpreter -{ - /// - /// Interprets a Translate() call and its fluent chain into a - /// using symbol identity from . Zero string-based dispatch. - /// - public static TranslationEntry? InterpretTranslateCall( - ExtractedCall call, - IMethodSymbol translateMethodSymbol, - IReadOnlyList? chain, - BuilderSymbolTable symbols) - { - var source = MakeSource(call); - - var key = FindArgByParam(call.Arguments, symbols.TranslateKeyParam); - if (key is null) - return null; - - var builderKind = symbols.ClassifyReturnType(translateMethodSymbol.ReturnType); - - if (chain is not { Count: > 0 }) - { - if (builderKind is BuilderKind.Simple) - { - var message = FindArgByParam(call.Arguments, symbols.TranslateMessageParam); - var sourceText = message is not null ? new SingularText(message) : null; - return new TranslationEntry(key, sourceText, source); - } - - return new TranslationEntry(key, null, source); - } - - return builderKind switch - { - BuilderKind.Simple => InterpretSimpleChain(call, chain, key, source, symbols), - BuilderKind.Plural => InterpretPluralChain(call, chain, key, source, symbols), - BuilderKind.Select => InterpretSelectChain(chain, key, source, symbols), - BuilderKind.SelectPlural => InterpretSelectPluralChain(call, chain, key, source, symbols), - _ => null - }; - } - - /// - /// Interprets a TranslationDefinitions.DefineSimple/DefinePlural/DefineSelect/DefineSelectPlural() - /// definition factory call and its fluent chain into a . - /// - public static TranslationEntry? InterpretDefinitionCall( - ExtractedCall call, - IMethodSymbol factoryMethodSymbol, - IReadOnlyList? chain, - BuilderSymbolTable symbols) - { - var source = MakeSource(call); - - var key = FindArgByParam(call.Arguments, symbols.DefKeyParam); - if (key is null) - return null; - - var builderKind = symbols.ClassifyReturnType(factoryMethodSymbol.ReturnType); - - if (chain is not { Count: > 0 }) - { - if (builderKind is BuilderKind.Simple) - { - var message = FindArgByParam(call.Arguments, symbols.DefSimpleMessageParam); - var sourceText = message is not null ? new SingularText(message) : null; - return new TranslationEntry(key, sourceText, source); - } - - return new TranslationEntry(key, null, source); - } - - return builderKind switch - { - BuilderKind.Simple => InterpretSimpleChain(call, chain, key, source, symbols), - BuilderKind.Plural => InterpretPluralChain(call, chain, key, source, symbols), - BuilderKind.Select => InterpretSelectChain(chain, key, source, symbols), - BuilderKind.SelectPlural => InterpretSelectPluralChain(call, chain, key, source, symbols), - _ => null - }; - } - - private static TranslationEntry InterpretSimpleChain( - ExtractedCall call, - IReadOnlyList chain, - string key, - SourceReference source, - BuilderSymbolTable symbols) - { - var message = FindArgByParam(call.Arguments, symbols.TranslateMessageParam); - var sourceText = message is not null ? new SingularText(message) : null; - - Dictionary? inlineTranslations = null; - - foreach (var link in chain) - { - if (link.Symbol is null) continue; - - if (symbols.IsSimpleFor(link.Symbol)) - { - var locale = FindArgByParam(link.Arguments, symbols.SimpleForLocaleParam); - var msg = FindLiteralByParam(link.Arguments, symbols.SimpleForMessageParam); - if (locale is not null && msg is not null) - { - inlineTranslations ??= new(); - inlineTranslations[locale] = new SingularText(msg); - } - } - } - - return new TranslationEntry(key, sourceText, source, inlineTranslations); - } - - private static TranslationEntry InterpretPluralChain( - ExtractedCall call, - IReadOnlyList chain, - string key, - SourceReference source, - BuilderSymbolTable symbols) - { - var categories = new Dictionary(); - var exactMatches = new Dictionary(); - var isOrdinal = string.Equals(FindArgByParam(call.Arguments, symbols.TranslateOrdinalParam), "true", StringComparison.OrdinalIgnoreCase); - var forSections = new List<(string locale, List calls)>(); - string? currentLocale = null; - List? currentForCalls = null; - - foreach (var link in chain) - { - if (link.Symbol is null) continue; - - if (symbols.IsPluralFor(link.Symbol)) - { - if (currentLocale is not null && currentForCalls is not null) - forSections.Add((currentLocale, currentForCalls)); - currentLocale = FindArgByParam(link.Arguments, symbols.PluralForLocaleParam); - currentForCalls = []; - continue; - } - - if (currentLocale is not null) - { - currentForCalls?.Add(link); - continue; - } - - if (symbols.IsPluralExactly(link.Symbol)) - { - var valueStr = FindArgByParam(link.Arguments, symbols.ExactlyValueParam); - var msg = FindLiteralByParam(link.Arguments, symbols.ExactlyMessageParam); - if (valueStr is not null && int.TryParse(valueStr, out var exactValue) && msg is not null) - exactMatches[exactValue] = msg; - continue; - } - - if (symbols.AllPluralCategoryMethods.Contains(link.Symbol.OriginalDefinition)) - { - var msg = FindLiteralByParam(link.Arguments, symbols.CategoryMessageParam); - if (msg is not null) - categories[link.MethodName] = msg; - } - } - - if (currentLocale is not null && currentForCalls is not null) - forSections.Add((currentLocale, currentForCalls)); - - var sourceText = categories.ContainsKey(nameof(BlazorLocalization.Extensions.Translation.PluralBuilder.Other)) - ? BuildPluralText(categories, exactMatches, isOrdinal) - : null; - - var inlineTranslations = forSections.Count > 0 - ? BuildPluralInlineTranslations(forSections, isOrdinal, symbols) - : null; - - return new TranslationEntry(key, sourceText, source, inlineTranslations); - } - - private static TranslationEntry InterpretSelectChain( - IReadOnlyList chain, - string key, - SourceReference source, - BuilderSymbolTable symbols) - { - var cases = new Dictionary(); - string? otherwise = null; - var forSections = new List<(string locale, List calls)>(); - string? currentLocale = null; - List? currentForCalls = null; - - foreach (var link in chain) - { - if (link.Symbol is null) continue; - - if (symbols.IsSelectFor(link.Symbol)) - { - if (currentLocale is not null && currentForCalls is not null) - forSections.Add((currentLocale, currentForCalls)); - currentLocale = FindArgByParam(link.Arguments, symbols.SelectForLocaleParam); - currentForCalls = []; - continue; - } - - if (currentLocale is not null) - { - currentForCalls?.Add(link); - continue; - } - - if (symbols.IsSelectWhen(link.Symbol)) - { - var selectValue = StripEnumPrefix(FindArgByParam(link.Arguments, symbols.SelectWhenSelectParam)); - var msg = FindLiteralByParam(link.Arguments, symbols.SelectWhenMessageParam); - if (selectValue is not null && msg is not null) - cases[selectValue] = msg; - } - else if (symbols.IsSelectOtherwise(link.Symbol)) - { - otherwise = FindLiteralByParam(link.Arguments, symbols.SelectOtherwiseMessageParam); - } - } - - if (currentLocale is not null && currentForCalls is not null) - forSections.Add((currentLocale, currentForCalls)); - - var sourceText = cases.Count > 0 || otherwise is not null - ? new SelectText(cases, otherwise) - : null; - - var inlineTranslations = forSections.Count > 0 - ? BuildSelectInlineTranslations(forSections, symbols) - : null; - - return new TranslationEntry(key, sourceText, source, inlineTranslations); - } - - private static TranslationEntry InterpretSelectPluralChain( - ExtractedCall call, - IReadOnlyList chain, - string key, - SourceReference source, - BuilderSymbolTable symbols) - { - var cases = new Dictionary(); - var isOrdinal = string.Equals(FindArgByParam(call.Arguments, symbols.TranslateOrdinalParam), "true", StringComparison.OrdinalIgnoreCase); - var forSections = new List<(string locale, List calls)>(); - string? currentLocale = null; - List? currentForCalls = null; - - string? currentSelectCase = null; - var currentCategories = new Dictionary(); - var currentExact = new Dictionary(); - var selectCaseStarted = false; - PluralText? otherwisePlural = null; - - void FlushSelectCase() - { - if (!selectCaseStarted || (currentCategories.Count == 0 && currentExact.Count == 0)) return; - var plural = BuildPluralText(currentCategories, currentExact, isOrdinal); - if (currentSelectCase is not null) - cases[currentSelectCase] = plural; - } - - foreach (var link in chain) - { - if (link.Symbol is null) continue; - - if (symbols.IsSelectPluralFor(link.Symbol)) - { - FlushSelectCase(); - if (selectCaseStarted && currentSelectCase is null && (currentCategories.Count > 0 || currentExact.Count > 0)) - otherwisePlural = BuildPluralText(currentCategories, currentExact, isOrdinal); - selectCaseStarted = false; - if (currentLocale is not null && currentForCalls is not null) - forSections.Add((currentLocale, currentForCalls)); - currentLocale = FindArgByParam(link.Arguments, symbols.SelectPluralForLocaleParam); - currentForCalls = []; - continue; - } - - if (currentLocale is not null) - { - currentForCalls?.Add(link); - continue; - } - - if (symbols.IsSelectPluralWhen(link.Symbol)) - { - FlushSelectCase(); - currentSelectCase = StripEnumPrefix(FindArgByParam(link.Arguments, symbols.SelectPluralWhenSelectParam)); - currentCategories = new Dictionary(); - currentExact = new Dictionary(); - selectCaseStarted = true; - continue; - } - - if (symbols.IsSelectPluralOtherwise(link.Symbol) && link.Arguments.Count == 0) - { - FlushSelectCase(); - currentSelectCase = null; - currentCategories = new Dictionary(); - currentExact = new Dictionary(); - selectCaseStarted = true; - continue; - } - - if (symbols.IsSelectPluralExactly(link.Symbol)) - { - selectCaseStarted = true; - var valueStr = FindArgByParam(link.Arguments, symbols.ExactlyValueParam); - var msg = FindLiteralByParam(link.Arguments, symbols.ExactlyMessageParam); - if (valueStr is not null && int.TryParse(valueStr, out var exactValue) && msg is not null) - currentExact[exactValue] = msg; - continue; - } - - if (symbols.AllSelectPluralCategoryMethods.Contains(link.Symbol.OriginalDefinition)) - { - selectCaseStarted = true; - var msg = FindLiteralByParam(link.Arguments, symbols.CategoryMessageParam); - if (msg is not null) - currentCategories[link.MethodName] = msg; - } - } - - FlushSelectCase(); - if (selectCaseStarted && currentSelectCase is null && (currentCategories.Count > 0 || currentExact.Count > 0)) - otherwisePlural = BuildPluralText(currentCategories, currentExact, isOrdinal); - if (currentLocale is not null && currentForCalls is not null) - forSections.Add((currentLocale, currentForCalls)); - - TranslationSourceText? sourceText = null; - if (cases.Count > 0 || otherwisePlural is not null) - sourceText = new SelectPluralText(cases, otherwisePlural); - - var inlineTranslations = forSections.Count > 0 - ? BuildSelectPluralInlineTranslations(forSections, isOrdinal, symbols) - : null; - - return new TranslationEntry(key, sourceText, source, inlineTranslations); - } - - private static PluralText BuildPluralText( - Dictionary categories, - Dictionary exactMatches, - bool isOrdinal) - { - return new PluralText( - Other: categories.GetValueOrDefault(nameof(BlazorLocalization.Extensions.Translation.PluralBuilder.Other), ""), - Zero: categories.GetValueOrDefault(nameof(BlazorLocalization.Extensions.Translation.PluralBuilder.Zero)), - One: categories.GetValueOrDefault(nameof(BlazorLocalization.Extensions.Translation.PluralBuilder.One)), - Two: categories.GetValueOrDefault(nameof(BlazorLocalization.Extensions.Translation.PluralBuilder.Two)), - Few: categories.GetValueOrDefault(nameof(BlazorLocalization.Extensions.Translation.PluralBuilder.Few)), - Many: categories.GetValueOrDefault(nameof(BlazorLocalization.Extensions.Translation.PluralBuilder.Many)), - ExactMatches: exactMatches.Count > 0 ? exactMatches : null, - IsOrdinal: isOrdinal); - } - - private static IReadOnlyDictionary? BuildPluralInlineTranslations( - List<(string locale, List calls)> forSections, - bool isOrdinal, - BuilderSymbolTable symbols) - { - var result = new Dictionary(); - - foreach (var (locale, calls) in forSections) - { - var categories = new Dictionary(); - var exactMatches = new Dictionary(); - - foreach (var link in calls) - { - if (link.Symbol is null) continue; - - if (symbols.IsPluralExactly(link.Symbol)) - { - var valueStr = FindArgByParam(link.Arguments, symbols.ExactlyValueParam); - var msg = FindLiteralByParam(link.Arguments, symbols.ExactlyMessageParam); - if (valueStr is not null && int.TryParse(valueStr, out var exactValue) && msg is not null) - exactMatches[exactValue] = msg; - } - else if (symbols.AllPluralCategoryMethods.Contains(link.Symbol.OriginalDefinition)) - { - var msg = FindLiteralByParam(link.Arguments, symbols.CategoryMessageParam); - if (msg is not null) - categories[link.MethodName] = msg; - } - } - - if (categories.Count > 0 || exactMatches.Count > 0) - result[locale] = BuildPluralText(categories, exactMatches, isOrdinal); - } - - return result.Count > 0 ? result : null; - } - - private static IReadOnlyDictionary? BuildSelectInlineTranslations( - List<(string locale, List calls)> forSections, - BuilderSymbolTable symbols) - { - var result = new Dictionary(); - - foreach (var (locale, calls) in forSections) - { - var cases = new Dictionary(); - string? otherwise = null; - - foreach (var link in calls) - { - if (link.Symbol is null) continue; - - if (symbols.IsSelectWhen(link.Symbol)) - { - var selectValue = StripEnumPrefix(FindArgByParam(link.Arguments, symbols.SelectWhenSelectParam)); - var msg = FindLiteralByParam(link.Arguments, symbols.SelectWhenMessageParam); - if (selectValue is not null && msg is not null) - cases[selectValue] = msg; - } - else if (symbols.IsSelectOtherwise(link.Symbol)) - { - otherwise = FindLiteralByParam(link.Arguments, symbols.SelectOtherwiseMessageParam); - } - } - - if (cases.Count > 0 || otherwise is not null) - result[locale] = new SelectText(cases, otherwise); - } - - return result.Count > 0 ? result : null; - } - - private static IReadOnlyDictionary? BuildSelectPluralInlineTranslations( - List<(string locale, List calls)> forSections, - bool isOrdinal, - BuilderSymbolTable symbols) - { - var result = new Dictionary(); - - foreach (var (locale, calls) in forSections) - { - var cases = new Dictionary(); - PluralText? otherwisePlural = null; - string? currentSelectCase = null; - var currentCategories = new Dictionary(); - var currentExact = new Dictionary(); - var selectCaseStarted = false; - - void Flush() - { - if (!selectCaseStarted || (currentCategories.Count == 0 && currentExact.Count == 0)) return; - var plural = BuildPluralText(currentCategories, currentExact, isOrdinal); - if (currentSelectCase is not null) - cases[currentSelectCase] = plural; - } - - foreach (var link in calls) - { - if (link.Symbol is null) continue; - - if (symbols.IsSelectPluralWhen(link.Symbol)) - { - Flush(); - currentSelectCase = StripEnumPrefix(FindArgByParam(link.Arguments, symbols.SelectPluralWhenSelectParam)); - currentCategories = new Dictionary(); - currentExact = new Dictionary(); - selectCaseStarted = true; - } - else if (symbols.IsSelectPluralOtherwise(link.Symbol) && link.Arguments.Count == 0) - { - Flush(); - currentSelectCase = null; - currentCategories = new Dictionary(); - currentExact = new Dictionary(); - selectCaseStarted = true; - } - else if (symbols.IsSelectPluralExactly(link.Symbol)) - { - selectCaseStarted = true; - var valueStr = FindArgByParam(link.Arguments, symbols.ExactlyValueParam); - var msg = FindLiteralByParam(link.Arguments, symbols.ExactlyMessageParam); - if (valueStr is not null && int.TryParse(valueStr, out var exactValue) && msg is not null) - currentExact[exactValue] = msg; - } - else if (symbols.AllSelectPluralCategoryMethods.Contains(link.Symbol.OriginalDefinition)) - { - selectCaseStarted = true; - var msg = FindLiteralByParam(link.Arguments, symbols.CategoryMessageParam); - if (msg is not null) - currentCategories[link.MethodName] = msg; - } - } - - Flush(); - if (selectCaseStarted && currentSelectCase is null && (currentCategories.Count > 0 || currentExact.Count > 0)) - otherwisePlural = BuildPluralText(currentCategories, currentExact, isOrdinal); - - if (cases.Count > 0 || otherwisePlural is not null) - result[locale] = new SelectPluralText(cases, otherwisePlural); - } - - return result.Count > 0 ? result : null; - } - - internal static string? FindArgByParam(IReadOnlyList args, string paramName) => - args.FirstOrDefault(a => a.ParameterName == paramName)?.Value; - - private static string? FindLiteralByParam(IReadOnlyList args, string paramName) => - args.FirstOrDefault(a => a.ParameterName == paramName && a.IsLiteral)?.Value; - - internal static SourceReference MakeSource(ExtractedCall call) => - new(call.Location.FilePath, call.Location.Line, call.Location.ProjectName, - $"{call.ContainingTypeName}.{call.MethodName}"); - - private static string? StripEnumPrefix(string? value) - { - if (value is null) return null; - var lastDot = value.LastIndexOf('.'); - return lastDot >= 0 ? value[(lastDot + 1)..] : value; - } -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Extractors/EnumAttributeExtractor.cs b/src/BlazorLocalization.Extractor/Scanning/Extractors/EnumAttributeExtractor.cs deleted file mode 100644 index f179c47..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Extractors/EnumAttributeExtractor.cs +++ /dev/null @@ -1,120 +0,0 @@ -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Scanning.Sources; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace BlazorLocalization.Extractor.Scanning.Extractors; - -/// -/// Detects [Translation] attributes on enum members and produces both an -/// (for inspect) and a (for export). -/// -internal static class EnumAttributeExtractor -{ - /// - /// Extracts an and from an enum - /// member decorated with [Translation] attributes. Returns null if the member - /// has no matching attributes. - /// - public static (ExtractedCall Call, TranslationEntry? Entry)? TryExtract( - EnumMemberDeclarationSyntax enumMember, - SemanticModel semanticModel, - SourceOrigin origin, - BuilderSymbolTable symbols) - { - var fieldSymbol = semanticModel.GetDeclaredSymbol(enumMember); - if (fieldSymbol is null) - return null; - - var matchingAttrs = fieldSymbol.GetAttributes() - .Where(a => symbols.IsTranslationAttribute(a.AttributeClass)) - .ToList(); - - if (matchingAttrs.Count == 0) - return null; - - string? customKey = null; - string? sourceText = null; - Dictionary? inlineTranslations = null; - - foreach (var attr in matchingAttrs) - { - // Constructor arg [0] = Message - var message = attr.ConstructorArguments is [{ Value: string msg }] ? msg : null; - if (message is null) - continue; - - // Named arg: Locale - string? locale = null; - string? key = null; - foreach (var named in attr.NamedArguments) - { - if (named.Key == symbols.TranslationLocaleParam && named.Value.Value is string loc) - locale = loc; - else if (named.Key == symbols.TranslationKeyParam && named.Value.Value is string k) - key = k; - } - - if (locale is not null) - { - inlineTranslations ??= new(StringComparer.OrdinalIgnoreCase); - inlineTranslations[locale] = new SingularText(message); - } - else - { - sourceText = message; - customKey ??= key; - } - } - - if (sourceText is null && inlineTranslations is null) - return null; - - var enumTypeName = fieldSymbol.ContainingType.Name; - var memberName = fieldSymbol.Name; - var entryKey = customKey ?? $"Enum.{enumTypeName}_{memberName}"; - - var location = origin.ResolveLocation(enumMember); - - // Build arguments matching the attribute's constructor + named parameters - var arguments = new List(); - var position = 0; - if (sourceText is not null) - arguments.Add(new ResolvedArgument(position++, sourceText, IsLiteral: true, null, symbols.TranslationMessageParam)); - if (customKey is not null) - arguments.Add(new ResolvedArgument(position++, customKey, IsLiteral: true, null, symbols.TranslationKeyParam)); - if (inlineTranslations is not null) - { - foreach (var (locale, text) in inlineTranslations) - arguments.Add(new ResolvedArgument(position++, $"{locale}: {((SingularText)text).Value}", IsLiteral: true, null, symbols.TranslationLocaleParam)); - } - - var call = new ExtractedCall( - enumTypeName, - memberName, - CallKind.AttributeDeclaration, - location, - OverloadResolutionStatus.Resolved, - arguments); - - var source = new SourceReference( - origin.FilePath, - location.Line, - origin.ProjectName, - Context: null); - - var entry = sourceText is not null || inlineTranslations is not null - ? new TranslationEntry( - entryKey, - sourceText is not null ? new SingularText(sourceText) : null, - source, - inlineTranslations) - : null; - - return (call, entry); - } -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Extractors/LocalizerCallExtractor.cs b/src/BlazorLocalization.Extractor/Scanning/Extractors/LocalizerCallExtractor.cs deleted file mode 100644 index 134e1a5..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Extractors/LocalizerCallExtractor.cs +++ /dev/null @@ -1,294 +0,0 @@ -using System.Collections.Immutable; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Scanning.Sources; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using StringLocalizerExtensions = BlazorLocalization.Extensions.StringLocalizerExtensions; - -namespace BlazorLocalization.Extractor.Scanning.Extractors; - -/// -/// Detects method calls -/// (Translate(), GetString(), etc.) and indexer access (localizer["key"]), -/// producing both an and an optional . -/// -internal static class LocalizerCallExtractor -{ - public static (ExtractedCall Call, TranslationEntry? Entry)? TryExtractInvocation( - InvocationExpressionSyntax invocation, - SemanticModel semanticModel, - SourceOrigin origin, - BuilderSymbolTable symbols) - { - var receiverType = GetReceiverType(invocation, semanticModel); - if (!IsStringLocalizerType(receiverType)) - return null; - - var symbolInfo = semanticModel.GetSymbolInfo(invocation); - var methodSymbol = symbolInfo.Symbol as IMethodSymbol - ?? BestCandidateByArgCount(symbolInfo, invocation.ArgumentList.Arguments.Count); - - if (methodSymbol is null) - return null; - - // When BestCandidateByArgCount returns an unreduced extension method, - // parameters[0] is the 'this' parameter β€” skip it so args align correctly. - var parameters = methodSymbol is { IsExtensionMethod: true, ReducedFrom: null } - ? methodSymbol.Parameters.RemoveAt(0) - : methodSymbol.Parameters; - - var isTranslate = methodSymbol.Name == nameof(StringLocalizerExtensions.Translation); - - var fluentChain = isTranslate - ? CollectFluentChain(invocation, semanticModel) - : null; - - var call = new ExtractedCall( - methodSymbol.ContainingType.Name, - methodSymbol.Name, - CallKind.MethodInvocation, - origin.ResolveLocation(invocation), - ResolveOverloadStatus(symbolInfo), - ExtractArguments(invocation.ArgumentList.Arguments, parameters, semanticModel), - fluentChain?.Select(c => new ChainedMethodCall(c.MethodName, c.Arguments)).ToList()); - - TranslationEntry? entry = null; - if (isTranslate) - entry = ChainInterpreter.InterpretTranslateCall(call, methodSymbol, fluentChain, symbols); - - return (call, entry); - } - - /// - /// Detects TranslationDefinitions.DefineSimple/DefinePlural/DefineSelect/DefineSelectPlural() - /// static factory calls, producing both an - /// and an optional . - /// - public static (ExtractedCall Call, TranslationEntry? Entry)? TryExtractDefinition( - InvocationExpressionSyntax invocation, - SemanticModel semanticModel, - SourceOrigin origin, - BuilderSymbolTable symbols) - { - var symbolInfo = semanticModel.GetSymbolInfo(invocation); - var methodSymbol = symbolInfo.Symbol as IMethodSymbol; - if (methodSymbol is null) - return null; - - if (!symbols.IsDefinitionFactory(methodSymbol)) - return null; - - var fluentChain = CollectFluentChain(invocation, semanticModel); - - var call = new ExtractedCall( - methodSymbol.ContainingType.Name, - methodSymbol.Name, - CallKind.MethodInvocation, - origin.ResolveLocation(invocation), - ResolveOverloadStatus(symbolInfo), - ExtractArguments(invocation.ArgumentList.Arguments, methodSymbol.Parameters, semanticModel), - fluentChain?.Select(c => new ChainedMethodCall(c.MethodName, c.Arguments)).ToList()); - - var entry = ChainInterpreter.InterpretDefinitionCall(call, methodSymbol, fluentChain, symbols); - - return (call, entry); - } - - public static (ExtractedCall Call, TranslationEntry? Entry)? TryExtractIndexer( - ElementAccessExpressionSyntax elementAccess, - SemanticModel semanticModel, - SourceOrigin origin) - { - var expressionType = semanticModel.GetTypeInfo(elementAccess.Expression).Type; - if (!IsStringLocalizerType(expressionType)) - return null; - - var symbolInfo = semanticModel.GetSymbolInfo(elementAccess); - var indexerSymbol = symbolInfo.Symbol as IPropertySymbol; - var parameters = indexerSymbol?.Parameters ?? []; - - var typeName = expressionType?.Name ?? ""; - - var call = new ExtractedCall( - typeName, - "this[]", - CallKind.IndexerAccess, - origin.ResolveLocation(elementAccess), - ResolveOverloadStatus(symbolInfo), - ExtractArguments(elementAccess.ArgumentList.Arguments, parameters, semanticModel)); - - var key = call.Arguments.FirstOrDefault()?.Value; - var entry = key is not null ? new TranslationEntry(key, null, ChainInterpreter.MakeSource(call)) : null; - - return (call, entry); - } - - /// - /// Walks up the syntax tree from a Translate() invocation to capture all - /// fluent builder chain calls, preserving the Roslyn - /// for identity-based dispatch. - /// - internal static IReadOnlyList? CollectFluentChain( - InvocationExpressionSyntax translateInvocation, - SemanticModel semanticModel) - { - List? chain = null; - SyntaxNode current = translateInvocation; - - while (current.Parent is MemberAccessExpressionSyntax memberAccess - && memberAccess.Parent is InvocationExpressionSyntax nextInvocation) - { - var methodName = memberAccess.Name.Identifier.Text; - - // ToString() is a terminator, not part of the fluent chain - if (methodName == "ToString") - break; - - var methodSymbol = semanticModel.GetSymbolInfo(nextInvocation).Symbol as IMethodSymbol; - var parameters = methodSymbol?.Parameters ?? ImmutableArray.Empty; - var args = ExtractArguments(nextInvocation.ArgumentList.Arguments, parameters, semanticModel); - - chain ??= []; - chain.Add(new ResolvedChainLink(methodName, args, methodSymbol)); - - current = nextInvocation; - } - - return chain; - } - - private static ITypeSymbol? GetReceiverType(InvocationExpressionSyntax invocation, SemanticModel semanticModel) => - invocation.Expression is MemberAccessExpressionSyntax memberAccess - ? semanticModel.GetTypeInfo(memberAccess.Expression).Type - : null; - - private static bool IsStringLocalizerType(ITypeSymbol? type) - { - if (type is null) return false; - if (IsStringLocalizerInterface(type)) return true; - return type.AllInterfaces.Any(IsStringLocalizerInterface); - } - - private static bool IsStringLocalizerInterface(ITypeSymbol type) => - type is INamedTypeSymbol { Name: "IStringLocalizer", ContainingNamespace: { Name: "Localization", ContainingNamespace: { Name: "Extensions", ContainingNamespace: { Name: "Microsoft" } } } }; - - // ── Argument extraction (absorbed from RoslynHelpers) ────────────── - - private static IMethodSymbol? BestCandidateByArgCount(SymbolInfo symbolInfo, int argCount) => - symbolInfo.CandidateSymbols - .OfType() - .OrderBy(m => Math.Abs(EffectiveParameterCount(m) - argCount)) - .FirstOrDefault(); - - private static int EffectiveParameterCount(IMethodSymbol method) => - method.ReducedFrom is not null - ? method.Parameters.Length - : method.IsExtensionMethod - ? method.Parameters.Length - 1 - : method.Parameters.Length; - - private static OverloadResolutionStatus ResolveOverloadStatus(SymbolInfo symbolInfo) - { - if (symbolInfo.Symbol is not null) - return OverloadResolutionStatus.Resolved; - - return symbolInfo.CandidateReason == CandidateReason.Ambiguous - ? OverloadResolutionStatus.Ambiguous - : OverloadResolutionStatus.BestCandidate; - } - - private static IReadOnlyList ExtractArguments( - SeparatedSyntaxList args, - ImmutableArray parameters, - SemanticModel semanticModel) - { - var result = new List(args.Count); - - for (var i = 0; i < args.Count; i++) - { - var arg = args[i]; - var syntaxName = arg.NameColon?.Name.Identifier.Text; - var literalValue = TryGetStringLiteral(arg.Expression); - var parameter = ResolveParameter(parameters, arg, i); - var objectCreation = ExtractObjectCreation(arg.Expression, semanticModel); - - result.Add(new ResolvedArgument( - i, - literalValue ?? arg.Expression.ToString(), - literalValue is not null, - syntaxName, - parameter?.Name, - objectCreation)); - } - - return result; - } - - private static ObjectCreation? ExtractObjectCreation(ExpressionSyntax expression, SemanticModel semanticModel) => - expression switch - { - ObjectCreationExpressionSyntax explicit_ => ExtractObjectCreationCore(explicit_, explicit_.ArgumentList?.Arguments, semanticModel), - ImplicitObjectCreationExpressionSyntax implicit_ => ExtractObjectCreationCore(implicit_, implicit_.ArgumentList?.Arguments, semanticModel), - _ => null - }; - - private static ObjectCreation? ExtractObjectCreationCore( - ExpressionSyntax creationExpression, - SeparatedSyntaxList? args, - SemanticModel semanticModel) - { - var ctorSymbol = semanticModel.GetSymbolInfo(creationExpression).Symbol as IMethodSymbol; - var createdType = semanticModel.GetTypeInfo(creationExpression).Type - ?? semanticModel.GetTypeInfo(creationExpression).ConvertedType; - - var typeName = ctorSymbol?.ContainingType.ToDisplayString() - ?? createdType?.ToDisplayString() - ?? ""; - - var ctorArgs = args ?? default; - var ctorArguments = new List(ctorArgs.Count); - - for (var i = 0; i < ctorArgs.Count; i++) - { - var arg = ctorArgs[i]; - var syntaxName = arg.NameColon?.Name.Identifier.Text; - var literalValue = TryGetStringLiteral(arg.Expression); - var parameter = ctorSymbol is not null ? ResolveParameter(ctorSymbol.Parameters, arg, i) : null; - - ctorArguments.Add(new ResolvedArgument( - i, - literalValue ?? arg.Expression.ToString(), - literalValue is not null, - syntaxName, - parameter?.Name)); - } - - return new ObjectCreation(typeName, ctorArguments); - } - - private static IParameterSymbol? ResolveParameter(ImmutableArray parameters, ArgumentSyntax arg, int position) - { - var named = arg.NameColon?.Name.Identifier.Text; - if (!string.IsNullOrWhiteSpace(named)) - return parameters.FirstOrDefault(p => p.Name == named); - - return position < parameters.Length ? parameters[position] : null; - } - - private static string? TryGetStringLiteral(ExpressionSyntax expression) => - expression switch - { - LiteralExpressionSyntax lit when lit.IsKind(SyntaxKind.StringLiteralExpression) - => lit.Token.ValueText, - BinaryExpressionSyntax bin when bin.IsKind(SyntaxKind.AddExpression) - => TryGetStringLiteral(bin.Left) is { } left && TryGetStringLiteral(bin.Right) is { } right - ? left + right - : null, - _ => null - }; -} diff --git a/src/BlazorLocalization.Extractor/Scanning/ProjectDiscovery.cs b/src/BlazorLocalization.Extractor/Scanning/ProjectDiscovery.cs deleted file mode 100644 index 79f967c..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/ProjectDiscovery.cs +++ /dev/null @@ -1,83 +0,0 @@ -namespace BlazorLocalization.Extractor.Scanning; - -/// -/// Discovers .csproj project directories by scanning a root path recursively. -/// Skips common build-output and dependency directories. -/// -public static class ProjectDiscovery -{ - private static readonly HashSet SkipDirs = new(StringComparer.OrdinalIgnoreCase) - { - "obj", "bin", "node_modules", ".git", ".vs", ".idea" - }; - - /// - /// Resolves one or more input paths (directories or .csproj files) into - /// deduplicated project directories. Returns errors for paths that don't exist. - /// - public static (IReadOnlyList ProjectDirs, IReadOnlyList Errors) ResolveAll(string[] paths) - { - var dirs = new List(); - var errors = new List(); - - foreach (var raw in paths) - { - var expanded = ExpandPath(raw); - - if (expanded.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) - { - if (!File.Exists(expanded)) - { - errors.Add($"File not found: {raw}"); - continue; - } - - dirs.Add(Path.GetDirectoryName(Path.GetFullPath(expanded))!); - } - else if (Directory.Exists(expanded)) - { - var found = Discover(raw); - if (found.Count == 0) - errors.Add($"No .csproj projects found in: {raw}"); - else - dirs.AddRange(found); - } - else - { - errors.Add($"Path not found: {raw}"); - } - } - - var deduped = dirs.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - return (deduped, errors); - } - - /// - /// Returns distinct project root directories under . - /// Each returned path is the directory containing a .csproj file. - /// - public static IReadOnlyList Discover(string root) - { - var fullRoot = Path.GetFullPath(ExpandPath(root)); - if (!Directory.Exists(fullRoot)) - return []; - - return Directory.EnumerateFiles(fullRoot, "*.csproj", SearchOption.AllDirectories) - .Where(path => !ShouldSkip(path)) - .Select(path => Path.GetDirectoryName(path)!) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - private static string ExpandPath(string path) => - path.StartsWith('~') - ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), path[1..].TrimStart('/')) - : Environment.ExpandEnvironmentVariables(path); - - private static bool ShouldSkip(string path) - { - var sep = Path.DirectorySeparatorChar; - return SkipDirs.Any(dir => - path.Contains($"{sep}{dir}{sep}", StringComparison.OrdinalIgnoreCase)); - } -} diff --git a/src/BlazorLocalization.Extractor/Scanning/ProjectScanner.cs b/src/BlazorLocalization.Extractor/Scanning/ProjectScanner.cs deleted file mode 100644 index 3f4197b..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/ProjectScanner.cs +++ /dev/null @@ -1,49 +0,0 @@ -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Scanning.Providers; - -namespace BlazorLocalization.Extractor.Scanning; - -/// -/// Scans a single project directory: Roslyn analysis + .resx import β†’ merged entries. -/// Single source of truth for the scanning pipeline β€” used by both extract and inspect commands. -/// -public static class ProjectScanner -{ - /// - /// Full scan result including raw calls (for inspect) and merged entries (for export). - /// - public sealed record Result( - string ProjectName, - IReadOnlyList Calls, - MergeResult MergeResult); - - /// - /// Scans and returns calls, merged entries, and conflicts. - /// - public static Result Scan(string projectDir) - { - var projectName = Path.GetFileName(projectDir); - - var providers = new ISourceProvider[] - { - new RazorGeneratedSourceProvider(projectDir), - new CSharpFileSourceProvider(projectDir) - }; - - var scanResult = new Scanner(providers).Run(); - - var rawEntries = scanResult.Entries; - var resxEntries = ResxImporter.ImportFromProject(projectDir); - if (resxEntries.Count > 0) - { - var combined = new List(rawEntries.Count + resxEntries.Count); - combined.AddRange(rawEntries); - combined.AddRange(resxEntries); - rawEntries = combined; - } - - return new Result(projectName, scanResult.Calls, MergedTranslationEntry.FromRaw(rawEntries)); - } -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Providers/CSharpFileSourceProvider.cs b/src/BlazorLocalization.Extractor/Scanning/Providers/CSharpFileSourceProvider.cs deleted file mode 100644 index 76b0855..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Providers/CSharpFileSourceProvider.cs +++ /dev/null @@ -1,32 +0,0 @@ -using BlazorLocalization.Extractor.Scanning.Sources; -using Microsoft.CodeAnalysis.CSharp; - -namespace BlazorLocalization.Extractor.Scanning.Providers; - -/// -/// Reads C# files from a source root and converts them into syntax trees. -/// -public sealed class CSharpFileSourceProvider : ISourceProvider -{ - private readonly string _root; - - public CSharpFileSourceProvider(string root) - { - _root = root; - } - - public IEnumerable GetDocuments() - { - foreach (var path in EnumerateSourceFiles(_root)) - { - var tree = CSharpSyntaxTree.ParseText(File.ReadAllText(path), path: path); - yield return new SourceDocument(tree, new SourceOrigin(path, Path.GetFileName(_root), null)); - } - } - - private static IEnumerable EnumerateSourceFiles(string root) => - Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories) - .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") - && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) - .Order(); -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Providers/ISourceProvider.cs b/src/BlazorLocalization.Extractor/Scanning/Providers/ISourceProvider.cs deleted file mode 100644 index 0e21459..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Providers/ISourceProvider.cs +++ /dev/null @@ -1,11 +0,0 @@ -using BlazorLocalization.Extractor.Scanning.Sources; - -namespace BlazorLocalization.Extractor.Scanning.Providers; - -/// -/// Provides syntax trees and their source origins. -/// -public interface ISourceProvider -{ - IEnumerable GetDocuments(); -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Providers/RazorGeneratedSourceProvider.cs b/src/BlazorLocalization.Extractor/Scanning/Providers/RazorGeneratedSourceProvider.cs deleted file mode 100644 index 7564fda..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Providers/RazorGeneratedSourceProvider.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Xml.Linq; -using BlazorLocalization.Extractor.Scanning.Sources; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; - -namespace BlazorLocalization.Extractor.Scanning.Providers; - -/// -/// Compiles Razor files to generated C# and exposes them as syntax trees. -/// -public sealed class RazorGeneratedSourceProvider : ISourceProvider -{ - private readonly string _projectRoot; - - public RazorGeneratedSourceProvider(string projectRoot) - { - _projectRoot = projectRoot; - } - - public IEnumerable GetDocuments() - { - var rootNamespace = ResolveRootNamespace(_projectRoot); - - foreach (var path in EnumerateRazorFiles(_projectRoot)) - { - var generated = CompileRazorToCSharp(path, _projectRoot, rootNamespace); - if (generated is null) - continue; - - var tree = CSharpSyntaxTree.ParseText(generated, path: path); - var lineMap = BuildLineMap(tree.GetRoot()); - yield return new SourceDocument(tree, new SourceOrigin(path, Path.GetFileName(_projectRoot), new LineMap(lineMap))); - } - } - - private static IEnumerable EnumerateRazorFiles(string root) => - new[] { "*.razor", "*.cshtml" } - .SelectMany(ext => Directory.EnumerateFiles(root, ext, SearchOption.AllDirectories)) - .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") - && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) - .Order(); - - private static string? CompileRazorToCSharp(string filePath, string projectRoot, string rootNamespace) - { - var fileSystem = RazorProjectFileSystem.Create(projectRoot); - var engine = RazorProjectEngine.Create( - RazorConfiguration.Default, - fileSystem, - builder => builder.SetRootNamespace(rootNamespace)); - - var relativePath = "/" + Path.GetRelativePath(projectRoot, filePath).Replace('\\', '/'); - var item = fileSystem.GetItem(relativePath, fileKind: null); - if (item is null) - return null; - - var codeDocument = engine.Process(item); - return codeDocument.GetCSharpDocument().GeneratedCode; - } - - /// - /// Reads the RootNamespace from the .csproj in , - /// falling back to the directory name (MSBuild's default behaviour). - /// - private static string ResolveRootNamespace(string projectRoot) - { - var csproj = Directory.EnumerateFiles(projectRoot, "*.csproj", SearchOption.TopDirectoryOnly).FirstOrDefault(); - if (csproj is not null) - { - var doc = XDocument.Load(csproj); - var ns = doc.Descendants("RootNamespace").FirstOrDefault()?.Value; - if (!string.IsNullOrWhiteSpace(ns)) - return ns; - } - - return Path.GetFileName(projectRoot); - } - - private static List<(int GeneratedLine, int OriginalLine)> BuildLineMap(SyntaxNode root) - { - var map = new List<(int GeneratedLine, int OriginalLine)>(); - - foreach (var trivia in root.DescendantTrivia()) - { - if (!trivia.IsKind(SyntaxKind.LineDirectiveTrivia) || !trivia.HasStructure) - continue; - - if (trivia.GetStructure() is not LineDirectiveTriviaSyntax directive) - continue; - - if (!int.TryParse(directive.Line.Text, out var originalLine)) - continue; - - var generatedLine = trivia.GetLocation().GetLineSpan().StartLinePosition.Line + 1; - map.Add((generatedLine, originalLine)); - } - - return map.OrderBy(m => m.GeneratedLine).ToList(); - } -} diff --git a/src/BlazorLocalization.Extractor/Scanning/ResxImporter.cs b/src/BlazorLocalization.Extractor/Scanning/ResxImporter.cs deleted file mode 100644 index 72e97ce..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/ResxImporter.cs +++ /dev/null @@ -1,206 +0,0 @@ -using System.Globalization; -using System.Xml; -using System.Xml.Linq; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; - -namespace BlazorLocalization.Extractor.Scanning; - -/// -/// Imports translation entries from .resx files by parsing XML directly into records. -/// Groups neutral and culture-specific .resx files by base name (e.g. Home.resx, Home.da.resx, -/// Home.es-MX.resx) and produces entries with populated -/// from the culture-specific files. -/// -public static class ResxImporter -{ - /// - /// Imports all .resx files from a project directory, grouping neutral and culture-specific files by base name. - /// Culture-specific values are mapped into . - /// - public static IReadOnlyList ImportFromProject(string projectDir) - { - var projectName = Path.GetFileName(projectDir); - var groups = GroupResxFilesByBaseName(EnumerateResxFiles(projectDir)); - return groups - .OrderBy(g => g.Key, StringComparer.Ordinal) - .SelectMany(g => ImportGroup(g.Value, projectName)) - .ToList(); - } - - /// - /// Parses a single .resx file into a dictionary of key β†’ (value, comment, line). - /// Filters out non-string resource entries (embedded files, images, etc.). - /// - public static IReadOnlyDictionary ParseResx(string resxFilePath) - { - var doc = XDocument.Load(resxFilePath, LoadOptions.SetLineInfo); - var entries = new Dictionary(); - - foreach (var data in doc.Descendants("data")) - { - if (!IsStringEntry(data)) - continue; - - var key = data.Attribute("name")?.Value; - if (key is null) - continue; - - var value = data.Element("value")?.Value; - if (string.IsNullOrEmpty(value)) - continue; - - var comment = data.Element("comment")?.Value; - var line = (data as IXmlLineInfo)?.LineNumber ?? 0; - - entries[key] = (value, comment, line); - } - - return entries; - } - - /// - /// Imports a group of .resx files sharing a base name into records. - /// The neutral file provides ; culture-specific files - /// provide . - /// - private static IEnumerable ImportGroup( - ResxFileGroup group, - string projectName) - { - var neutralEntries = group.NeutralPath is not null - ? ParseResx(group.NeutralPath) - : new Dictionary(); - - var cultureEntries = new Dictionary>(); - foreach (var (culture, path) in group.CulturePaths) - cultureEntries[culture] = ParseResx(path); - - // Collect all keys across neutral and all culture files - var allKeys = new HashSet(neutralEntries.Keys); - foreach (var entries in cultureEntries.Values) - allKeys.UnionWith(entries.Keys); - - foreach (var key in allKeys.Order(StringComparer.Ordinal)) - { - // Neutral β†’ SourceText - TranslationSourceText? sourceText = null; - string? sourceFile = null; - var sourceLine = 0; - string? comment = null; - - if (neutralEntries.TryGetValue(key, out var neutral)) - { - sourceText = new SingularText(neutral.Value); - sourceFile = group.NeutralPath; - sourceLine = neutral.Line; - comment = neutral.Comment; - } - - // Culture files β†’ InlineTranslations - Dictionary? inlineTranslations = null; - foreach (var (culture, entries) in cultureEntries) - { - if (entries.TryGetValue(key, out var cultureEntry)) - { - inlineTranslations ??= new Dictionary(); - inlineTranslations[culture] = new SingularText(cultureEntry.Value); - } - } - - // For culture-only keys, use the first culture file that has the key as the source reference - if (sourceFile is null && inlineTranslations is not null) - { - var firstCulture = inlineTranslations.Keys.Order(StringComparer.Ordinal).First(); - sourceFile = group.CulturePaths[firstCulture]; - var firstEntries = cultureEntries[firstCulture]; - if (firstEntries.TryGetValue(key, out var firstEntry)) - { - sourceLine = firstEntry.Line; - comment = firstEntry.Comment; - } - } - - var source = new SourceReference(sourceFile!, sourceLine, projectName, comment); - yield return new TranslationEntry(key, sourceText, source, inlineTranslations); - } - } - - /// - /// Groups .resx file paths by base name. For example, Home.resx, Home.da.resx, - /// and Home.es-MX.resx all share the base name Home. - /// - private static Dictionary GroupResxFilesByBaseName(IEnumerable paths) - { - var groups = new Dictionary(); - - foreach (var path in paths) - { - var (baseName, culture) = ParseResxFileName(path); - if (!groups.TryGetValue(baseName, out var group)) - { - group = new ResxFileGroup(); - groups[baseName] = group; - } - - if (culture is null) - group.NeutralPath = path; - else - group.CulturePaths[culture] = path; - } - - return groups; - } - - /// - /// Parses a .resx file path into its base name (full path without culture suffix and extension) - /// and optional culture code. For example: - /// /path/Home.resx β†’ (/path/Home, null) - /// /path/Home.da.resx β†’ (/path/Home, "da") - /// /path/Home.es-MX.resx β†’ (/path/Home, "es-MX") - /// - private static (string BaseName, string? Culture) ParseResxFileName(string path) - { - var dir = Path.GetDirectoryName(path); - var fileNameNoResx = Path.GetFileNameWithoutExtension(path); // strips .resx - var lastDot = fileNameNoResx.LastIndexOf('.'); - if (lastDot < 0) - return (Combine(dir, fileNameNoResx), null); - - var suffix = fileNameNoResx[(lastDot + 1)..]; - try - { - CultureInfo.GetCultureInfo(suffix, predefinedOnly: true); - return (Combine(dir, fileNameNoResx[..lastDot]), suffix); - } - catch - { - return (Combine(dir, fileNameNoResx), null); - } - - static string Combine(string? dir, string name) => - dir is null ? name : Path.Combine(dir, name); - } - - /// - /// Returns true if the <data> element represents a string resource. - /// Entries with a type attribute (e.g. System.Resources.ResXFileRef) are embedded resources. - /// - private static bool IsStringEntry(XElement data) => - data.Attribute("type") is null; - - private static IEnumerable EnumerateResxFiles(string root) => - Directory.EnumerateFiles(root, "*.resx", SearchOption.AllDirectories) - .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}") - && !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}")) - .Order(); - - /// - /// Mutable accumulator for grouping neutral and culture-specific .resx files by base name. - /// - private sealed class ResxFileGroup - { - public string? NeutralPath { get; set; } - public Dictionary CulturePaths { get; } = new(); - } -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Scanner.cs b/src/BlazorLocalization.Extractor/Scanning/Scanner.cs deleted file mode 100644 index 52fde85..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Scanner.cs +++ /dev/null @@ -1,103 +0,0 @@ -using Basic.Reference.Assemblies; -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Scanning.Extractors; -using BlazorLocalization.Extractor.Scanning.Providers; -using BlazorLocalization.Extractor.Scanning.Sources; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.Extensions.Localization; -using StringLocalizerExtensions = BlazorLocalization.Extensions.StringLocalizerExtensions; - -namespace BlazorLocalization.Extractor.Scanning; - -/// -/// Orchestrates source scanning: collects documents, compiles them, and delegates -/// extraction to and . -/// -public sealed class Scanner -{ - private readonly IReadOnlyList _providers; - - public Scanner(IEnumerable providers) - { - _providers = providers.Distinct().ToList(); - } - - /// - /// Scans all source providers and returns raw calls plus interpreted translation entries. - /// - public ScanResult Run() - { - var documents = _providers.SelectMany(p => p.GetDocuments()).ToList(); - if (documents.Count == 0) - return new ScanResult([], []); - - var trees = documents.Select(d => d.Tree).ToList(); - var compilation = CreateCompilation(trees); - var symbols = new BuilderSymbolTable(compilation); - - var calls = new List(); - var entries = new List(); - - foreach (var doc in documents) - { - var semanticModel = compilation.GetSemanticModel(doc.Tree, ignoreAccessibility: true); - var root = doc.Tree.GetRoot(); - - foreach (var node in root.DescendantNodes()) - { - (ExtractedCall Call, TranslationEntry? Entry)? result = node switch - { - InvocationExpressionSyntax invocation - => LocalizerCallExtractor.TryExtractInvocation(invocation, semanticModel, doc.Origin, symbols) - ?? LocalizerCallExtractor.TryExtractDefinition(invocation, semanticModel, doc.Origin, symbols), - ElementAccessExpressionSyntax elementAccess - => LocalizerCallExtractor.TryExtractIndexer(elementAccess, semanticModel, doc.Origin), - EnumMemberDeclarationSyntax enumMember - => EnumAttributeExtractor.TryExtract(enumMember, semanticModel, doc.Origin, symbols), - _ => null - }; - - if (result is not null) - { - calls.Add(result.Value.Call); - if (result.Value.Entry is not null) - entries.Add(result.Value.Entry); - } - } - } - - return new ScanResult(calls, entries); - } - - private static CSharpCompilation CreateCompilation(IReadOnlyList trees) - { - var references = BuildMetadataReferences(); - return CSharpCompilation.Create( - "ExtractorAnalysis", - trees, - references, - new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); - } - - /// - /// Builds the metadata references needed for Roslyn's semantic model. - /// Uses embedded reference assemblies (Basic.Reference.Assemblies) for BCL types β€” no disk I/O - /// for the ~200 core framework assemblies. Our own extension assemblies are loaded from disk - /// (always available as tool package dependencies). - /// - private static List BuildMetadataReferences() - { - var refs = new List(Net100.References.All); - - refs.Add(MetadataReference.CreateFromFile(typeof(IStringLocalizer).Assembly.Location)); - refs.Add(MetadataReference.CreateFromFile(typeof(StringLocalizerExtensions).Assembly.Location)); - - return refs; - } -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Sources/LineMap.cs b/src/BlazorLocalization.Extractor/Scanning/Sources/LineMap.cs deleted file mode 100644 index 678ea17..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Sources/LineMap.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace BlazorLocalization.Extractor.Scanning.Sources; - -/// -/// Maps generated C# line numbers back to original Razor line numbers. -/// -public sealed class LineMap -{ - private readonly List<(int GeneratedLine, int OriginalLine)> _entries; - - public LineMap(List<(int GeneratedLine, int OriginalLine)> entries) - { - _entries = entries; - } - - public int MapToOriginalLine(int generatedLine) - { - var mapped = 0; - foreach (var entry in _entries) - { - if (entry.GeneratedLine > generatedLine) - break; - // #line N means the line AFTER the directive is original line N - mapped = entry.OriginalLine + (generatedLine - entry.GeneratedLine - 1); - } - - return mapped; - } -} diff --git a/src/BlazorLocalization.Extractor/Scanning/Sources/SourceDocument.cs b/src/BlazorLocalization.Extractor/Scanning/Sources/SourceDocument.cs deleted file mode 100644 index cf99fbe..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Sources/SourceDocument.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace BlazorLocalization.Extractor.Scanning.Sources; - -/// -/// Holds a syntax tree and its original source context. -/// -public sealed record SourceDocument(SyntaxTree Tree, SourceOrigin Origin); diff --git a/src/BlazorLocalization.Extractor/Scanning/Sources/SourceOrigin.cs b/src/BlazorLocalization.Extractor/Scanning/Sources/SourceOrigin.cs deleted file mode 100644 index 0612b2a..0000000 --- a/src/BlazorLocalization.Extractor/Scanning/Sources/SourceOrigin.cs +++ /dev/null @@ -1,25 +0,0 @@ -using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using Microsoft.CodeAnalysis; - -namespace BlazorLocalization.Extractor.Scanning.Sources; - -/// -/// Describes the original source context for a syntax tree. -/// -public sealed record SourceOrigin(string FilePath, string ProjectName, LineMap? LineMap) -{ - /// - /// Resolves a syntax node's position to the correct , - /// mapping through the for generated-from-Razor sources. - /// - public SourceLocation ResolveLocation(SyntaxNode node) - { - var generatedLine = node.GetLocation().GetLineSpan().StartLinePosition.Line + 1; - if (LineMap is null) - return new SourceLocation(FilePath, generatedLine, ProjectName); - - var mapped = LineMap.MapToOriginalLine(generatedLine); - return new SourceLocation(FilePath, mapped > 0 ? mapped : generatedLine, ProjectName); - } -} diff --git a/tests/BlazorLocalization.Extractor.Tests/CliSmokeTests.cs b/tests/BlazorLocalization.Extractor.Tests/CliSmokeTests.cs index b35b507..816ff95 100644 --- a/tests/BlazorLocalization.Extractor.Tests/CliSmokeTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/CliSmokeTests.cs @@ -1,5 +1,5 @@ using System.Text.Json; -using BlazorLocalization.Extractor.Cli.Commands; +using BlazorLocalization.Extractor.Adapters.Cli.Commands; using FluentAssertions; using Spectre.Console.Cli; @@ -70,7 +70,6 @@ public void Extract_ToStdout_ProducesValidJson() [Fact] public void Extract_WithOutputDir_WritesFiles() { - // --output takes precedence over piped-stdout JSON mode var exitCode = BuildApp().Run(["extract", SampleAppDir, "-f", "po", "-o", _tempDir]); exitCode.Should().Be(0); @@ -87,8 +86,8 @@ public void Inspect_ProducesJsonWithCallsAndEntries() exitCode.Should().Be(0); using var doc = JsonDocument.Parse(output); - doc.RootElement.TryGetProperty("calls", out _).Should().BeTrue(); - doc.RootElement.TryGetProperty("entries", out _).Should().BeTrue(); + doc.RootElement.TryGetProperty("translationEntries", out _).Should().BeTrue(); + doc.RootElement.TryGetProperty("crossReference", out _).Should().BeTrue(); } [Fact] @@ -102,7 +101,6 @@ public void Extract_NonexistentPath_ReturnsError() [Fact] public void Extract_DirectoryOutput_WritesPerLocaleFiles() { - // Per-locale files are now exported by default with directory output var exitCode = BuildApp().Run( ["extract", SampleAppDir, "-f", "i18next", "-o", _tempDir]); @@ -163,11 +161,11 @@ public void Extract_ToStdout_SingleLocale_ProducesLocaleData() } [Fact] - public void Extract_ToStdout_MultipleLocales_ReturnsError() + public void Extract_ToStdout_MultipleLocales_Succeeds() { var (_, exitCode) = RunCapturingStdout(["extract", SampleAppDir, "-l", "da", "-l", "es-MX"]); - exitCode.Should().Be(1); + exitCode.Should().Be(0); } [Fact] diff --git a/tests/BlazorLocalization.Extractor.Tests/DomainHelperTests.cs b/tests/BlazorLocalization.Extractor.Tests/DomainHelperTests.cs index 3730cd8..7fac784 100644 --- a/tests/BlazorLocalization.Extractor.Tests/DomainHelperTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/DomainHelperTests.cs @@ -1,19 +1,17 @@ +using BlazorLocalization.Extractor.Application; using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; using FluentAssertions; namespace BlazorLocalization.Extractor.Tests; public class LocaleDiscoveryTests { - private static SourceReference Source(string file, int line) => - new(file, line, "TestProject", null); - - private static MergedTranslationEntry Entry( + private static MergedTranslation Entry( string key, IReadOnlyDictionary? inlineTranslations = null) => - new(key, new SingularText("Hello"), [Source("A.cs", 1)], inlineTranslations); + new(key, new SingularText("Hello"), + [new DefinitionSite(new SourceFilePath("/test/TestProject/A.cs", "/test/TestProject"), 1, DefinitionKind.InlineTranslation)], + [], inlineTranslations); [Fact] public void DiscoverLocales_ReturnsEmpty_WhenNoInlineTranslations() @@ -87,62 +85,60 @@ public void DiscoverLocales_IsCaseInsensitive() } } -public class RelativizeTests +public class SourceFilePathTests { [Fact] - public void SourceReference_Relativize_ProducesForwardSlashes() + public void RelativePath_ProducesForwardSlashes() { - var source = new SourceReference("/project/src/File.cs", 10, "MyProject", null); + var sfp = new SourceFilePath("/project/src/File.cs", "/project"); + + sfp.RelativePath.Should().Be("src/File.cs"); + } - var result = source.Relativize("/project"); + [Fact] + public void Display_Relative_ReturnsRelativePath() + { + var sfp = new SourceFilePath("/project/src/File.cs", "/project"); - result.FilePath.Should().Be("src/File.cs"); - result.Line.Should().Be(10); - result.ProjectName.Should().Be("MyProject"); + sfp.Display(PathStyle.Relative).Should().Be("src/File.cs"); } [Fact] - public void SourceLocation_Relativize_ProducesForwardSlashes() + public void Display_Absolute_ReturnsAbsolutePath() { - var loc = new SourceLocation("/project/src/File.cs", 10, "MyProject"); + var sfp = new SourceFilePath("/project/src/File.cs", "/project"); - var result = loc.Relativize("/project"); + sfp.Display(PathStyle.Absolute).Should().Be("/project/src/File.cs"); + } - result.FilePath.Should().Be("src/File.cs"); - result.Line.Should().Be(10); + [Fact] + public void IsResx_TrueForResxFiles() + { + new SourceFilePath("/p/Home.resx", "/p").IsResx.Should().BeTrue(); + new SourceFilePath("/p/Home.da.resx", "/p").IsResx.Should().BeTrue(); } [Fact] - public void MergedTranslationEntry_RelativizeSources_AllSourcesRelativized() + public void IsResx_FalseForNonResxFiles() { - var entry = new MergedTranslationEntry( - "key1", - new SingularText("Hello"), - [ - new SourceReference("/project/src/A.cs", 1, "P", null), - new SourceReference("/project/src/B.cs", 5, "P", null) - ]); - - var result = entry.RelativizeSources("/project"); - - result.Sources.Select(s => s.FilePath).Should().Equal("src/A.cs", "src/B.cs"); - result.Key.Should().Be("key1"); + new SourceFilePath("/p/Home.razor", "/p").IsResx.Should().BeFalse(); + new SourceFilePath("/p/Home.cs", "/p").IsResx.Should().BeFalse(); } + [Fact] - public void ExtractedCall_RelativizeLocation() + public void ProjectName_DerivedFromProjectDir() { - var call = new ExtractedCall( - "MyClass", - "GetString", - CallKind.MethodInvocation, - new SourceLocation("/project/src/File.cs", 42, "P"), - OverloadResolutionStatus.Resolved, - []); - - var result = call.RelativizeLocation("/project"); - - result.Location.FilePath.Should().Be("src/File.cs"); - result.Location.Line.Should().Be(42); + var sfp = new SourceFilePath("/home/user/MyApp/File.cs", "/home/user/MyApp"); + + sfp.ProjectName.Should().Be("MyApp"); + } + + [Fact] + public void FileName_ReturnsJustFileName() + { + var sfp = new SourceFilePath("/project/src/Components/Home.razor", "/project"); + + sfp.FileName.Should().Be("Home.razor"); } } diff --git a/tests/BlazorLocalization.Extractor.Tests/ExporterTests.GenericJson.verified.txt b/tests/BlazorLocalization.Extractor.Tests/ExporterTests.GenericJson.verified.txt index f18ff2a..aee2600 100644 --- a/tests/BlazorLocalization.Extractor.Tests/ExporterTests.GenericJson.verified.txt +++ b/tests/BlazorLocalization.Extractor.Tests/ExporterTests.GenericJson.verified.txt @@ -5,13 +5,14 @@ "type": "singular", "value": "Welcome" }, - "sources": [ + "definitions": [ { "filePath": "Home.razor", "line": 10, "projectName": "MyApp" } - ] + ], + "references": [] }, { "key": "Cart.Items", @@ -24,13 +25,14 @@ "42": "The answer" } }, - "sources": [ + "definitions": [ { "filePath": "Cart.razor", "line": 20, "projectName": "MyApp" } - ] + ], + "references": [] }, { "key": "Invite", @@ -42,13 +44,14 @@ }, "otherwise": "They invited you" }, - "sources": [ + "definitions": [ { "filePath": "Invite.razor", "line": 30, "projectName": "MyApp" } - ] + ], + "references": [] }, { "key": "Inbox", @@ -72,22 +75,23 @@ "one": "They have {MessageCount} message" } }, - "sources": [ + "definitions": [ { "filePath": "Inbox.razor", "line": 40, "projectName": "MyApp" } - ] + ], + "references": [] }, { "key": "Legacy.Key", - "sources": [ + "definitions": [], + "references": [ { "filePath": "Old.cs", "line": 50, - "projectName": "MyApp", - "context": "GetString call" + "projectName": "MyApp" } ] } diff --git a/tests/BlazorLocalization.Extractor.Tests/ExporterTests.cs b/tests/BlazorLocalization.Extractor.Tests/ExporterTests.cs index d991fa9..1dd7d76 100644 --- a/tests/BlazorLocalization.Extractor.Tests/ExporterTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/ExporterTests.cs @@ -1,6 +1,5 @@ +using BlazorLocalization.Extractor.Adapters.Export; using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Exporters; namespace BlazorLocalization.Extractor.Tests; @@ -10,11 +9,12 @@ namespace BlazorLocalization.Extractor.Tests; /// public class ExporterTests { - private static readonly IReadOnlyList TestEntries = + private static readonly IReadOnlyList TestEntries = [ new("App.Title", new SingularText("Welcome"), - [new SourceReference("Home.razor", 10, "MyApp", null)]), + [new DefinitionSite(new SourceFilePath("/test/MyApp/Home.razor", "/test/MyApp"), 10, DefinitionKind.InlineTranslation)], + []), new("Cart.Items", new PluralText( @@ -22,7 +22,8 @@ [new SourceReference("Home.razor", 10, "MyApp", null)]), One: "{ItemCount} item", Zero: "No items", ExactMatches: new Dictionary { [42] = "The answer" }), - [new SourceReference("Cart.razor", 20, "MyApp", null)]), + [new DefinitionSite(new SourceFilePath("/test/MyApp/Cart.razor", "/test/MyApp"), 20, DefinitionKind.InlineTranslation)], + []), new("Invite", new SelectText( @@ -32,7 +33,8 @@ [new SourceReference("Cart.razor", 20, "MyApp", null)]), ["Male"] = "He invited you" }, Otherwise: "They invited you"), - [new SourceReference("Invite.razor", 30, "MyApp", null)]), + [new DefinitionSite(new SourceFilePath("/test/MyApp/Invite.razor", "/test/MyApp"), 30, DefinitionKind.InlineTranslation)], + []), new("Inbox", new SelectPluralText( @@ -42,22 +44,24 @@ [new SourceReference("Invite.razor", 30, "MyApp", null)]), ["Male"] = new("He has {MessageCount} messages", One: "He has {MessageCount} message") }, Otherwise: new("They have {MessageCount} messages", One: "They have {MessageCount} message")), - [new SourceReference("Inbox.razor", 40, "MyApp", null)]), + [new DefinitionSite(new SourceFilePath("/test/MyApp/Inbox.razor", "/test/MyApp"), 40, DefinitionKind.InlineTranslation)], + []), new("Legacy.Key", null, - [new SourceReference("Old.cs", 50, "MyApp", "GetString call")]) + [], + [new ReferenceSite(new SourceFilePath("/test/MyApp/Old.cs", "/test/MyApp"), 50, "GetString call")]) ]; [Fact] public Task I18NextJson() => - Verify(new I18NextJsonExporter().Export(TestEntries)); + Verify(new I18NextJsonExporter().Export(TestEntries, PathStyle.Relative)); [Fact] public Task Po() => - Verify(new PoExporter().Export(TestEntries)); + Verify(new PoExporter().Export(TestEntries, PathStyle.Relative)); [Fact] public Task GenericJson() => - Verify(new GenericJsonExporter().Export(TestEntries)); + Verify(new GenericJsonExporter().Export(TestEntries, PathStyle.Relative)); } diff --git a/tests/BlazorLocalization.Extractor.Tests/ExtensionsContractTests.cs b/tests/BlazorLocalization.Extractor.Tests/ExtensionsContractTests.cs new file mode 100644 index 0000000..d8186d2 --- /dev/null +++ b/tests/BlazorLocalization.Extractor.Tests/ExtensionsContractTests.cs @@ -0,0 +1,130 @@ +using System.Reflection; +using BlazorLocalization.Extensions; +using BlazorLocalization.Extensions.Translation; +using BlazorLocalization.Extensions.Translation.Definitions; +using BlazorLocalization.Extractor.Adapters.Roslyn; +using FluentAssertions; + +namespace BlazorLocalization.Extractor.Tests; + +/// +/// Validates that the parameter-name string constants in +/// match the actual parameter names in the Extensions project's public API. +/// These can't use nameof (C# doesn't support it for parameters), so this +/// reflection-based test is the safety net against silent renames. +/// +public class ExtensionsContractTests +{ + [Fact] + public void Translation_Simple_ParameterNames() + { + // SimpleBuilder Translation(string key, string message, object? replaceWith = null) + var method = GetExtensionMethod("Translation", typeof(string), typeof(string)); + method.Should().NotBeNull("SimpleBuilder Translation(key, message) overload should exist"); + AssertParam(method!, ExtensionsContract.ParamKey); + AssertParam(method!, ExtensionsContract.ParamMessage); + } + + [Fact] + public void Translation_Plural_ParameterNames() + { + // PluralBuilder Translation(string key, int howMany, bool ordinal = false, ...) + var method = GetExtensionMethod("Translation", typeof(string), typeof(int)); + method.Should().NotBeNull("PluralBuilder Translation(key, howMany) overload should exist"); + AssertParam(method!, ExtensionsContract.ParamKey); + AssertParam(method!, ExtensionsContract.ParamHowMany); + AssertParam(method!, ExtensionsContract.ParamOrdinal); + } + + [Fact] + public void DefineSimple_ParameterNames() + { + var method = typeof(TranslationDefinitions).GetMethod( + ExtensionsContract.DefineSimple, + [typeof(string), typeof(string)]); + method.Should().NotBeNull(); + AssertParam(method!, ExtensionsContract.ParamKey); + AssertParam(method!, ExtensionsContract.ParamMessage); + } + + [Fact] + public void DefinePlural_ParameterNames() + { + var method = typeof(TranslationDefinitions).GetMethod( + ExtensionsContract.DefinePlural, + [typeof(string)]); + method.Should().NotBeNull(); + AssertParam(method!, ExtensionsContract.ParamKey); + } + + [Fact] + public void SimpleBuilder_For_ParameterNames() + { + var method = typeof(SimpleBuilder).GetMethod( + ExtensionsContract.ChainFor, + [typeof(string), typeof(string)]); + method.Should().NotBeNull(); + AssertParam(method!, ExtensionsContract.ParamLocale); + AssertParam(method!, ExtensionsContract.ParamMessage); + } + + [Fact] + public void PluralBuilder_Exactly_ParameterNames() + { + var method = typeof(PluralBuilder).GetMethod( + ExtensionsContract.ChainExactly, + [typeof(int), typeof(string)]); + method.Should().NotBeNull(); + AssertParam(method!, ExtensionsContract.ParamValue); + AssertParam(method!, ExtensionsContract.ParamMessage); + } + + [Fact] + public void PluralBuilder_Category_ParameterNames() + { + // All category methods (One, Other, Zero, etc.) take a single 'message' param + foreach (var name in new[] { "One", "Other", "Zero", "Two", "Few", "Many" }) + { + var method = typeof(PluralBuilder).GetMethod(name, [typeof(string)]); + method.Should().NotBeNull($"PluralBuilder.{name}(string) should exist"); + AssertParam(method!, ExtensionsContract.ParamMessage); + } + } + + [Fact] + public void TranslationAttribute_PropertyNames() + { + typeof(TranslationAttribute).GetProperty(ExtensionsContract.AttrLocale) + .Should().NotBeNull(); + typeof(TranslationAttribute).GetProperty(ExtensionsContract.AttrKey) + .Should().NotBeNull(); + } + + // ── Helpers ────────────────────────────────────────────────────── + + /// + /// Finds a Translation extension method by matching two parameter types. + /// C#14 extension block methods may compile with the receiver as param 0 or not β€” + /// so we search for any two params matching the given types regardless of position. + /// + private static MethodInfo? GetExtensionMethod(string name, Type firstArgType, Type secondArgType) + { + return typeof(BlazorLocalization.Extensions.StringLocalizerExtensions) + .GetMethods(BindingFlags.Public | BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(m => m.Name == name && !m.IsGenericMethod) + .FirstOrDefault(m => + { + var p = m.GetParameters(); + return p.Any(pp => pp.Name == "key" && pp.ParameterType == firstArgType) + && p.Any(pp => pp.ParameterType == secondArgType && pp.Name != "key"); + }); + } + + private static void AssertParam(MethodInfo method, string expectedParamName) + { + method.GetParameters() + .Select(p => p.Name) + .Should().Contain(expectedParamName, + $"{method.DeclaringType!.Name}.{method.Name} should have parameter '{expectedParamName}'"); + } +} diff --git a/tests/BlazorLocalization.Extractor.Tests/ExtractRequestTests.cs b/tests/BlazorLocalization.Extractor.Tests/ExtractRequestTests.cs index 5b18f31..a837842 100644 --- a/tests/BlazorLocalization.Extractor.Tests/ExtractRequestTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/ExtractRequestTests.cs @@ -1,5 +1,5 @@ +using BlazorLocalization.Extractor.Adapters.Cli.Commands; using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Requests; using FluentAssertions; namespace BlazorLocalization.Extractor.Tests; @@ -14,7 +14,7 @@ private static ExtractRequest MakeRequest( new( ProjectDirs: projectDirs ?? ["/proj"], Format: ExportFormat.I18Next, - Output: output ?? OutputTarget.Stdout, + Output: output ?? new OutputTarget.StdoutTarget(), LocaleFilter: localeFilter, SourceOnly: sourceOnly, PathStyle: PathStyle.Relative, @@ -36,7 +36,7 @@ public void NoProjects_ReturnsError() var request = MakeRequest(projectDirs: []); request.Validate().Should().ContainSingle() - .Which.Should().Contain("No .csproj projects found"); + .Which.Should().Contain("No projects to scan"); } [Fact] @@ -56,17 +56,16 @@ public void Stdout_MultipleProjects_ReturnsError() var request = MakeRequest(projectDirs: ["/proj1", "/proj2"]); request.Validate().Should().ContainSingle() - .Which.Should().Contain("Multiple projects"); + .Which.Should().Contain("single project"); } [Fact] - public void Stdout_MultipleLocales_ReturnsError() + public void Stdout_MultipleLocales_IsValid() { var request = MakeRequest( localeFilter: new(StringComparer.OrdinalIgnoreCase) { "da", "es-MX" }); - request.Validate().Should().ContainSingle() - .Which.Should().Contain("one locale at a time"); + request.Validate().Should().BeEmpty(); } [Fact] @@ -83,10 +82,10 @@ public void FileOutput_MultipleProjects_ReturnsError() { var request = MakeRequest( projectDirs: ["/proj1", "/proj2"], - output: OutputTarget.File("out.po")); + output: new OutputTarget.FileTarget("out.po")); request.Validate().Should().ContainSingle() - .Which.Should().Contain("Multiple projects require -o "); + .Which.Should().Contain("single project"); } [Fact] @@ -94,7 +93,7 @@ public void DirOutput_MultipleProjects_Valid() { var request = MakeRequest( projectDirs: ["/proj1", "/proj2"], - output: OutputTarget.Dir("./out")); + output: new OutputTarget.DirTarget("./out")); request.Validate().Should().BeEmpty(); } @@ -116,7 +115,7 @@ public class OutputTargetTests [Fact] public void Null_ReturnsStdout() { - OutputTarget.FromRawOutput(null).Should().Be(OutputTarget.Stdout); + OutputTarget.FromRawOutput(null).Should().BeOfType(); } [Fact] diff --git a/tests/BlazorLocalization.Extractor.Tests/InspectRequestTests.cs b/tests/BlazorLocalization.Extractor.Tests/InspectRequestTests.cs index dfac961..c8a6c4f 100644 --- a/tests/BlazorLocalization.Extractor.Tests/InspectRequestTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/InspectRequestTests.cs @@ -1,5 +1,5 @@ +using BlazorLocalization.Extractor.Adapters.Cli.Commands; using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Requests; using FluentAssertions; namespace BlazorLocalization.Extractor.Tests; @@ -8,13 +8,13 @@ public class InspectRequestTests { private static InspectRequest MakeRequest( IReadOnlyList? projectDirs = null, - HashSet? localeFilter = null, - bool sourceOnly = false) => + HashSet? localeFilter = null) => new( ProjectDirs: projectDirs ?? ["/proj"], JsonOutput: false, LocaleFilter: localeFilter, - SourceOnly: sourceOnly, + ShowResxLocales: false, + ShowExtractedCalls: false, PathStyle: PathStyle.Relative); [Fact] @@ -31,18 +31,7 @@ public void NoProjects_ReturnsError() var request = MakeRequest(projectDirs: []); request.Validate().Should().ContainSingle() - .Which.Should().Contain("No .csproj projects found"); - } - - [Fact] - public void SourceOnly_WithLocale_ReturnsError() - { - var request = MakeRequest( - sourceOnly: true, - localeFilter: new(StringComparer.OrdinalIgnoreCase) { "da" }); - - request.Validate().Should().ContainSingle() - .Which.Should().Contain("--source-only").And.Contain("--locale"); + .Which.Should().Contain("No projects to scan"); } [Fact] diff --git a/tests/BlazorLocalization.Extractor.Tests/MergeTests.cs b/tests/BlazorLocalization.Extractor.Tests/MergeTests.cs index 769fbc0..bf5b29b 100644 --- a/tests/BlazorLocalization.Extractor.Tests/MergeTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/MergeTests.cs @@ -1,40 +1,54 @@ +using BlazorLocalization.Extractor.Application; using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; +using BlazorLocalization.Extractor.Ports; using FluentAssertions; namespace BlazorLocalization.Extractor.Tests; public class MergeTests { - private static SourceReference Source(string file, int line) => - new(file, line, "TestProject", null); + private static DefinitionSite DefSite(string file, int line, DefinitionKind kind = DefinitionKind.InlineTranslation) => + new(new SourceFilePath(file, "/test/TestProject"), line, kind); + + private static ReferenceSite RefSite(string file, int line) => + new(new SourceFilePath(file, "/test/TestProject"), line); + + private record TestScannerOutput( + IReadOnlyList Definitions, + IReadOnlyList References, + IReadOnlyList Diagnostics) : IScannerOutput; + + private static IScannerOutput Output( + IReadOnlyList? defs = null, + IReadOnlyList? refs = null) => + new TestScannerOutput(defs ?? [], refs ?? [], []); [Fact] - public void SameKey_SameText_MergesSources_NoConflict() + public void SameKey_SameText_MergesDefinitions_NoConflict() { - var entries = new List - { - new("Key1", new SingularText("Hello"), Source("A.cs", 1)), - new("Key1", new SingularText("Hello"), Source("B.cs", 5)) - }; + var output = Output(defs: + [ + new("Key1", new SingularText("Hello"), DefSite("A.cs", 1)), + new("Key1", new SingularText("Hello"), DefSite("B.cs", 5)) + ]); - var result = MergedTranslationEntry.FromRaw(entries); + var result = TranslationPipeline.Run([output]); result.Entries.Should().ContainSingle() - .Which.Sources.Should().HaveCount(2); + .Which.Definitions.Should().HaveCount(2); result.Conflicts.Should().BeEmpty(); } [Fact] public void SameKey_DifferentText_DetectsConflict() { - var entries = new List - { - new("Key1", new SingularText("Hello"), Source("A.cs", 1)), - new("Key1", new SingularText("Goodbye"), Source("B.cs", 5)) - }; + var output = Output(defs: + [ + new("Key1", new SingularText("Hello"), DefSite("A.cs", 1)), + new("Key1", new SingularText("Goodbye"), DefSite("B.cs", 5)) + ]); - var result = MergedTranslationEntry.FromRaw(entries); + var result = TranslationPipeline.Run([output]); result.Entries.Should().ContainSingle(); result.Conflicts.Should().ContainSingle() @@ -45,13 +59,11 @@ public void SameKey_DifferentText_DetectsConflict() [Fact] public void Definition_PlusReference_DefinitionWins() { - var entries = new List - { - new("Key1", null, Source("Indexer.cs", 1)), - new("Key1", new SingularText("Real text"), Source("Translation.cs", 10)) - }; + var output = Output( + defs: [new("Key1", new SingularText("Real text"), DefSite("Translation.cs", 10))], + refs: [new("Key1", true, RefSite("Indexer.cs", 1))]); - var result = MergedTranslationEntry.FromRaw(entries); + var result = TranslationPipeline.Run([output]); result.Entries.Should().ContainSingle() .Which.SourceText.Should().BeOfType() diff --git a/tests/BlazorLocalization.Extractor.Tests/ModuleInit.cs b/tests/BlazorLocalization.Extractor.Tests/ModuleInit.cs index 152ec41..7687a22 100644 --- a/tests/BlazorLocalization.Extractor.Tests/ModuleInit.cs +++ b/tests/BlazorLocalization.Extractor.Tests/ModuleInit.cs @@ -1,5 +1,7 @@ using System.Runtime.CompilerServices; +namespace BlazorLocalization.Extractor.Tests; + static class ModuleInit { [ModuleInitializer] @@ -9,4 +11,4 @@ public static void Init() // In CI or automated runs, we want test failure output, not GUI windows. DiffEngine.DiffRunner.Disabled = true; } -} +} \ No newline at end of file diff --git a/tests/BlazorLocalization.Extractor.Tests/ProjectDiscoveryTests.cs b/tests/BlazorLocalization.Extractor.Tests/ProjectDiscoveryTests.cs index c0615b6..5688031 100644 --- a/tests/BlazorLocalization.Extractor.Tests/ProjectDiscoveryTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/ProjectDiscoveryTests.cs @@ -1,4 +1,4 @@ -using BlazorLocalization.Extractor.Scanning; +using BlazorLocalization.Extractor.Adapters.Cli; using FluentAssertions; namespace BlazorLocalization.Extractor.Tests; diff --git a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_FullOutput.verified.txt b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_FullOutput.verified.txt index 36a8101..914fe4c 100644 --- a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_FullOutput.verified.txt +++ b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_FullOutput.verified.txt @@ -1,6 +1,26 @@ ο»Ώ{ - "RC.Title": "Connection Interrupted", + "AppTitle": "Sample Blazor Application", + "CB.CodeBehind": "From code-behind", "CB.Razor": "From Razor side", + "Def.Cart_one": "{ItemCount} item", + "Def.Cart_other": "{ItemCount} items", + "Def.Greeting_Alpha": "Hello Alpha", + "Def.Greeting_Beta": "Hello Beta", + "Def.Greeting": "Hello friend", + "Def.Inbox_Alpha_one": "{ItemCount} Alpha message", + "Def.Inbox_Alpha_other": "{ItemCount} Alpha messages", + "Def.Inbox_one": "{ItemCount} message", + "Def.Inbox_other": "{ItemCount} messages", + "Def.Save": "Save", + "Enum.FlightStatus_Delayed": "Delayed", + "Flight.Late": "Arrived a bit late", + "R01.IndexerResolved": "Resolved from database via indexer", + "R03.RuntimeTarget": "You can only reach me at runtime", + "R04.WithCultures": "Base text with culture variants", + "RC.Title": "Connection Interrupted", + "Resx.Conflict": "Code says this", + "Resx.Match": "Matching source text", + "Resx.Only": "Only in resx, no code counterpart", "S01": "Hello World", "S02": "Hi", "S03_zero": "no items", @@ -39,9 +59,6 @@ "S11_one": "{ItemCount} other item", "S11_other": "{ItemCount} other items", "S12": "", - "_dynamicKey": "Dynamic message", - "Resx.Match": "Matching source text", - "Resx.Conflict": "Code says this", "S16.Attr": "Search…", "S17.TernA": "Edit", "S17.TernB": "Add", @@ -49,24 +66,12 @@ "S19.A": "First", "S19.B": "Second", "S20.TextBlock": "Inside text block", - "S23.Placeholder": "Visited {VisitCount} times", - "S21.Verbatim": "Line one\nLine two with \u0022quotes\u0022", - "S21.Raw": "This has \u0022raw quotes\u0022 inside", "S21.Concat": "First half second half", + "S21.Raw": "This has \u0022raw quotes\u0022 inside", + "S21.Verbatim": "Line one\nLine two with \u0022quotes\u0022", "S22.Nested": "Nested value", - "Def.Save": "Save", - "Def.Cart_one": "{ItemCount} item", - "Def.Cart_other": "{ItemCount} items", - "Def.Greeting_Alpha": "Hello Alpha", - "Def.Greeting_Beta": "Hello Beta", - "Def.Greeting": "Hello friend", - "Def.Inbox_Alpha_one": "{ItemCount} Alpha message", - "Def.Inbox_Alpha_other": "{ItemCount} Alpha messages", - "Def.Inbox_one": "{ItemCount} message", - "Def.Inbox_other": "{ItemCount} messages", - "CB.CodeBehind": "From code-behind", - "Enum.FlightStatus_Delayed": "Delayed", - "Flight.Late": "Arrived a bit late", - "Resx.CultureOnly": "", - "Resx.Only": "Only in resx, no code counterpart" + "S23.Placeholder": "Visited {VisitCount} times", + "WelcomeMessage": "Welcome to the localization test matrix", + "_dynamicNoResx": "", + "_dynamicWithResx": "" } \ No newline at end of file diff --git a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_PerLocale_Da.verified.txt b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_PerLocale_Da.verified.txt index a2021d1..d1cd338 100644 --- a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_PerLocale_Da.verified.txt +++ b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_PerLocale_Da.verified.txt @@ -1,16 +1,5 @@ ο»Ώ{ - "S02": "Hej", - "S06_one": "{ItemCount} genstand", - "S06_other": "{ItemCount} genstande", - "S08_Alpha": "Alfa-sti", - "S08": "Anden sti", - "S11_Alpha_one": "{ItemCount} Alfa-genstand", - "S11_Alpha_other": "{ItemCount} Alfa-genstande", - "S11_one": "{ItemCount} anden genstand", - "S11_other": "{ItemCount} andre genstande", - "Resx.Match": "Matchende kildetekst", - "Resx.Conflict": "Resx siger dette", - "Def.Save": "Gem", + "AppTitle": "Eksempel Blazor-applikation", "Def.Cart_one": "{ItemCount} vare", "Def.Cart_other": "{ItemCount} varer", "Def.Greeting_Alpha": "Hej Alfa", @@ -20,7 +9,22 @@ "Def.Inbox_Alpha_other": "{ItemCount} Alfa-beskeder", "Def.Inbox_one": "{ItemCount} besked", "Def.Inbox_other": "{ItemCount} beskeder", + "Def.Save": "Gem", "Enum.FlightStatus_Delayed": "Forsinket", - "Resx.CultureOnly": "Kun i dansk, ikke i neutral", - "Resx.Only": "Kun i resx, ingen kode-modpart" + "R01.IndexerResolved": "OplΓΈst fra database via indekser", + "R03.RuntimeTarget": "Du kan kun nΓ₯ mig ved kΓΈrsel", + "R04.WithCultures": "Grundtekst med kulturvarianter", + "Resx.Conflict": "Resx siger dette", + "Resx.Match": "Matchende kildetekst", + "Resx.Only": "Kun i resx, ingen kode-modpart", + "S02": "Hej", + "S06_one": "{ItemCount} genstand", + "S06_other": "{ItemCount} genstande", + "S08_Alpha": "Alfa-sti", + "S08": "Anden sti", + "S11_Alpha_one": "{ItemCount} Alfa-genstand", + "S11_Alpha_other": "{ItemCount} Alfa-genstande", + "S11_one": "{ItemCount} anden genstand", + "S11_other": "{ItemCount} andre genstande", + "WelcomeMessage": "Velkommen til lokaliseringstestmatrixen" } \ No newline at end of file diff --git a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_PerLocale_EsMx.verified.txt b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_PerLocale_EsMx.verified.txt index 9e8af7c..c6e5c07 100644 --- a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_PerLocale_EsMx.verified.txt +++ b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.I18NextJson_PerLocale_EsMx.verified.txt @@ -1,8 +1,11 @@ ο»Ώ{ - "S02": "Hola", - "Resx.Match": "Texto fuente coincidente", - "Resx.Conflict": "Resx dice esto", "Def.Save": "Guardar", "Enum.FlightStatus_Delayed": "Retrasado", - "Resx.Only": "Solo en resx, sin contraparte de cΓ³digo" + "R01.IndexerResolved": "Resuelto desde base de datos vΓ­a indexador", + "R03.RuntimeTarget": "Solo puedes alcanzarme en tiempo de ejecuciΓ³n", + "R04.WithCultures": "Texto base con variantes culturales", + "Resx.Conflict": "Resx dice esto", + "Resx.Match": "Texto fuente coincidente", + "Resx.Only": "Solo en resx, sin contraparte de cΓ³digo", + "S02": "Hola" } \ No newline at end of file diff --git a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.Po_FullOutput.verified.txt b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.Po_FullOutput.verified.txt index a4301f7..4ae6603 100644 --- a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.Po_FullOutput.verified.txt +++ b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.Po_FullOutput.verified.txt @@ -6,34 +6,120 @@ msgstr "" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Crowdin-SourceKey: msgstr\n" -#: tests/SampleBlazorApp/Components/Layout/ReconnectModal.razor:9 +#: Resources/SharedResource.resx:20 +msgid "AppTitle" +msgstr "Sample Blazor Application" + +#: Components/Pages/EdgeCasePage.razor.cs:11 #. .Translation -msgid "RC.Title" -msgstr "Connection Interrupted" +msgid "CB.CodeBehind" +msgstr "From code-behind" -#: tests/SampleBlazorApp/Components/Pages/EdgeCasePage.razor:5 +#: Components/Pages/EdgeCasePage.razor:5 #. .Translation msgid "CB.Razor" msgstr "From Razor side" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:15 +#: CommonDefinitions.cs:22 +#. TranslationDefinitions.DefinePlural +msgid "Def.Cart" +msgid_plural "Def.Cart" +msgstr[0] "{ItemCount} item" +msgstr[1] "{ItemCount} items" + +#: CommonDefinitions.cs:32 +#. TranslationDefinitions.DefineSelect +msgid "Def.Greeting_Alpha" +msgstr "Hello Alpha" + +msgid "Def.Greeting_Beta" +msgstr "Hello Beta" + +msgid "Def.Greeting" +msgstr "Hello friend" + +#: CommonDefinitions.cs:44 +#. TranslationDefinitions.DefineSelectPlural +msgid "Def.Inbox_Alpha" +msgid_plural "Def.Inbox_Alpha" +msgstr[0] "{ItemCount} Alpha message" +msgstr[1] "{ItemCount} Alpha messages" + +msgid "Def.Inbox" +msgid_plural "Def.Inbox" +msgstr[0] "{ItemCount} message" +msgstr[1] "{ItemCount} messages" + +#: CommonDefinitions.cs:15 +#. TranslationDefinitions.DefineSimple +msgid "Def.Save" +msgstr "Save" + +#: FlightStatus.cs:7 +msgid "Enum.FlightStatus_Delayed" +msgstr "Delayed" + +#: FlightStatus.cs:12 +msgid "Flight.Late" +msgstr "Arrived a bit late" + +#: Resources/Components/Pages/Home.resx:35 +#. Compile-time indexer reference β€” scanner can match this key deterministically +msgid "R01.IndexerResolved" +msgstr "Resolved from database via indexer" + +#: Resources/Components/Pages/Home.resx:40 +#. RESX entry exists but R03 code uses a variable key β€” scanner cannot match +msgid "R03.RuntimeTarget" +msgstr "You can only reach me at runtime" + +#: Resources/Components/Pages/Home.resx:45 +#. Compile-time indexer reference with culture-specific translations in da/es-MX +msgid "R04.WithCultures" +msgstr "Base text with culture variants" + +#: Components/Layout/ReconnectModal.razor:9 +#. .Translation +msgid "RC.Title" +msgstr "Connection Interrupted" + +#: Components/Pages/Home.razor:158 +#. .Translation +#: Resources/Components/Pages/Home.resx:25 +#. Different text from S15 Translation() call β€” should produce KeyConflict +msgid "Resx.Conflict" +msgstr "Code says this" + +#: Components/Pages/Home.razor:154 +#. .Translation +#: Resources/Components/Pages/Home.resx:20 +#. Same text as S14 Translation() call β€” should merge cleanly +msgid "Resx.Match" +msgstr "Matching source text" + +#: Resources/Components/Pages/Home.resx:30 +#. Passive entry β€” no Translation() call references this key +msgid "Resx.Only" +msgstr "Only in resx, no code counterpart" + +#: Components/Pages/Home.razor:15 #. .Translation msgid "S01" msgstr "Hello World" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:21 +#: Components/Pages/Home.razor:21 #. .Translation msgid "S02" msgstr "Hi" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:30 +#: Components/Pages/Home.razor:30 #. .Translation msgid "S03" msgid_plural "S03" msgstr[0] "{ItemCount} item" msgstr[1] "{ItemCount} items" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:43 +#: Components/Pages/Home.razor:43 #. .Translation #. ⚠️ ORDINAL β€” use ordinal forms (1st, 2nd, 3rd), not cardinal (1, 2, 3) msgid "S04" @@ -41,7 +127,7 @@ msgid_plural "S04" msgstr[0] "{Position}st" msgstr[1] "{Position}th" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:54 +#: Components/Pages/Home.razor:54 #. .Translation msgid "S05" msgid_plural "S05" @@ -51,14 +137,14 @@ msgstr[1] "{ItemCount} items" msgid "S05_exactly_0" msgstr "none at all" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:64 +#: Components/Pages/Home.razor:64 #. .Translation msgid "S06" msgid_plural "S06" msgstr[0] "{ItemCount} item" msgstr[1] "{ItemCount} items" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:76 +#: Components/Pages/Home.razor:76 #. .Translation msgid "S07_Alpha" msgstr "Alpha path" @@ -69,7 +155,7 @@ msgstr "Beta path" msgid "S07" msgstr "Other path" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:86 +#: Components/Pages/Home.razor:86 #. .Translation msgid "S08_Alpha" msgstr "Alpha path" @@ -77,7 +163,7 @@ msgstr "Alpha path" msgid "S08" msgstr "Other path" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:98 +#: Components/Pages/Home.razor:98 #. .Translation msgid "S09_Alpha" msgid_plural "S09_Alpha" @@ -94,7 +180,7 @@ msgid_plural "S09" msgstr[0] "{ItemCount} other item" msgstr[1] "{ItemCount} other items" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:114 +#: Components/Pages/Home.razor:114 #. .Translation #. ⚠️ ORDINAL β€” use ordinal forms (1st, 2nd, 3rd), not cardinal (1, 2, 3) msgid "S10_Alpha" @@ -111,7 +197,7 @@ msgid_plural "S10" msgstr[0] "{Position}st other" msgstr[1] "{Position}th other" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:128 +#: Components/Pages/Home.razor:128 #. .Translation msgid "S11_Alpha" msgid_plural "S11_Alpha" @@ -123,145 +209,80 @@ msgid_plural "S11" msgstr[0] "{ItemCount} other item" msgstr[1] "{ItemCount} other items" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:146 +#: Components/Pages/Home.razor:146 #. IStringLocalizer.this[] msgid "S12" msgstr "" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:150 -#. .Translation -msgid "_dynamicKey" -msgstr "Dynamic message" - -#: tests/SampleBlazorApp/Components/Pages/Home.razor:154 -#. .Translation -#: tests/SampleBlazorApp/Resources/Home.resx:20 -#. Same text as S14 Translation() call β€” should merge cleanly -msgid "Resx.Match" -msgstr "Matching source text" - -#: tests/SampleBlazorApp/Components/Pages/Home.razor:158 -#. .Translation -#: tests/SampleBlazorApp/Resources/Home.resx:25 -#. Different text from S15 Translation() call β€” should produce KeyConflict -msgid "Resx.Conflict" -msgstr "Code says this" - -#: tests/SampleBlazorApp/Components/Pages/Home.razor:162 +#: Components/Pages/Home.razor:162 #. .Translation msgid "S16.Attr" msgstr "Search…" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:166 +#: Components/Pages/Home.razor:166 #. .Translation msgid "S17.TernA" msgstr "Edit" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:166 +#: Components/Pages/Home.razor:166 #. .Translation msgid "S17.TernB" msgstr "Add" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:170 +#: Components/Pages/Home.razor:170 #. .Translation msgid "S18.Cast" msgstr "Text with bold" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:174 +#: Components/Pages/Home.razor:174 #. .Translation msgid "S19.A" msgstr "First" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:174 +#: Components/Pages/Home.razor:174 #. .Translation msgid "S19.B" msgstr "Second" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:178 +#: Components/Pages/Home.razor:178 #. .Translation msgid "S20.TextBlock" msgstr "Inside text block" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:190 +#: Components/Pages/Home.razor:264 #. .Translation -msgid "S23.Placeholder" -msgstr "Visited {VisitCount} times" - -#: tests/SampleBlazorApp/Components/Pages/Home.razor:229 -#. .Translation -msgid "S21.Verbatim" -msgstr "Line one\nLine two with \"quotes\"" +msgid "S21.Concat" +msgstr "First half second half" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:233 +#: Components/Pages/Home.razor:261 #. .Translation msgid "S21.Raw" msgstr "This has \"raw quotes\" inside" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:236 +#: Components/Pages/Home.razor:257 #. .Translation -msgid "S21.Concat" -msgstr "First half second half" +msgid "S21.Verbatim" +msgstr "Line one\nLine two with \"quotes\"" -#: tests/SampleBlazorApp/Components/Pages/Home.razor:239 +#: Components/Pages/Home.razor:267 #. .Translation msgid "S22.Nested" msgstr "Nested value" -#: tests/SampleBlazorApp/CommonDefinitions.cs:15 -#. TranslationDefinitions.DefineSimple -msgid "Def.Save" -msgstr "Save" - -#: tests/SampleBlazorApp/CommonDefinitions.cs:22 -#. TranslationDefinitions.DefinePlural -msgid "Def.Cart" -msgid_plural "Def.Cart" -msgstr[0] "{ItemCount} item" -msgstr[1] "{ItemCount} items" - -#: tests/SampleBlazorApp/CommonDefinitions.cs:32 -#. TranslationDefinitions.DefineSelect -msgid "Def.Greeting_Alpha" -msgstr "Hello Alpha" - -msgid "Def.Greeting_Beta" -msgstr "Hello Beta" - -msgid "Def.Greeting" -msgstr "Hello friend" - -#: tests/SampleBlazorApp/CommonDefinitions.cs:44 -#. TranslationDefinitions.DefineSelectPlural -msgid "Def.Inbox_Alpha" -msgid_plural "Def.Inbox_Alpha" -msgstr[0] "{ItemCount} Alpha message" -msgstr[1] "{ItemCount} Alpha messages" - -msgid "Def.Inbox" -msgid_plural "Def.Inbox" -msgstr[0] "{ItemCount} message" -msgstr[1] "{ItemCount} messages" - -#: tests/SampleBlazorApp/Components/Pages/EdgeCasePage.razor.cs:11 +#: Components/Pages/Home.razor:190 #. .Translation -msgid "CB.CodeBehind" -msgstr "From code-behind" - -#: tests/SampleBlazorApp/FlightStatus.cs:7 -msgid "Enum.FlightStatus_Delayed" -msgstr "Delayed" +msgid "S23.Placeholder" +msgstr "Visited {VisitCount} times" -#: tests/SampleBlazorApp/FlightStatus.cs:12 -msgid "Flight.Late" -msgstr "Arrived a bit late" +#: Resources/SharedResource.resx:24 +msgid "WelcomeMessage" +msgstr "Welcome to the localization test matrix" -#: tests/SampleBlazorApp/Resources/Home.da.resx:32 -#. Key only present in culture-specific file, not in neutral -msgid "Resx.CultureOnly" +#: Components/Pages/Home.razor:227 +msgid "_dynamicNoResx" msgstr "" -#: tests/SampleBlazorApp/Resources/Home.resx:30 -#. Passive entry β€” no Translation() call references this key -msgid "Resx.Only" -msgstr "Only in resx, no code counterpart" +#: Components/Pages/Home.razor:231 +msgid "_dynamicWithResx" +msgstr "" diff --git a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.cs b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.cs index 140196e..07d89a5 100644 --- a/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/SampleAppExportTests.cs @@ -1,5 +1,5 @@ -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Exporters; +using BlazorLocalization.Extractor.Adapters.Export; +using BlazorLocalization.Extractor.Domain; namespace BlazorLocalization.Extractor.Tests; @@ -11,11 +11,11 @@ public class SampleAppExportTests(SampleAppFixture fixture) : IClassFixture - Verify(new I18NextJsonExporter().Export(fixture.MergeResult.Entries.ToList())); + Verify(new I18NextJsonExporter().Export(fixture.MergeResult.Entries.ToList(), PathStyle.Relative)); [Fact] public Task Po_FullOutput() => - Verify(new PoExporter().Export(RelativizePaths(fixture.MergeResult.Entries))); + Verify(new PoExporter().Export(fixture.MergeResult.Entries.ToList(), PathStyle.Relative)); /// /// Per-locale files should contain only entries that have a .For() translation for that locale β€” @@ -33,23 +33,9 @@ private string ExportLocale(string locale) { var entries = fixture.MergeResult.Entries .Where(e => e.InlineTranslations is not null && e.InlineTranslations.ContainsKey(locale)) - .Select(e => new MergedTranslationEntry(e.Key, e.InlineTranslations![locale], e.Sources)) + .Select(e => new MergedTranslation(e.Key, e.InlineTranslations![locale], e.Definitions, e.References)) .ToList(); - return new I18NextJsonExporter().Export(entries); + return new I18NextJsonExporter().Export(entries, PathStyle.Relative); } - - /// - /// Converts absolute source paths to solution-relative forward-slash paths, - /// mirroring what the CLI does with PathStyle.Relative. - /// This avoids platform-dependent Verify path scrubbing issues. - /// - private List RelativizePaths(IReadOnlyList entries) => - entries.Select(e => e with - { - Sources = e.Sources.Select(s => s with - { - FilePath = Path.GetRelativePath(fixture.SolutionDirectory, s.FilePath).Replace('\\', '/') - }).ToList() - }).ToList(); } diff --git a/tests/BlazorLocalization.Extractor.Tests/SampleAppFixture.cs b/tests/BlazorLocalization.Extractor.Tests/SampleAppFixture.cs index f4bf072..b014ed9 100644 --- a/tests/BlazorLocalization.Extractor.Tests/SampleAppFixture.cs +++ b/tests/BlazorLocalization.Extractor.Tests/SampleAppFixture.cs @@ -1,51 +1,33 @@ +using BlazorLocalization.Extractor.Application; using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Entries; -using BlazorLocalization.Extractor.Scanning; -using BlazorLocalization.Extractor.Scanning.Providers; namespace BlazorLocalization.Extractor.Tests; /// -/// Runs Scanner + ResxImporter + merge against SampleBlazorApp once. +/// Runs ProjectScanner against SampleBlazorApp once. /// Shared across all SampleApp* test classes via . /// public sealed class SampleAppFixture { - public ScanResult ScanResult { get; } + public ProjectScanResult ScanResult { get; } public MergeResult MergeResult { get; } public string SolutionDirectory { get; } /// Keyed lookup into merged entries for spot-check assertions. - public IReadOnlyDictionary EntryByKey { get; } + public IReadOnlyDictionary EntryByKey { get; } public SampleAppFixture() { var (solutionDir, sampleAppDir) = ResolvePaths(); SolutionDirectory = solutionDir; - var providers = new ISourceProvider[] - { - new RazorGeneratedSourceProvider(sampleAppDir), - new CSharpFileSourceProvider(sampleAppDir) - }; - - ScanResult = new Scanner(providers).Run(); - - var codeEntries = ScanResult.Entries; - var resxEntries = ResxImporter.ImportFromProject(sampleAppDir); - - var all = new List(codeEntries.Count + resxEntries.Count); - all.AddRange(codeEntries); - all.AddRange(resxEntries); - - MergeResult = MergedTranslationEntry.FromRaw(all); + ScanResult = ProjectScanner.Scan(sampleAppDir); + MergeResult = ScanResult.MergeResult; EntryByKey = MergeResult.Entries.ToDictionary(e => e.Key); } private static (string SolutionDir, string SampleAppDir) ResolvePaths() { - // Walk up from the test assembly's output directory to the repo root, - // then navigate to tests/SampleBlazorApp. var dir = AppContext.BaseDirectory; while (dir is not null && !File.Exists(Path.Combine(dir, "BlazorLocalization.sln"))) dir = Directory.GetParent(dir)?.FullName; diff --git a/tests/BlazorLocalization.Extractor.Tests/SampleAppScannerTests.cs b/tests/BlazorLocalization.Extractor.Tests/SampleAppScannerTests.cs index f9fdd88..dede89f 100644 --- a/tests/BlazorLocalization.Extractor.Tests/SampleAppScannerTests.cs +++ b/tests/BlazorLocalization.Extractor.Tests/SampleAppScannerTests.cs @@ -1,6 +1,4 @@ using BlazorLocalization.Extractor.Domain; -using BlazorLocalization.Extractor.Domain.Calls; -using BlazorLocalization.Extractor.Domain.Entries; using FluentAssertions; namespace BlazorLocalization.Extractor.Tests; @@ -11,7 +9,7 @@ namespace BlazorLocalization.Extractor.Tests; /// public class SampleAppScannerTests(SampleAppFixture fixture) : IClassFixture { - private MergedTranslationEntry Entry(string key) => + private MergedTranslation Entry(string key) => fixture.EntryByKey.TryGetValue(key, out var e) ? e : throw new KeyNotFoundException($"No merged entry for key '{key}'"); @@ -31,8 +29,16 @@ public void AllExpectedKeysPresent() "S09", "S10", "S11", // Indexer "S12", - // Resx - "Resx.Match", "Resx.Conflict", "Resx.Only", "Resx.CultureOnly", + // Resx (CultureOnly excluded β€” new adapter skips keys without neutral counterpart) + "Resx.Match", "Resx.Conflict", "Resx.Only", + // Reference types (call-site β†’ .resx) + "R01.IndexerResolved", "R04.WithCultures", + // R03.RuntimeTarget exists in RESX but not matched by code (unreferenced .resx entry) + "R03.RuntimeTarget", + // Runtime variable keys (unresolvable β€” variable name extracted, not the value) + "_dynamicNoResx", "_dynamicWithResx", + // SharedResource RESX entries + "AppTitle", "WelcomeMessage", // Attribute, ternary, cast, multi-call, text block "S16.Attr", "S17.TernA", "S17.TernB", "S18.Cast", "S19.A", "S19.B", "S20.TextBlock", // Code-behind string literals @@ -41,6 +47,8 @@ public void AllExpectedKeysPresent() "S22.Nested", "S23.Placeholder", // Enum [Translation] attributes "Enum.FlightStatus_Delayed", "Flight.Late", + // Reusable definitions (DefineXxx) + "Def.Save", "Def.Cart", "Def.Greeting", "Def.Inbox", // EdgeCasePage "CB.Razor", "CB.CodeBehind", // ReconnectModal @@ -149,18 +157,8 @@ public void ResxEntry_HasCultureInlineTranslations() .Which.Value.Should().Be("Texto fuente coincidente"); } - [Fact] - public void ResxCultureOnlyEntry_NullSourceTextWithInlineTranslations() - { - var entry = Entry("Resx.CultureOnly"); - entry.SourceText.Should().BeNull(); - entry.InlineTranslations.Should().NotBeNull(); - entry.InlineTranslations!.Should().ContainKey("da"); - entry.InlineTranslations!["da"].Should().BeOfType() - .Which.Value.Should().Be("Kun i dansk, ikke i neutral"); - // es-MX does not have this key - entry.InlineTranslations!.Should().NotContainKey("es-MX"); - } + // ResxCultureOnlyEntry test removed β€” new adapter intentionally skips + // keys that exist only in culture .resx files (no neutral counterpart). [Fact] public void ResxConflict_Detected() @@ -212,13 +210,154 @@ public void EnumTranslation_CustomKey() } [Fact] - public void EnumTranslation_ProducesExtractedCalls() + public void EnumTranslation_HasDefinition_NoFakeReference() + { + var entry = Entry("Enum.FlightStatus_Delayed"); + entry.Definitions.Should().NotBeEmpty(); + entry.Definitions[0].Kind.Should().Be(DefinitionKind.EnumAttribute); + entry.References.Should().BeEmpty("enum attributes are definitions, not usages"); + entry.Status.Should().Be(TranslationStatus.Review, "no Loc.Display() usage can be resolved yet"); + } + + // ── DefinitionKind tests ── + + [Fact] + public void InlineTranslation_HasInlineKind() + { + Entry("S01").Definitions.First().Kind.Should().Be(DefinitionKind.InlineTranslation); + } + + [Fact] + public void ReusableDefinition_HasReusableKind() + { + Entry("Def.Save").Definitions.First().Kind.Should().Be(DefinitionKind.ReusableDefinition); + } + + [Fact] + public void ReusableDefinition_HasNoReference() + { + var entry = Entry("Def.Save"); + entry.References.Should().BeEmpty("DefineXxx is a data definition, not a usage"); + entry.Status.Should().Be(TranslationStatus.Review); + } + + [Fact] + public void EnumAttribute_HasEnumKind() + { + Entry("Enum.FlightStatus_Delayed").Definitions.First().Kind.Should().Be(DefinitionKind.EnumAttribute); + } + + [Fact] + public void ResourceFile_HasResourceKind() + { + Entry("Resx.Only").Definitions.First().Kind.Should().Be(DefinitionKind.ResourceFile); + } + + // ── Reference type tests (call-site β†’ .resx) ── + + [Fact] + public void CompileTimeIndexer_ResolvesResxSourceText() + { + Entry("R01.IndexerResolved").SourceText.Should().BeOfType() + .Which.Value.Should().Be("Resolved from database via indexer"); + } + + [Fact] + public void CompileTimeIndexer_ResolvesResxCultures() + { + var entry = Entry("R01.IndexerResolved"); + entry.InlineTranslations.Should().NotBeNull(); + entry.InlineTranslations!.Should().ContainKey("da"); + entry.InlineTranslations!.Should().ContainKey("es-MX"); + + entry.InlineTranslations["da"].Should().BeOfType() + .Which.Value.Should().Be("OplΓΈst fra database via indekser"); + } + + [Fact] + public void CompileTimeIndexer_WithCultures_ResolvesAll() + { + var entry = Entry("R04.WithCultures"); + entry.SourceText.Should().BeOfType() + .Which.Value.Should().Be("Base text with culture variants"); + entry.InlineTranslations.Should().NotBeNull(); + entry.InlineTranslations!.Should().ContainKey("da"); + entry.InlineTranslations!.Should().ContainKey("es-MX"); + + entry.InlineTranslations["da"].Should().BeOfType() + .Which.Value.Should().Be("Grundtekst med kulturvarianter"); + entry.InlineTranslations["es-MX"].Should().BeOfType() + .Which.Value.Should().Be("Texto base con variantes culturales"); + } + + [Fact] + public void UnreferencedResxEntry_ImportedWithoutCodeMatch() + { + Entry("R03.RuntimeTarget").SourceText.Should().BeOfType() + .Which.Value.Should().Be("You can only reach me at runtime"); + } + + [Fact] + public void CompileTimeIndexerWithoutResx_NullSourceText() + { + Entry("S12").SourceText.Should().BeNull(); + } + + // ── Origin & cross-reference tests ── + + [Fact] + public void ResxEntry_HasResxDefinition() + { + Entry("Resx.Only").Definitions.Should().Contain(d => d.File.IsResx); + } + + [Fact] + public void CodeOnlyEntry_HasNoResxDefinition() + { + Entry("S01").Definitions.Should().NotContain(d => d.File.IsResx); + } + + [Fact] + public void MatchedEntry_HasBothCodeAndResxDefinitions() + { + var entry = Entry("R01.IndexerResolved"); + entry.Definitions.Should().Contain(d => d.File.IsResx); + entry.References.Should().NotBeEmpty(); + } + + [Fact] + public void CompileTimeIndexer_IsKeyLiteralTrue() + { + Entry("R01.IndexerResolved").IsKeyLiteral.Should().BeTrue(); + } + + [Fact] + public void RuntimeIndexer_IsKeyLiteralFalse() + { + Entry("_dynamicNoResx").IsKeyLiteral.Should().BeFalse(); + } + + [Fact] + public void CrossReference_MatchedEntry_IsResolved() + { + Entry("R01.IndexerResolved").Status.Should().Be(TranslationStatus.Resolved); + } + + [Fact] + public void CrossReference_CodeOnlyWithSourceText_IsResolved() + { + Entry("S01").Status.Should().Be(TranslationStatus.Resolved); + } + + [Fact] + public void CrossReference_Unreferenced_IsReview() { - var enumCalls = fixture.ScanResult.Calls - .Where(c => c.CallKind == CallKind.AttributeDeclaration) - .ToList(); + Entry("Resx.Only").Status.Should().Be(TranslationStatus.Review); + } - enumCalls.Should().Contain(c => c.ContainingTypeName == "FlightStatus" && c.MethodName == "Delayed"); - enumCalls.Should().Contain(c => c.ContainingTypeName == "FlightStatus" && c.MethodName == "ArrivedABitLate"); + [Fact] + public void CrossReference_Unresolvable_IsReview() + { + Entry("_dynamicNoResx").Status.Should().Be(TranslationStatus.Review); } } diff --git a/tests/SampleBlazorApp/Components/Pages/Home.razor b/tests/SampleBlazorApp/Components/Pages/Home.razor index 638aa49..f58f40f 100644 --- a/tests/SampleBlazorApp/Components/Pages/Home.razor +++ b/tests/SampleBlazorApp/Components/Pages/Home.razor @@ -213,8 +213,36 @@ S29 β€” Definition: SelectPlural S29 Definition SelectPlural Alpha howMany=1, expect "1 Alpha message": @_defSelectPlural +@* ────────────────────────────────────────────────────────────────── *@ +@* Reference Type Test Matrix *@ +@* Tests the call-site to .resx reference model for RESX support *@ +@* ────────────────────────────────────────────────────────────────── *@ + +@* R01 β€” Compile-time indexer with RESX match *@ +R01 β€” Compile-time indexer (RESX match) +R01 Literal indexer key, RESX has entry β€” expect "Resolved from database via indexer": @Loc["R01.IndexerResolved"] + +@* R02 β€” Runtime indexer, no RESX *@ +R02 β€” Runtime indexer (no RESX) +R02 Variable key, no RESX entry β€” expect raw key fallback: @Loc[_dynamicNoResx] + +@* R03 β€” Runtime indexer, RESX exists but scanner can't match *@ +R03 β€” Runtime indexer (RESX exists, can't match) +R03 Variable key, RESX has the actual key but scanner can't resolve β€” expect raw key fallback: @Loc[_dynamicWithResx] + +@* R04 β€” Compile-time indexer with RESX match + cultures *@ +R04 β€” Compile-time indexer (RESX match + cultures) +R04 Literal indexer key, RESX has entry with da/es-MX β€” expect "Base text with culture variants": @Loc["R04.WithCultures"] + +@* R05 β€” SharedResource localizer (separate marker class) *@ +R05 β€” SharedResource (cross-component RESX) +R05 AppTitle, expect "Sample Blazor Application" (en) / "Eksempel Blazor-applikation" (da): @SharedLoc["AppTitle"] +R05 WelcomeMessage, expect "Welcome to the localization test matrix" (en) / "Velkommen til lokaliseringstestmatrixen" (da): @SharedLoc["WelcomeMessage"] + @code { private readonly string _dynamicKey = "S13.Dynamic"; + private readonly string _dynamicNoResx = "R02.Unknown"; + private readonly string _dynamicWithResx = "R03.RuntimeTarget"; private bool _flag = true; private string? _codeResult; private string? _nestedResult; diff --git a/tests/SampleBlazorApp/Components/_Imports.razor b/tests/SampleBlazorApp/Components/_Imports.razor index dd63d3e..b9e84af 100644 --- a/tests/SampleBlazorApp/Components/_Imports.razor +++ b/tests/SampleBlazorApp/Components/_Imports.razor @@ -13,3 +13,4 @@ @using Microsoft.Extensions.Localization @using BlazorLocalization.Extensions @using SampleBlazorApp.Components.Layout +@inject IStringLocalizer SharedLoc diff --git a/tests/SampleBlazorApp/Program.cs b/tests/SampleBlazorApp/Program.cs index 0889b4e..23cc09b 100644 --- a/tests/SampleBlazorApp/Program.cs +++ b/tests/SampleBlazorApp/Program.cs @@ -13,6 +13,7 @@ // Localization: builder.Services.AddSqliteCache(o => o.CachePath = "translations.db"); +// builder.Services.AddLocalization(options => options.ResourcesPath = "Resources"); builder.Services.AddProviderBasedLocalization(builder.Configuration); // Stub providers β€” uncomment one pair at a time to test different architectures. diff --git a/tests/SampleBlazorApp/Resources/Home.da.resx b/tests/SampleBlazorApp/Resources/Components/Pages/Home.da.resx similarity index 78% rename from tests/SampleBlazorApp/Resources/Home.da.resx rename to tests/SampleBlazorApp/Resources/Components/Pages/Home.da.resx index 748e2db..347f0b4 100644 --- a/tests/SampleBlazorApp/Resources/Home.da.resx +++ b/tests/SampleBlazorApp/Resources/Components/Pages/Home.da.resx @@ -33,4 +33,16 @@ Kun i dansk, ikke i neutral Key only present in culture-specific file, not in neutral + + + OplΓΈst fra database via indekser + + + + Du kan kun nΓ₯ mig ved kΓΈrsel + + + + Grundtekst med kulturvarianter + diff --git a/tests/SampleBlazorApp/Resources/Home.es-MX.resx b/tests/SampleBlazorApp/Resources/Components/Pages/Home.es-MX.resx similarity index 74% rename from tests/SampleBlazorApp/Resources/Home.es-MX.resx rename to tests/SampleBlazorApp/Resources/Components/Pages/Home.es-MX.resx index 72c5313..63ca5c1 100644 --- a/tests/SampleBlazorApp/Resources/Home.es-MX.resx +++ b/tests/SampleBlazorApp/Resources/Components/Pages/Home.es-MX.resx @@ -28,4 +28,16 @@ Solo en resx, sin contraparte de cΓ³digo + + + Resuelto desde base de datos vΓ­a indexador + + + + Solo puedes alcanzarme en tiempo de ejecuciΓ³n + + + + Texto base con variantes culturales + diff --git a/tests/SampleBlazorApp/Resources/Home.resx b/tests/SampleBlazorApp/Resources/Components/Pages/Home.resx similarity index 67% rename from tests/SampleBlazorApp/Resources/Home.resx rename to tests/SampleBlazorApp/Resources/Components/Pages/Home.resx index 2c00cbd..6122d9c 100644 --- a/tests/SampleBlazorApp/Resources/Home.resx +++ b/tests/SampleBlazorApp/Resources/Components/Pages/Home.resx @@ -31,4 +31,19 @@ Only in resx, no code counterpart Passive entry β€” no Translation() call references this key + + + Resolved from database via indexer + Compile-time indexer reference β€” scanner can match this key deterministically + + + + You can only reach me at runtime + RESX entry exists but R03 code uses a variable key β€” scanner cannot match + + + + Base text with culture variants + Compile-time indexer reference with culture-specific translations in da/es-MX + diff --git a/tests/SampleBlazorApp/Resources/SharedResource.da.resx b/tests/SampleBlazorApp/Resources/SharedResource.da.resx new file mode 100644 index 0000000..a7ac333 --- /dev/null +++ b/tests/SampleBlazorApp/Resources/SharedResource.da.resx @@ -0,0 +1,27 @@ + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Eksempel Blazor-applikation + + + + Velkommen til lokaliseringstestmatrixen + + diff --git a/tests/SampleBlazorApp/Resources/SharedResource.resx b/tests/SampleBlazorApp/Resources/SharedResource.resx new file mode 100644 index 0000000..bde1590 --- /dev/null +++ b/tests/SampleBlazorApp/Resources/SharedResource.resx @@ -0,0 +1,27 @@ + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + Sample Blazor Application + + + + Welcome to the localization test matrix + + diff --git a/tests/SampleBlazorApp/SharedResource.cs b/tests/SampleBlazorApp/SharedResource.cs new file mode 100644 index 0000000..9009318 --- /dev/null +++ b/tests/SampleBlazorApp/SharedResource.cs @@ -0,0 +1,4 @@ +namespace SampleBlazorApp; + +/// Marker class for shared (cross-component) localization resources. +public class SharedResource; From 651626f5c61f577b499e2983ca5f3d49f7f99ce2 Mon Sep 17 00:00:00 2001 From: linckez Date: Wed, 8 Apr 2026 22:19:29 +0200 Subject: [PATCH 2/2] refactor: streamline method documentation and improve enum descriptions for clarity --- .../Adapters/Roslyn/CallSiteBuilder.cs | 9 --------- .../Adapters/Roslyn/EnumAttributeInterpreter.cs | 4 ++-- .../Domain/ExportFormat.cs | 14 ++++++++++---- .../Domain/PathStyle.cs | 7 ++++++- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs index 24bdecc..fd27a26 100644 --- a/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/CallSiteBuilder.cs @@ -57,15 +57,6 @@ private static ScannedCallSite BuildFromInvocation( var returnType = method.ReturnType; var returnsBuilder = FluentChainWalker.IsBuilderType(returnType as INamedTypeSymbol); - // Extension method detection: - // - Classic extensions: ReducedFrom is set on the call-site symbol - // - C# 14 extension blocks: IsExtensionMethod may be true - // - Reduced instance calls: the receiver is the first param - var isExtension = method.IsExtensionMethod || - method.ReducedFrom is not null || - (invocation.Instance is null && method.Parameters.Length > 0 && - method.IsStatic); - // A call is a "definition" if it returns a builder type OR is a DefineXxx factory var isDefinitionLike = returnsBuilder || method.Name.StartsWith("Define", StringComparison.Ordinal); diff --git a/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs b/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs index 8d0cc8d..29c0add 100644 --- a/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs +++ b/src/BlazorLocalization.Extractor/Adapters/Roslyn/EnumAttributeInterpreter.cs @@ -12,8 +12,8 @@ internal static class EnumAttributeInterpreter { /// /// Inspects a field symbol (enum member) for [Translation] attributes. - /// Returns a and a matching - /// (so the entry gets status=Resolved instead of Review). + /// Returns a only β€” enum declarations are definitions, + /// not usages. The reference slot is always null. /// public static (TranslationDefinition? Definition, TranslationReference? Reference) TryInterpret( IFieldSymbol field, diff --git a/src/BlazorLocalization.Extractor/Domain/ExportFormat.cs b/src/BlazorLocalization.Extractor/Domain/ExportFormat.cs index ad67688..5981e95 100644 --- a/src/BlazorLocalization.Extractor/Domain/ExportFormat.cs +++ b/src/BlazorLocalization.Extractor/Domain/ExportFormat.cs @@ -1,16 +1,22 @@ +using System.ComponentModel; + namespace BlazorLocalization.Extractor.Domain; /// -/// The export format for serializing translation data. +/// Export format for extracted translation files. +/// [Description] attributes serve as single source of truth for both --help and interactive wizard prompts. /// public enum ExportFormat { - /// Full-fidelity JSON array with all metadata. + /// Generic JSON β€” full-fidelity array with source references, useful for debugging and downstream tooling. + [Description("Generic JSON (full-fidelity debug export with all metadata)")] Json, - /// Flat i18next JSON for Crowdin upload. + /// Crowdin i18next JSON β€” flat key/value pairs, plurals via _one/_other suffixes. + [Description("Crowdin i18next JSON (flat key/value, plurals via _one/_other)")] I18Next, - /// GNU Gettext PO template (.pot). + /// GNU Gettext PO β€” msgid/msgstr pairs with #: source references and #. translator comments. + [Description("GNU Gettext PO (with source references and translator comments)")] Po } diff --git a/src/BlazorLocalization.Extractor/Domain/PathStyle.cs b/src/BlazorLocalization.Extractor/Domain/PathStyle.cs index 4f35cfd..7a18cbf 100644 --- a/src/BlazorLocalization.Extractor/Domain/PathStyle.cs +++ b/src/BlazorLocalization.Extractor/Domain/PathStyle.cs @@ -1,13 +1,18 @@ +using System.ComponentModel; + namespace BlazorLocalization.Extractor.Domain; /// /// Controls how source file paths are written in export output. +/// [Description] attributes serve as single source of truth for both --help and interactive wizard prompts. /// public enum PathStyle { - /// Paths relative to the project root directory. + /// Paths relative to the project root directory (e.g. Components/Pages/Home.razor). + [Description("Paths relative to project root (recommended)")] Relative, /// Full absolute filesystem paths. + [Description("Full filesystem paths")] Absolute }