diff --git a/README.md b/README.md index 85d6d8e..005692b 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,126 @@ | Build | NuGet | |--|--| |![](https://github.com/dotnet-campus/dotnetCampus.SourceLocalizations/workflows/.NET%20Core/badge.svg)|[![](https://img.shields.io/nuget/v/dotnetCampus.SourceLocalizations.svg)](https://www.nuget.org/packages/dotnetCampus.SourceLocalizations)| + +dotnetCampus.SourceLocalizations is a source generator that can convert text localization files (e.g. .toml) into C# code and provide strong type support for localization keys. + +## Features + +```csharp +static void Main() +{ + Console.WriteLine(LocalizedText.Current.App.Title); // "Hello, World!" + Console.WriteLine(LocalizedText.Current.App.Description); // "This is a sample application." + Console.WriteLine(LocalizedText.Current.Cli.Usage); // "Usage: dotnetCampus.SourceLocalizations [options]" + Console.WriteLine(LocalizedText.Current.PressAnyKeyToExit); // "Press any key to exit..." +} +``` + +- Source Generators + - [x] Generate C# codes + - [ ] Generate properties for implementation types (so that reflections on types can get localized properties which is very important for WPF Bindings) + - [ ] Generate localized types for each language item which contains more than one arguments (This fixes different argument orders among different languages.) +- File formats + - [x] TOML + - [x] YAML `🤡 Might be deprecated in the future.` +- UI Frameworks Support + - [x] Avalonia `😉 We look forward to your better suggestions.` + - [ ] MAUI `😶‍🌫️ Not tested yet` + - [x] Uno Platform `😉 We look forward to your better suggestions.` + - [ ] Wpf `😶‍🌫️ Not tested yet` +- Diagnostics Analyzers and Code Fixes + - [ ] Detect (and generate) missing localization keys + - [ ] Detect (and remove) unused localization keys + - [ ] Detect arguments mismatch among localized texts (e.g. `Hello, {name:string}` in en but `こんにちは、{errorCode:int}` in ja) + - [ ] Detect invalid IETF language tags and report errors + +## Installation + +[![](https://img.shields.io/nuget/v/dotnetCampus.SourceLocalizations.svg)](https://www.nuget.org/packages/dotnetCampus.SourceLocalizations) + +```shell +dotnet add package dotnetCampus.SourceLocalizations +``` + +## Usage + +### 1. Create localization files + +```toml +// Localizations/en.toml +App.Title = "Hello, World!" +App.Description = "This is a sample application." +Cli.Usage = "Usage: dotnetCampus.SourceLocalizations [options]" +PressAnyKeyToExit = "Press any key to exit..." +``` + +```toml +// Localizations/zh-hans.toml +App.Title = "你好,世界!" +App.Description = "这是一个示例应用程序。" +Cli.Usage = "用法:dotnetCampus.SourceLocalizations [选项]" +PressAnyKeyToExit = "按任意键退出..." +``` + +The file name must conform to the [IETF BCP 47 standard](https://en.wikipedia.org/wiki/IETF_language_tag). + +Add these files to your project `csproj` file: + +```xml + + + +``` + +### 2. Write a localization class + +```csharp +// LocalizedText.cs +using dotnetCampus.SourceLocalizations; + +namespace SampleApp; + +// The default language is used to generate localization interfaces, so it must be the most complete one. +[LocalizedConfiguration(Default = "en", Current = "zh-hans")] +public partial class LocalizedText; +``` + +### 3. Use the generated code + +```csharp +// Program.cs +static void Main() +{ + Console.WriteLine(LocalizedText.Current.App.Title); // "Hello, World!" + Console.WriteLine(LocalizedText.Current.App.Description); // "This is a sample application." + Console.WriteLine(LocalizedText.Current.Cli.Usage); // "Usage: dotnetCampus.SourceLocalizations [options]" + Console.WriteLine(LocalizedText.Current.PressAnyKeyToExit); // "Press any key to exit..." +} +``` + +```xml + + + +``` + +```xml + + + +``` + +```csharp +// Uno Platform MainPage.xaml.cs +using dotnetCampus.Localizations; + +namespace dotnetCampus.SampleUnoApp; + +public sealed partial class MainPage : Page +{ + public MainPage() => InitializeComponent(); + + // IMPORTANT: The Lang property must be public. + public ILocalizedValues Lang => global::dotnetCampus.SampleUnoApp.Localizations.LocalizedText.Current; +} +``` diff --git a/dotnetCampus.SourceLocalizations.sln b/dotnetCampus.SourceLocalizations.sln index 7f50212..011f78f 100644 --- a/dotnetCampus.SourceLocalizations.sln +++ b/dotnetCampus.SourceLocalizations.sln @@ -24,6 +24,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{6E0F9A76 build\Version.props = build\Version.props EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "dotnetCampus.SourceLocalizations.PrivateGenerator", "src\dotnetCampus.SourceLocalizations.PrivateGenerator\dotnetCampus.SourceLocalizations.PrivateGenerator.csproj", "{452ACCD4-EB0A-46ED-A8F9-38B5AD57C9EB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A585D7EF-916E-45B1-AFB8-025DE9FD1647}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,9 +53,14 @@ Global {3170B151-6A25-4321-BF09-EFC516E32F8D}.Debug|Any CPU.Build.0 = Debug|Any CPU {3170B151-6A25-4321-BF09-EFC516E32F8D}.Release|Any CPU.ActiveCfg = Release|Any CPU {3170B151-6A25-4321-BF09-EFC516E32F8D}.Release|Any CPU.Build.0 = Release|Any CPU + {452ACCD4-EB0A-46ED-A8F9-38B5AD57C9EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {452ACCD4-EB0A-46ED-A8F9-38B5AD57C9EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {452ACCD4-EB0A-46ED-A8F9-38B5AD57C9EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {452ACCD4-EB0A-46ED-A8F9-38B5AD57C9EB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F330175F-FC20-4D42-921A-747AC296D1B9} = {E407F54C-8E41-4F4E-B9BF-9864B6178E5F} {3170B151-6A25-4321-BF09-EFC516E32F8D} = {4239BEE3-D480-4874-83E1-353B35F1BD86} + {452ACCD4-EB0A-46ED-A8F9-38B5AD57C9EB} = {A585D7EF-916E-45B1-AFB8-025DE9FD1647} EndGlobalSection EndGlobal diff --git a/samples/LocalizationSample/Program.cs b/samples/LocalizationSample/Program.cs index 7ffbdf9..4fddb7f 100644 --- a/samples/LocalizationSample/Program.cs +++ b/samples/LocalizationSample/Program.cs @@ -15,13 +15,13 @@ public static void Main(string[] args) internal partial class Lang; [EditorBrowsable(EditorBrowsableState.Never)] -public interface ILocalized_Root : ILocalizedStringProvider +public interface ILocalizedValues : ILocalizedStringProvider { - ILocalized_Root_A A => (ILocalized_Root_A)this; + ILocalizedValues_A A => (ILocalizedValues_A)this; } [EditorBrowsable(EditorBrowsableState.Never)] -public interface ILocalized_Root_A : ILocalizedStringProvider +public interface ILocalizedValues_A : ILocalizedStringProvider { LocalizedString A1 => this.Get0("A.A1"); @@ -30,8 +30,8 @@ public interface ILocalized_Root_A : ILocalizedStringProvider LocalizedString A3 => this.Get1("A.A3"); } -public class Lang_ZhHans(ILocalized_Root? fallback) : ILocalized_Root, - ILocalized_Root_A +public class Lang_ZhHans(ILocalizedValues? fallback) : ILocalizedValues, + ILocalizedValues_A { private readonly FrozenDictionary _strings = new Dictionary { @@ -45,8 +45,8 @@ public class Lang_ZhHans(ILocalized_Root? fallback) : ILocalized_Root, public string IetfLanguageTag => "zh-hans"; } -public class Lang_En(ILocalized_Root? fallback) : ILocalized_Root, - ILocalized_Root_A +public class Lang_En(ILocalizedValues? fallback) : ILocalizedValues, + ILocalizedValues_A { private readonly FrozenDictionary _strings = new Dictionary { diff --git a/src/dotnetCampus.Localizations.Analyzer/Assets/Analyzers/LspPlaceholder.cs b/src/dotnetCampus.Localizations.Analyzer/Assets/Analyzers/LspPlaceholder.cs index ceb162e..a9c6b6f 100644 --- a/src/dotnetCampus.Localizations.Analyzer/Assets/Analyzers/LspPlaceholder.cs +++ b/src/dotnetCampus.Localizations.Analyzer/Assets/Analyzers/LspPlaceholder.cs @@ -8,7 +8,7 @@ namespace dotnetCampus.Localizations.Assets.Analyzers; /// /// /// -public class LspPlaceholder(string ietfLanguageTag, ILocalized_Root? fallback) : ILocalized_Root +public class LspPlaceholder(string ietfLanguageTag, ILocalizedValues? fallback) : ILocalizedValues { /// public string IetfLanguageTag => ietfLanguageTag; diff --git a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalized_Root.g.cs b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalizedValues.g.cs similarity index 77% rename from src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalized_Root.g.cs rename to src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalizedValues.g.cs index fdf627f..15c6a3f 100644 --- a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalized_Root.g.cs +++ b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/ILocalizedValues.g.cs @@ -6,10 +6,10 @@ namespace dotnetCampus.Localizations.Assets.Templates; [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] -public interface ILocalized_Root : ILocalizedStringProvider +public partial interface ILocalizedValues : ILocalizedStringProvider { // - // ILocalized_Root_A A => (ILocalized_Root_A)this; + // ILocalizedValues A => (ILocalizedValues_A)this; // LocalizedString A1 => this.Get0("A.A1"); // } diff --git a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/Localization.g.cs b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/Localization.g.cs index 31c737f..d5f67f4 100644 --- a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/Localization.g.cs +++ b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/Localization.g.cs @@ -7,10 +7,10 @@ partial class Localization /// /// 获取默认的本地化字符串集。 /// - public static ILocalized_Root Default { get; } = new LspPlaceholder("default", null); + public static ILocalizedValues Default { get; } = new LspPlaceholder("default", null); /// /// 获取当前的本地化字符串集。 /// - public static ILocalized_Root Current { get; private set; } = new LspPlaceholder("current", null); + public static ILocalizedValues Current { get; private set; } = new LspPlaceholder("current", null); } diff --git a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/LocalizationValues.g.cs b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/LocalizationValues.g.cs index 38d242c..d463506 100644 --- a/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/LocalizationValues.g.cs +++ b/src/dotnetCampus.Localizations.Analyzer/Assets/Templates/LocalizationValues.g.cs @@ -6,7 +6,7 @@ namespace dotnetCampus.Localizations.Assets.Templates; /// [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] -public class LocalizationValues(ILocalized_Root? fallback) : ILocalized_Root +public class LocalizationValues(ILocalizedValues? fallback) : ILocalizedValues { /// public string IetfLanguageTag => "default"; diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationCodeTransformer.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationCodeTransformer.cs index 41e299b..27bfd5b 100644 --- a/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationCodeTransformer.cs +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/CodeTransforming/LocalizationCodeTransformer.cs @@ -45,7 +45,7 @@ public string ToInterfaceCodeText(string rootNamespace) => $""" using ILocalizedStringProvider = global::dotnetCampus.Localizations.ILocalizedStringProvider; using LocalizedString = global::dotnetCampus.Localizations.LocalizedString; -namespace {rootNamespace}.Localizations; +namespace {GeneratorInfo.RootNamespace}; {RecursiveConvertLocalizationTreeNodeToKeyInterfaceCode(Tree, 0)} """; @@ -57,8 +57,8 @@ private string RecursiveConvertLocalizationTreeNodeToKeyInterfaceCode(Localizati } var nodeTypeName = depth is 0 - ? "Root" - : "Root_" + string.Join("_", node.FullIdentifierKey); + ? "" + : "_" + string.Join("_", node.FullIdentifierKey); var propertyLines = node.Children.Select(x => { var identifierKey = string.Join("_", x.FullIdentifierKey); @@ -86,13 +86,13 @@ private string RecursiveConvertLocalizationTreeNodeToKeyInterfaceCode(Localizati } else { - return $" ILocalized_Root_{identifierKey} {x.IdentifierKey} => (ILocalized_Root_{identifierKey})this;"; + return $" ILocalizedValues_{identifierKey} {x.IdentifierKey} => (ILocalizedValues_{identifierKey})this;"; } }); return $$""" [global::System.ComponentModel.EditorBrowsable(global::System.ComponentModel.EditorBrowsableState.Never)] -public interface ILocalized_{{nodeTypeName}} : ILocalizedStringProvider +public{{(depth is 0 ? " partial" : "")}} interface ILocalizedValues{{nodeTypeName}} : ILocalizedStringProvider { {{string.Join("\n", propertyLines)}} } @@ -120,11 +120,11 @@ public string ToImplementationCodeText(string rootNamespace, string ietfLanguage var typeName = IetfLanguageTagToIdentifier(ietfLanguageTag); var template = GeneratorInfo.GetEmbeddedTemplateFile(); var code = template.Content - .Replace($"namespace {template.Namespace};", $"namespace {rootNamespace}.Localizations;") + .Replace($"namespace {template.Namespace};", $"namespace {GeneratorInfo.RootNamespace};") .Replace($"class {nameof(LocalizationValues)}", $"class {nameof(LocalizationValues)}_{typeName}") .Replace( - $" : ILocalized_Root", - $" : ILocalized_Root{string.Concat(EnumerateConvertTreeNodeToInterfaceNames(Tree.Children).Select(x => $",\n ILocalized_Root_{x}"))}") + $" : ILocalizedValues", + $" : ILocalizedValues{string.Concat(EnumerateConvertTreeNodeToInterfaceNames(Tree.Children).Select(x => $",\n ILocalizedValues_{x}"))}") .Replace("""IetfLanguageTag => "default";""", $"""IetfLanguageTag => "{ietfLanguageTag}";"""); var lines = LocalizationItems.Select(x => ConvertKeyValueToValueCodeLine(x.Key, x.Value)); code = TemplateRegexes.FlagRegex.Replace(code, string.Concat(lines)); diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationCurrentGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationCurrentGenerator.cs deleted file mode 100644 index 48466f1..0000000 --- a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationCurrentGenerator.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using dotnetCampus.Localizations.Assets.Templates; -using dotnetCampus.Localizations.Generators.ModelProviding; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -using static dotnetCampus.Localizations.Generators.ModelProviding.IetfLanguageTagExtensions; - -namespace dotnetCampus.Localizations.Generators; - -/// -/// 为静态的本地化中心类生成设置当前语言的方法。 -/// -[Generator] -public class LocalizationCurrentGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var localizationFilesProvider = context.AdditionalTextsProvider.SelectLocalizationFileModels(); - var localizationTypeprovider = context.SyntaxProvider.SelectGeneratingModels(); - context.RegisterSourceOutput(localizationTypeprovider.Combine(localizationFilesProvider.Collect()), Execute); - } - - private void Execute(SourceProductionContext context, (LocalizationGeneratingModel Left, ImmutableArray Right) modelTuple) - { - var ((typeNamespace, typeName, _, _), models) = modelTuple; - - var code = GenerateSetCurrentMethod(typeNamespace, typeName, models); - - context.AddSource($"{typeName}.g.cs", SourceText.From(code, Encoding.UTF8)); - } - - private string GenerateSetCurrentMethod(string typeNamespace, string typeName, ImmutableArray models) => $$""" -#nullable enable - -namespace {{typeNamespace}}; - -partial class {{typeName}} -{ - /// - /// 设置当前的本地化字符串集。 - /// - /// 要设置的 IETF 语言标签。 - public static void SetCurrent(string ietfLanguageTag) - { - Current = ietfLanguageTag switch - { -{{string.Join("\n", models.Select(x => ConvertModelToPatternMatch(typeNamespace, x)))}} - _ => throw new global::System.ArgumentException($"The language tag {ietfLanguageTag} is not supported.", nameof(ietfLanguageTag)), - }; - } -} - -"""; - - private string ConvertModelToPatternMatch(string typeNamespace, LocalizationFileModel model) - { - // "zh-hans" => new Lang_ZhHans(Default), - return $" \"{model.IetfLanguageTag}\" => new global::{typeNamespace}.Localizations.{nameof(LocalizationValues)}_{IetfLanguageTagToIdentifier(model.IetfLanguageTag)}(Default),"; - } -} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationFilesGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationFilesGenerator.cs deleted file mode 100644 index f56b1b1..0000000 --- a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationFilesGenerator.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System.Collections.Immutable; -using System.Text; -using dotnetCampus.Localizations.Assets.Templates; -using dotnetCampus.Localizations.Generators.CodeTransforming; -using dotnetCampus.Localizations.Generators.ModelProviding; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -namespace dotnetCampus.Localizations.Generators; - -/// -/// 为所有通过 LocalizationFile 指定的文件生成对应的 C# 代码。 -/// -[Generator] -public class LocalizationFilesGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var localizationFilesProvider = context.AdditionalTextsProvider.SelectLocalizationFileModels(); - var localizationTypeprovider = context.SyntaxProvider.SelectGeneratingModels(); - context.RegisterSourceOutput(localizationFilesProvider.Combine(localizationTypeprovider.Collect()), Execute); - } - - private void Execute(SourceProductionContext context, (LocalizationFileModel Left, ImmutableArray Right) modelTuple) - { - var ((model, ietfLanguageTag, textContent), localizationGeneratingModels) = modelTuple; - var options = localizationGeneratingModels.FirstOrDefault(); - - var transformer = new LocalizationCodeTransformer(textContent, model switch - { - "toml" => new TomlLocalizationFileReader(), - "yaml" => new YamlLocalizationFileReader(), - _ => throw new NotSupportedException($"Unsupported localization file format: {model}"), - }); - var code = transformer.ToImplementationCodeText(options.Namespace, ietfLanguageTag); - context.AddSource($"{nameof(LocalizationValues)}.{ietfLanguageTag}.g.cs", SourceText.From(code, Encoding.UTF8)); - - if (ietfLanguageTag == options.DefaultLanguage) - { - var keyCode = transformer.ToInterfaceCodeText(options.Namespace); - context.AddSource($"{nameof(ILocalized_Root)}.g.cs", SourceText.From(keyCode, Encoding.UTF8)); - } - } -} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationGenerator.cs deleted file mode 100644 index 4533bff..0000000 --- a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationGenerator.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Text; -using dotnetCampus.Localizations.Assets.Templates; -using dotnetCampus.Localizations.Generators.ModelProviding; -using dotnetCampus.Localizations.Utils.CodeAnalysis; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Text; - -using static dotnetCampus.Localizations.Generators.ModelProviding.IetfLanguageTagExtensions; - -namespace dotnetCampus.Localizations.Generators; - -/// -/// 为静态的本地化中心类生成分部实现。 -/// -/// -/// 本生成器会为下方示例中的类型生成分部实现: -/// -/// [LocalizedConfiguration(Default = "zh-hans", Current = "zh-hans")] -/// public static partial class Lang; -/// -/// -[Generator] -public class LocalizationGenerator : IIncrementalGenerator -{ - public void Initialize(IncrementalGeneratorInitializationContext context) - { - var localizationTypeprovider = context.SyntaxProvider.SelectGeneratingModels(); - context.RegisterSourceOutput(localizationTypeprovider, Execute); - } - - private void Execute(SourceProductionContext context, LocalizationGeneratingModel model) - { - var (typeNamespace, typeName, defaultLanguage, currentLanguage) = model; - var defaultLanguageIdentifier = IetfLanguageTagToIdentifier(defaultLanguage); - var currentLanguageIdentifier = IetfLanguageTagToIdentifier(currentLanguage); - - var localizationFile = GeneratorInfo.GetEmbeddedTemplateFile(); - var originalText = ReplaceNamespaceAndTypeName(localizationFile.Content, typeNamespace, typeName); - var code = originalText - .Replace("""ILocalized_Root Default { get; } = new LspPlaceholder("default", null)""", - $"global::{typeNamespace}.Localizations.ILocalized_Root Default {{ get; }} = new global::{typeNamespace}.Localizations.{nameof(LocalizationValues)}_{defaultLanguageIdentifier}(null)") - .Replace("""ILocalized_Root Current { get; private set; } = new LspPlaceholder("current", null)""", defaultLanguage == currentLanguage - ? $"global::{typeNamespace}.Localizations.ILocalized_Root Current {{ get; private set; }} = Default" - : $"global::{typeNamespace}.Localizations.ILocalized_Root Current {{ get; private set; }} = new global::{typeNamespace}.Localizations.{nameof(LocalizationValues)}_{currentLanguageIdentifier}(Default)"); - - context.AddSource($"{typeName}.g.cs", SourceText.From(code, Encoding.UTF8)); - } - - private static string ReplaceNamespaceAndTypeName(string sourceText, string rootNamespace, string? typeName) - { - var sourceSpan = sourceText.AsSpan(); - - var namespaceKeywordIndex = sourceText.IndexOf("namespace", StringComparison.Ordinal); - var namespaceStartIndex = namespaceKeywordIndex + "namespace".Length + 1; - var namespaceEndIndex = sourceText.IndexOf(";", namespaceStartIndex, StringComparison.Ordinal); - var typeKeywordMatch = TemplateRegexes.TypeRegex.Match(sourceText); - - if (typeName is null || !typeKeywordMatch.Success) - { - return string.Concat( - sourceSpan.Slice(0, namespaceStartIndex).ToString(), - rootNamespace, - sourceSpan.Slice(namespaceEndIndex, sourceSpan.Length - namespaceEndIndex).ToString() - ); - } - - var typeNameIndex = typeKeywordMatch.Groups[1].Index; - var typeNameLength = typeKeywordMatch.Groups[1].Length; - - return string.Concat( - sourceSpan.Slice(0, namespaceStartIndex).ToString(), - rootNamespace, - sourceSpan.Slice(namespaceEndIndex, typeNameIndex - namespaceEndIndex).ToString(), - typeName, - sourceSpan.Slice(typeNameIndex + typeNameLength, sourceSpan.Length - typeNameIndex - typeNameLength).ToString() - ); - } -} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationTypeGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationTypeGenerator.cs new file mode 100644 index 0000000..375785e --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/LocalizationTypeGenerator.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using System.Text; +using dotnetCampus.Localizations.Assets.Templates; +using dotnetCampus.Localizations.Generators.ModelProviding; +using dotnetCampus.Localizations.Utils.CodeAnalysis; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +using static dotnetCampus.Localizations.Generators.ModelProviding.IetfLanguageTagExtensions; + +namespace dotnetCampus.Localizations.Generators; + +/// +/// 为静态的本地化中心类生成分部实现。 +/// +/// +/// 本生成器会为下方示例中的类型生成分部实现: +/// +/// [LocalizedConfiguration(Default = "zh-hans", Current = "zh-hans")] +/// public static partial class Lang; +/// +/// +[Generator] +public class LocalizationTypeGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var localizationFilesProvider = context.AdditionalTextsProvider.SelectLocalizationFileModels(); + var localizationTypeprovider = context.SyntaxProvider.SelectGeneratingModels(); + context.RegisterSourceOutput(localizationTypeprovider.Combine(localizationFilesProvider.Collect()), Execute); + } + + private void Execute(SourceProductionContext context, (LocalizationGeneratingModel Left, ImmutableArray Right) modelTuple) + { + var ((typeNamespace, typeName, defaultLanguage, currentLanguage), models) = modelTuple; + var defaultLanguageIdentifier = IetfLanguageTagToIdentifier(defaultLanguage); + var currentLanguageIdentifier = IetfLanguageTagToIdentifier(currentLanguage); + + // 生成 Localization.current.g.cs + var currentCode = GenerateSetCurrentMethod(typeNamespace, typeName, models); + context.AddSource($"{typeName}.current.g.cs", SourceText.From(currentCode, Encoding.UTF8)); + + // 生成 Localization.default.g.cs + var localizationFile = GeneratorInfo.GetEmbeddedTemplateFile(); + var originalText = ReplaceNamespaceAndTypeName(localizationFile.Content, typeNamespace, typeName); + var defaultCode = originalText + .Replace("""ILocalizedValues Default { get; } = new LspPlaceholder("default", null)""", + $"global::{GeneratorInfo.RootNamespace}.ILocalizedValues Default {{ get; }} = new global::{GeneratorInfo.RootNamespace}.{nameof(LocalizationValues)}_{defaultLanguageIdentifier}(null)") + .Replace("""ILocalizedValues Current { get; private set; } = new LspPlaceholder("current", null)""", defaultLanguage == currentLanguage + ? $"global::{GeneratorInfo.RootNamespace}.ILocalizedValues Current {{ get; private set; }} = Default" + : $"global::{GeneratorInfo.RootNamespace}.ILocalizedValues Current {{ get; private set; }} = new global::{GeneratorInfo.RootNamespace}.{nameof(LocalizationValues)}_{currentLanguageIdentifier}(Default)"); + context.AddSource($"{typeName}.default.g.cs", SourceText.From(defaultCode, Encoding.UTF8)); + } + + private string GenerateSetCurrentMethod(string typeNamespace, string typeName, ImmutableArray models) => $$""" +#nullable enable + +namespace {{typeNamespace}}; + +partial class {{typeName}} +{ + /// + /// 设置当前的本地化字符串集。 + /// + /// 要设置的 IETF 语言标签。 + public static void SetCurrent(string ietfLanguageTag) + { + Current = ietfLanguageTag switch + { +{{string.Join("\n", models.Select(x => ConvertModelToPatternMatch(typeNamespace, x)))}} + _ => throw new global::System.ArgumentException($"The language tag {ietfLanguageTag} is not supported.", nameof(ietfLanguageTag)), + }; + } +} + +"""; + + private string ConvertModelToPatternMatch(string typeNamespace, LocalizationFileModel model) + { + // "zh-hans" => new Lang_ZhHans(Default), + return $" \"{model.IetfLanguageTag}\" => new global::{GeneratorInfo.RootNamespace}.{nameof(LocalizationValues)}_{IetfLanguageTagToIdentifier(model.IetfLanguageTag)}(Default),"; + } + + private static string ReplaceNamespaceAndTypeName(string sourceText, string rootNamespace, string? typeName) + { + var sourceSpan = sourceText.AsSpan(); + + var namespaceKeywordIndex = sourceText.IndexOf("namespace", StringComparison.Ordinal); + var namespaceStartIndex = namespaceKeywordIndex + "namespace".Length + 1; + var namespaceEndIndex = sourceText.IndexOf(";", namespaceStartIndex, StringComparison.Ordinal); + var typeKeywordMatch = TemplateRegexes.TypeRegex.Match(sourceText); + + if (typeName is null || !typeKeywordMatch.Success) + { + return string.Concat( + sourceSpan.Slice(0, namespaceStartIndex).ToString(), + rootNamespace, + sourceSpan.Slice(namespaceEndIndex, sourceSpan.Length - namespaceEndIndex).ToString() + ); + } + + var typeNameIndex = typeKeywordMatch.Groups[1].Index; + var typeNameLength = typeKeywordMatch.Groups[1].Length; + + return string.Concat( + sourceSpan.Slice(0, namespaceStartIndex).ToString(), + rootNamespace, + sourceSpan.Slice(namespaceEndIndex, typeNameIndex - namespaceEndIndex).ToString(), + typeName, + sourceSpan.Slice(typeNameIndex + typeNameLength, sourceSpan.Length - typeNameIndex - typeNameLength).ToString() + ); + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationFileModel.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationFileModel.cs index f9620b4..6293de5 100644 --- a/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationFileModel.cs +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/ModelProviding/LocalizationFileModel.cs @@ -3,7 +3,7 @@ namespace dotnetCampus.Localizations.Generators.ModelProviding; /// /// 本地化语言文件模型。 /// -/// 本地化语言文件的类型。 +/// 本地化语言文件的类型。 /// 此本地化语言文件所对应的 IETF 语言标签。 /// 本地化语言文件的内容。 -public readonly record struct LocalizationFileModel(string Type, string IetfLanguageTag, string Content); +public readonly record struct LocalizationFileModel(string FileFormat, string IetfLanguageTag, string Content); diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/PostInitializationGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/PostInitializationGenerator.cs new file mode 100644 index 0000000..fd9bac5 --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/PostInitializationGenerator.cs @@ -0,0 +1,30 @@ +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace dotnetCampus.Localizations.Generators; + +[Generator] +public class PostInitializationGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(Execute); + } + + private void Execute(IncrementalGeneratorPostInitializationContext context) + { + var code = CollectPostInitializationCode(); + context.AddSource("ILocalizedValues.g.cs", SourceText.From(code, Encoding.UTF8)); + } + + private string CollectPostInitializationCode() + { + return $""" +namespace {GeneratorInfo.RootNamespace}; + +partial interface ILocalizedValues; + +"""; + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/Generators/StringsGenerator.cs b/src/dotnetCampus.Localizations.Analyzer/Generators/StringsGenerator.cs new file mode 100644 index 0000000..fe59cdb --- /dev/null +++ b/src/dotnetCampus.Localizations.Analyzer/Generators/StringsGenerator.cs @@ -0,0 +1,48 @@ +using System.Collections.Immutable; +using System.Text; +using dotnetCampus.Localizations.Assets.Templates; +using dotnetCampus.Localizations.Generators.CodeTransforming; +using dotnetCampus.Localizations.Generators.ModelProviding; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace dotnetCampus.Localizations.Generators; + +/// +/// 为所有通过 LocalizationFile 指定的文件生成对应的 C# 代码。 +/// +[Generator] +public class StringsGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var localizationFilesProvider = context.AdditionalTextsProvider.SelectLocalizationFileModels().Collect(); + var localizationTypeprovider = context.SyntaxProvider.SelectGeneratingModels().Collect(); + context.RegisterSourceOutput(localizationFilesProvider.Combine(localizationTypeprovider), Execute); + } + + private void Execute(SourceProductionContext context, (ImmutableArray Left, ImmutableArray Right) models) + { + var localizationFiles = models.Left; + var options = models.Right.FirstOrDefault(); + + foreach (var file in localizationFiles) + { + var transformer = new LocalizationCodeTransformer(file.Content, file.FileFormat switch + { + "toml" => new TomlLocalizationFileReader(), + "yaml" => new YamlLocalizationFileReader(), + _ => throw new NotSupportedException($"Unsupported localization file format: {file.FileFormat}"), + }); + + var code = transformer.ToImplementationCodeText(options.Namespace, file.IetfLanguageTag); + context.AddSource($"{nameof(LocalizationValues)}.{file.IetfLanguageTag}.g.cs", SourceText.From(code, Encoding.UTF8)); + + if (file.IetfLanguageTag == options.DefaultLanguage) + { + var keyCode = transformer.ToInterfaceCodeText(options.Namespace); + context.AddSource($"{nameof(ILocalizedValues)}.g.cs", SourceText.From(keyCode, Encoding.UTF8)); + } + } + } +} diff --git a/src/dotnetCampus.Localizations.Analyzer/dotnetCampus.Localizations.Analyzer.csproj b/src/dotnetCampus.Localizations.Analyzer/dotnetCampus.Localizations.Analyzer.csproj index 3966f9e..1e946b6 100644 --- a/src/dotnetCampus.Localizations.Analyzer/dotnetCampus.Localizations.Analyzer.csproj +++ b/src/dotnetCampus.Localizations.Analyzer/dotnetCampus.Localizations.Analyzer.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/dotnetCampus.Localizations/Ietf/IetfLanguageTag.cs b/src/dotnetCampus.Localizations/Ietf/IetfLanguageTag.cs new file mode 100644 index 0000000..c0f3e76 --- /dev/null +++ b/src/dotnetCampus.Localizations/Ietf/IetfLanguageTag.cs @@ -0,0 +1,72 @@ +using System.Globalization; + +namespace dotnetCampus.Localizations.Ietf; + +/// +/// 表示符合 IETF BCP 47 标准的语言标签。 +/// +/// +/// 关于 IETF BCP 47 标准的语言标签,请参见:https://en.wikipedia.org/wiki/IETF_language_tag +/// +/// 数据由 INNA 提供,参见:https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry +/// +public readonly record struct IetfLanguageTag +{ + private readonly CultureInfo _cultureInfo; + + /// + /// 初始化 类的新实例。 + /// + /// 符合 IETF BCP 47 标准的语言标签。 + public IetfLanguageTag(string ietfLanguageTag) + { + _cultureInfo = CultureInfo.GetCultureInfo(ietfLanguageTag); + } + + /// + /// 初始化 类的新实例。 + /// + /// 区域性信息。 + public IetfLanguageTag(CultureInfo cultureInfo) + { + _cultureInfo = cultureInfo.IsReadOnly + ? CultureInfo.GetCultureInfo(cultureInfo.Name) + : cultureInfo; + } + + /// + /// 获取当前 IETF 语言标签的字符串表示形式。 + /// + /// + /// 虽然微软官方文档说明 已被弃用,应改成 , + /// 但实际上有且只有两个区域这两个值不同:zh-CHS 和 zh-CHT。
+ /// zh-CHS 的 Name 为 zh-CHS(被弃用)而 IetfLanguageTag 为 zh-Hans(符合标准); + /// zh-CHT 的 Name 为 zh-CHT(被弃用)而 IetfLanguageTag 为 zh-Hant(符合标准)。 + ///
+ public string Value => _cultureInfo.IetfLanguageTag; + + /// + /// 获取当前 IETF 语言标签的字符串表示形式。 + /// + /// 当前 IETF 语言标签的字符串表示形式。 + public override string ToString() => Value; + + /// + /// 获取当前 IETF 语言标签的字符串表示形式。 + /// + /// IETF 语言标签。 + /// + public static implicit operator string(IetfLanguageTag ietfLanguageTag) => ietfLanguageTag.Value; + + /// + /// 从字符串创建 实例。 + /// + /// + /// 符合 IETF BCP 47 标准的语言标签字符串。 + /// 如果字符串不符合 IETF BCP 47 标准,将会抛出 异常。 + /// 特别的,对于不符合 IETF BCP 47 标准但在 Windows 系统中可用的区域名称(如 zh-CN、zh-HK、zh-TW 等),此方法会自动将其转换为 IETF 语言标签。 + /// + /// IETF 语言标签。 + /// 字符串不符合 IETF BCP 47 标准。 + public static implicit operator IetfLanguageTag(string ietfLanguageTag) => new(ietfLanguageTag); +} diff --git a/src/dotnetCampus.Localizations/Ietf/IetfLanguageTags.cs b/src/dotnetCampus.Localizations/Ietf/IetfLanguageTags.cs new file mode 100644 index 0000000..3630aab --- /dev/null +++ b/src/dotnetCampus.Localizations/Ietf/IetfLanguageTags.cs @@ -0,0 +1,6 @@ +namespace dotnetCampus.Localizations.Ietf; + +/// +/// 包含所有 IETF 语言标签的字符串常量。 +/// +internal static partial class IetfLanguageTags; diff --git a/src/dotnetCampus.Localizations/Native/Windows/Kernel32.Localization.cs b/src/dotnetCampus.Localizations/Native/Windows/Kernel32.Localization.cs new file mode 100644 index 0000000..985f789 --- /dev/null +++ b/src/dotnetCampus.Localizations/Native/Windows/Kernel32.Localization.cs @@ -0,0 +1,52 @@ +using System.Runtime.InteropServices; + +namespace dotnetCampus.Localizations.Native.Windows; + +internal class Kernel32 +{ + /// + /// + /// Maximum length of a locale name. The maximum number of characters allowed for this string is 85, including a terminating null character. + /// Your application must use the constant for the maximum locale name length, instead of hard-coding the value "85". + /// + /// + /// From: + /// + /// + public const int LOCALE_NAME_MAX_LENGTH = 85; + + /// + /// + /// Retrieves the user default locale name. + /// Note + /// The application should call this function in preference to GetUserDefaultLCID if designed to run only on Windows Vista and later. + /// + /// + /// From: + /// + /// + /// + /// Pointer to a buffer in which this function retrieves the locale name. + /// + /// + /// Size, in characters, of the buffer indicated by . + /// The maximum possible length of a locale name, including a terminating null character, is . + /// This is the recommended size to supply in this parameter. + /// + /// + /// Returns the size of the buffer containing the locale name, including the terminating null character, if successful. + /// Note + /// On single-user systems, the return value is the same as that returned by GetSystemDefaultLocaleName. + /// The function returns 0 if it does not succeed. + /// To get extended error information, the application can call GetLastError, + /// which can return one of the following error codes: + /// ERROR_INSUFFICIENT_BUFFER. A supplied buffer size was not large enough, or it was incorrectly set to NULL. + /// + /// + /// This function can retrieve data from custom locales. + /// Data is not guaranteed to be the same from computer to computer or between runs of an application. + /// If your application must persist or transmit data, see Using Persistent Locale Data. + /// + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, EntryPoint = "GetUserDefaultLocaleName", ExactSpelling = true, SetLastError = true)] + public static extern int GetUserDefaultLocaleName([In] nint lpLocaleName, [In] int cchLocaleName); +} diff --git a/src/dotnetCampus.Localizations/dotnetCampus.Localizations.csproj b/src/dotnetCampus.Localizations/dotnetCampus.Localizations.csproj index 44bd18e..3b147cf 100644 --- a/src/dotnetCampus.Localizations/dotnetCampus.Localizations.csproj +++ b/src/dotnetCampus.Localizations/dotnetCampus.Localizations.csproj @@ -7,6 +7,7 @@ + true true dotnetCampus.SourceLocalizations true @@ -40,6 +41,10 @@
+ + + + diff --git a/src/dotnetCampus.SourceLocalizations.PrivateGenerator/Generators/IetfLanguageTagsGenerator.cs b/src/dotnetCampus.SourceLocalizations.PrivateGenerator/Generators/IetfLanguageTagsGenerator.cs new file mode 100644 index 0000000..c61b879 --- /dev/null +++ b/src/dotnetCampus.SourceLocalizations.PrivateGenerator/Generators/IetfLanguageTagsGenerator.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using System.Security; +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace dotnetCampus.Localizations.Generators; + +[Generator] +public class IetfLanguageTagsGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterPostInitializationOutput(Execute); + } + + private void Execute(IncrementalGeneratorPostInitializationContext context) + { + var allCultures = CultureInfo.GetCultures(CultureTypes.AllCultures); + + var ietfLanguageTagsCode = GenerateIetfLanguageTagsCode(allCultures); + context.AddSource("IetfLanguageTags.const.g.cs", SourceText.From(ietfLanguageTagsCode, Encoding.UTF8)); + + var ietfLanguageTagDictionaryCode = GenerateIetfLanguageTagDictionaryCode(allCultures); + context.AddSource("IetfLanguageTags.hashset.g.cs", SourceText.From(ietfLanguageTagDictionaryCode, Encoding.UTF8)); + } + + private string GenerateIetfLanguageTagsCode(CultureInfo[] allCultures) => $$""" +namespace dotnetCampus.Localizations.Ietf; + +partial class IetfLanguageTags +{ +{{string.Join("\n\n", GenerateConstantTagProperties(allCultures))}} +} + +"""; + + private string GenerateIetfLanguageTagDictionaryCode(CultureInfo[] allCultures) => $$""" +namespace dotnetCampus.Localizations.Ietf; + +partial class IetfLanguageTags +{ + /// + /// 包含所有 IETF 语言标签字符串常量的不可变哈希集合。 + /// + public static global::System.Collections.Immutable.ImmutableHashSet Set { get; } = + [ +{{string.Join("\n", GenerateDictionaryTagKeyValues(allCultures))}} + ]; +} + +"""; + + /// + /// + /// + /// + /// + /// + /// + private IEnumerable GenerateConstantTagProperties(CultureInfo[] allCultures) + { + foreach (var culture in allCultures) + { + var name = culture.Name; + if (name is "") + { + // culture.ThreeLetterISOLanguageName = ivl + continue; + } + + var identifier = name switch + { + "as" => "@as", + "is" => "@is", + _ => name.Replace('-', '_'), + }; + + yield return $""" + /// + /// {SecurityElement.Escape(culture.EnglishName)} + /// + /// {SecurityElement.Escape(culture.NativeName)} + /// {SecurityElement.Escape(culture.DisplayName)} + /// + /// + public const string {identifier} = "{name}"; +"""; + } + } + + private IEnumerable GenerateDictionaryTagKeyValues(CultureInfo[] allCultures) + { + foreach (var culture in allCultures) + { + var name = culture.Name; + if (name is "") + { + // culture.ThreeLetterISOLanguageName = ivl + continue; + } + + yield return $""" + "{name}", +"""; + } + } +} diff --git a/src/dotnetCampus.SourceLocalizations.PrivateGenerator/dotnetCampus.SourceLocalizations.PrivateGenerator.csproj b/src/dotnetCampus.SourceLocalizations.PrivateGenerator/dotnetCampus.SourceLocalizations.PrivateGenerator.csproj new file mode 100644 index 0000000..ba9f731 --- /dev/null +++ b/src/dotnetCampus.SourceLocalizations.PrivateGenerator/dotnetCampus.SourceLocalizations.PrivateGenerator.csproj @@ -0,0 +1,18 @@ + + + + netstandard2.0 + false + true + true + dotnetCampus.Localizations + + + + + + + + + +