diff --git a/Flow.Launcher.Localization.Attributes/EnumLocalizeAttribute.cs b/Flow.Launcher.Localization.Attributes/EnumLocalizeAttribute.cs new file mode 100644 index 0000000..21f2a06 --- /dev/null +++ b/Flow.Launcher.Localization.Attributes/EnumLocalizeAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace Flow.Launcher.Localization.Attributes +{ + /// + /// Attribute to mark an enum for localization. + /// + [AttributeUsage(AttributeTargets.Enum)] + public class EnumLocalizeAttribute : Attribute + { + } +} diff --git a/Flow.Launcher.Localization.Attributes/EnumLocalizeKeyAttribute.cs b/Flow.Launcher.Localization.Attributes/EnumLocalizeKeyAttribute.cs new file mode 100644 index 0000000..39d75eb --- /dev/null +++ b/Flow.Launcher.Localization.Attributes/EnumLocalizeKeyAttribute.cs @@ -0,0 +1,33 @@ +using System; + +namespace Flow.Launcher.Localization.Attributes +{ + /// + /// Attribute to mark a localization key for an enum field. + /// + [AttributeUsage(AttributeTargets.Field)] + public class EnumLocalizeKeyAttribute : Attribute + { + public static readonly EnumLocalizeKeyAttribute Default = new EnumLocalizeKeyAttribute(); + + public EnumLocalizeKeyAttribute() : this(string.Empty) + { + } + + public EnumLocalizeKeyAttribute(string enumLocalizeKey) + { + EnumLocalizeKey = enumLocalizeKey; + } + + public virtual string LocalizeKey => EnumLocalizeKey; + + protected string EnumLocalizeKey { get; set; } + + public override bool Equals(object obj) => + obj is EnumLocalizeKeyAttribute other && other.LocalizeKey == LocalizeKey; + + public override int GetHashCode() => LocalizeKey?.GetHashCode() ?? 0; + + public override bool IsDefaultAttribute() => Equals(Default); + } +} diff --git a/Flow.Launcher.Localization.Attributes/EnumLocalizeValueAttribute.cs b/Flow.Launcher.Localization.Attributes/EnumLocalizeValueAttribute.cs new file mode 100644 index 0000000..75c3859 --- /dev/null +++ b/Flow.Launcher.Localization.Attributes/EnumLocalizeValueAttribute.cs @@ -0,0 +1,33 @@ +using System; + +namespace Flow.Launcher.Localization.Attributes +{ + /// + /// Attribute to mark a localization value for an enum field. + /// + [AttributeUsage(AttributeTargets.Field)] + public class EnumLocalizeValueAttribute : Attribute + { + public static readonly EnumLocalizeValueAttribute Default = new EnumLocalizeValueAttribute(); + + public EnumLocalizeValueAttribute() : this(string.Empty) + { + } + + public EnumLocalizeValueAttribute(string enumLocalizeValue) + { + EnumLocalizeValue = enumLocalizeValue; + } + + public virtual string LocalizeValue => EnumLocalizeValue; + + protected string EnumLocalizeValue { get; set; } + + public override bool Equals(object obj) => + obj is EnumLocalizeValueAttribute other && other.LocalizeValue == LocalizeValue; + + public override int GetHashCode() => LocalizeValue?.GetHashCode() ?? 0; + + public override bool IsDefaultAttribute() => Equals(Default); + } +} diff --git a/Flow.Launcher.Localization.Attributes/Flow.Launcher.Localization.Attributes.csproj b/Flow.Launcher.Localization.Attributes/Flow.Launcher.Localization.Attributes.csproj new file mode 100644 index 0000000..dd973d9 --- /dev/null +++ b/Flow.Launcher.Localization.Attributes/Flow.Launcher.Localization.Attributes.csproj @@ -0,0 +1,8 @@ + + + + netstandard2.0 + Flow.Launcher.Localization.Attributes + + + diff --git a/Flow.Launcher.Localization.Shared/Constants.cs b/Flow.Launcher.Localization.Shared/Constants.cs index 568da5d..8932aa9 100644 --- a/Flow.Launcher.Localization.Shared/Constants.cs +++ b/Flow.Launcher.Localization.Shared/Constants.cs @@ -20,6 +20,14 @@ public static class Constants public const string OldLocalizationMethodName = "GetTranslation"; public const string StringFormatMethodName = "Format"; public const string StringFormatTypeName = "string"; + public const string EnumLocalizeClassSuffix = "Data"; + public const string EnumLocalizeAttributeName = "EnumLocalizeAttribute"; + public const string EnumLocalizeKeyAttributeName = "EnumLocalizeKeyAttribute"; + public const string EnumLocalizeValueAttributeName = "EnumLocalizeValueAttribute"; + // Use PublicApi instead of PublicAPI for possible ambiguity with Flow.Launcher.Plugin.IPublicAPI + public const string PublicApiClassName = "PublicApi"; + public const string PublicApiPrivatePropertyName = "instance"; + public const string PublicApiInternalPropertyName = "Instance"; public static readonly Regex LanguagesXamlRegex = new Regex(@"\\Languages\\[^\\]+\.xaml$", RegexOptions.IgnoreCase); public static readonly string[] OldLocalizationClasses = { "IPublicAPI", "Internationalization" }; diff --git a/Flow.Launcher.Localization.Shared/Helper.cs b/Flow.Launcher.Localization.Shared/Helper.cs index d9502ac..0807cef 100644 --- a/Flow.Launcher.Localization.Shared/Helper.cs +++ b/Flow.Launcher.Localization.Shared/Helper.cs @@ -1,9 +1,9 @@ -using Microsoft.CodeAnalysis; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; -using System.Linq; -using System.Threading; namespace Flow.Launcher.Localization.Shared { @@ -81,5 +81,14 @@ private static Location GetCodeFixLocation(PropertyDeclarationSyntax property, S } #endregion + + #region Tab String + + public static string Spacing(int n) + { + return new string(' ', n * 4); + } + + #endregion } } diff --git a/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Unshipped.md b/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Unshipped.md index f60d258..c1ad7a3 100644 --- a/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Unshipped.md +++ b/Flow.Launcher.Localization.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -11,3 +11,4 @@ FLSG0004 | Localization | Warning | FLSG0004_ContextPropertyNotStatic FLSG0005 | Localization | Warning | FLSG0005_ContextPropertyIsPrivate FLSG0006 | Localization | Warning | FLSG0006_ContextPropertyIsProtected FLSG0007 | Localization | Warning | FLSG0007_LocalizationKeyUnused +FLSG0008 | Localization | Warning | FLSG0008_EnumFieldLocalizationKeyValueInvalid diff --git a/Flow.Launcher.Localization.SourceGenerators/Flow.Launcher.Localization.SourceGenerators.csproj b/Flow.Launcher.Localization.SourceGenerators/Flow.Launcher.Localization.SourceGenerators.csproj index 1a95f4d..be6dda3 100644 --- a/Flow.Launcher.Localization.SourceGenerators/Flow.Launcher.Localization.SourceGenerators.csproj +++ b/Flow.Launcher.Localization.SourceGenerators/Flow.Launcher.Localization.SourceGenerators.csproj @@ -4,6 +4,8 @@ netstandard2.0 true Flow.Launcher.Localization.SourceGenerators + + 0.0.2 diff --git a/Flow.Launcher.Localization.SourceGenerators/Localize/EnumSourceGenerator.cs b/Flow.Launcher.Localization.SourceGenerators/Localize/EnumSourceGenerator.cs new file mode 100644 index 0000000..76a218f --- /dev/null +++ b/Flow.Launcher.Localization.SourceGenerators/Localize/EnumSourceGenerator.cs @@ -0,0 +1,358 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +namespace Flow.Launcher.Localization.SourceGenerators.Localize +{ + [Generator] + public partial class EnumSourceGenerator : IIncrementalGenerator + { + #region Fields + + private static readonly Version PackageVersion = typeof(EnumSourceGenerator).Assembly.GetName().Version; + + private static readonly ImmutableArray _emptyEnumFields = ImmutableArray.Empty; + + #endregion + + #region Incremental Generator + + /// + /// Initializes the generator and registers source output based on enum declarations. + /// + /// The initialization context. + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var enumDeclarations = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: (s, _) => s is EnumDeclarationSyntax, + transform: (ctx, _) => (EnumDeclarationSyntax)ctx.Node) + .Where(ed => ed.AttributeLists.Count > 0) + .Collect(); + + var pluginClasses = context.SyntaxProvider + .CreateSyntaxProvider( + predicate: (n, _) => n is ClassDeclarationSyntax, + transform: (c, t) => Helper.GetPluginClassInfo((ClassDeclarationSyntax)c.Node, c.SemanticModel, t)) + .Where(info => info != null) + .Collect(); + + var compilation = context.CompilationProvider; + + var configOptions = context.AnalyzerConfigOptionsProvider; + + var compilationEnums = enumDeclarations.Combine(pluginClasses).Combine(configOptions).Combine(compilation); + + context.RegisterSourceOutput(compilationEnums, Execute); + } + + /// + /// Executes the generation of enum data classes based on the provided data. + /// + /// The source production context. + /// The provided data. + private void Execute(SourceProductionContext spc, + (((ImmutableArray EnumsDeclarations, + ImmutableArray PluginClassInfos), + AnalyzerConfigOptionsProvider ConfigOptionsProvider), + Compilation Compilation) data) + { + var compilation = data.Compilation; + var configOptions = data.Item1.ConfigOptionsProvider; + var pluginClasses = data.Item1.Item1.PluginClassInfos; + var enumsDeclarations = data.Item1.Item1.EnumsDeclarations; + + var assemblyNamespace = compilation.AssemblyName ?? Constants.DefaultNamespace; + var useDI = configOptions.GetFLLUseDependencyInjection(); + + PluginClassInfo pluginInfo; + if (useDI) + { + // If we use dependency injection, we do not need to check if there is a valid plugin context + pluginInfo = null; + } + else + { + pluginInfo = PluginInfoHelper.GetValidPluginInfoAndReportDiagnostic(pluginClasses, spc); + if (pluginInfo == null) + { + // If we cannot find a valid plugin info, we do not need to generate the source + return; + } + } + + foreach (var enumDeclaration in enumsDeclarations.Distinct()) + { + var semanticModel = compilation.GetSemanticModel(enumDeclaration.SyntaxTree); + var enumSymbol = semanticModel.GetDeclaredSymbol(enumDeclaration) as INamedTypeSymbol; + + // Check if the enum has the EnumLocalize attribute + if (enumSymbol?.GetAttributes().Any(ad => + ad.AttributeClass?.Name == Constants.EnumLocalizeAttributeName) ?? false) + { + GenerateSource(spc, enumSymbol, useDI, pluginInfo, assemblyNamespace); + } + } + } + + #endregion + + #region Get Enum Fields + + private static ImmutableArray GetEnumFields(SourceProductionContext spc, INamedTypeSymbol enumSymbol, string enumFullName) + { + // Iterate through enum members and get enum fields + var enumFields = new List(); + var enumError = false; + foreach (var member in enumSymbol.GetMembers().Where(m => m.Kind == SymbolKind.Field)) + { + if (member is IFieldSymbol fieldSymbol) + { + var enumFieldName = fieldSymbol.Name; + + // Check if the field has the EnumLocalizeKey attribute + var keyAttr = fieldSymbol.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == Constants.EnumLocalizeKeyAttributeName); + var keyAttrExist = keyAttr != null; + + // Check if the field has the EnumLocalizeValue attribute + var valueAttr = fieldSymbol.GetAttributes() + .FirstOrDefault(a => a.AttributeClass?.Name == Constants.EnumLocalizeValueAttributeName); + var valueAttrExist = valueAttr != null; + + // Get the key and value from the attributes + var key = keyAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString() ?? string.Empty; + var value = valueAttr?.ConstructorArguments.FirstOrDefault().Value?.ToString() ?? string.Empty; + + // Users may use " " as a key, so we need to check if the key is not empty and not whitespace + if (keyAttrExist && !string.IsNullOrWhiteSpace(key)) + { + // If localization key exists and is valid, use it + enumFields.Add(new EnumField(enumFieldName, key, valueAttrExist ? value : null)); + } + else if (valueAttrExist) + { + // If localization value exists, use it + enumFields.Add(new EnumField(enumFieldName, value)); + } + else + { + // If localization key and value are not provided, do not generate the field and report a diagnostic + spc.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.EnumFieldLocalizationKeyValueInvalid, + Location.None, + $"{enumFullName}.{enumFieldName}")); + enumError = true; + } + } + } + + // If there was an error, do not generate the class + if (enumError) return _emptyEnumFields; + + return enumFields.ToImmutableArray(); + } + + #endregion + + #region Generate Source + + private void GenerateSource( + SourceProductionContext spc, + INamedTypeSymbol enumSymbol, + bool useDI, + PluginClassInfo pluginInfo, + string assemblyNamespace) + { + var enumFullName = enumSymbol.ToDisplayString(new SymbolDisplayFormat( + globalNamespaceStyle: SymbolDisplayGlobalNamespaceStyle.Omitted, // Remove global:: symbol + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces)); + var enumDataClassName = $"{enumSymbol.Name}{Constants.EnumLocalizeClassSuffix}"; + var enumName = enumSymbol.Name; + var enumNamespace = enumSymbol.ContainingNamespace.ToDisplayString(); + var tabString = Helper.Spacing(1); + + var sourceBuilder = new StringBuilder(); + + // Generate header + GeneratedHeaderFromPath(sourceBuilder, enumFullName); + sourceBuilder.AppendLine(); + + // Generate using directives + sourceBuilder.AppendLine("using System.Collections.Generic;"); + sourceBuilder.AppendLine(); + + // Generate namespace + sourceBuilder.AppendLine($"namespace {enumNamespace};"); + sourceBuilder.AppendLine(); + + // Generate class + sourceBuilder.AppendLine($"/// "); + sourceBuilder.AppendLine($"/// Data class for "); + sourceBuilder.AppendLine($"/// "); + sourceBuilder.AppendLine($"[System.CodeDom.Compiler.GeneratedCode(\"{nameof(EnumSourceGenerator)}\", \"{PackageVersion}\")]"); + sourceBuilder.AppendLine($"public class {enumDataClassName}"); + sourceBuilder.AppendLine("{"); + + // Generate properties + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// The value of the enum"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public {enumName} Value {{ get; private init; }}"); + sourceBuilder.AppendLine(); + + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// The display text of the enum value"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public string Display {{ get; set; }}"); + sourceBuilder.AppendLine(); + + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// The localization key of the enum value"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public string LocalizationKey {{ get; set; }}"); + sourceBuilder.AppendLine(); + + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// The localization value of the enum value"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public string LocalizationValue {{ get; set; }}"); + sourceBuilder.AppendLine(); + + // Generate API instance + string getTranslation = null; + if (useDI) + { + // Use instance from PublicApiSourceGenerator + getTranslation = $"{assemblyNamespace}.{Constants.PublicApiClassName}.{Constants.PublicApiInternalPropertyName}.GetTranslation"; + } + else if (pluginInfo?.IsValid == true) + { + getTranslation = $"{assemblyNamespace}.{pluginInfo.ContextAccessor}.API.GetTranslation"; + } + + // Generate GetValues method + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// Get all values of "); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}public static List<{enumDataClassName}> GetValues()"); + sourceBuilder.AppendLine($"{tabString}{{"); + sourceBuilder.AppendLine($"{tabString}{tabString}return new List<{enumDataClassName}>"); + sourceBuilder.AppendLine($"{tabString}{tabString}{{"); + var enumFields = GetEnumFields(spc, enumSymbol, enumFullName); + if (enumFields.Length == 0) return; + foreach (var enumField in enumFields) + { + GenerateEnumField(sourceBuilder, getTranslation, enumField, enumName, tabString); + } + sourceBuilder.AppendLine($"{tabString}{tabString}}};"); + sourceBuilder.AppendLine($"{tabString}}}"); + sourceBuilder.AppendLine(); + + // Generate UpdateLabels method + GenerateUpdateLabelsMethod(sourceBuilder, getTranslation, enumDataClassName, tabString); + + sourceBuilder.AppendLine($"}}"); + + // Add source to context + spc.AddSource($"{Constants.ClassName}.{assemblyNamespace}.{enumNamespace}.{enumDataClassName}.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); + } + + private static void GeneratedHeaderFromPath(StringBuilder sb, string enumFullName) + { + if (string.IsNullOrEmpty(enumFullName)) + { + sb.AppendLine("/// "); + } + else + { + sb.AppendLine("/// ") + .AppendLine($"/// From: {enumFullName}") + .AppendLine("/// "); + } + } + + private static void GenerateEnumField( + StringBuilder sb, + string getTranslation, + EnumField enumField, + string enumName, + string tabString) + { + sb.AppendLine($"{tabString}{tabString}{tabString}new()"); + sb.AppendLine($"{tabString}{tabString}{tabString}{{"); + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}Value = {enumName}.{enumField.EnumFieldName},"); + if (enumField.UseLocalizationKey) + { + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}Display = {getTranslation}(\"{enumField.LocalizationKey}\"),"); + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}LocalizationKey = \"{enumField.LocalizationKey}\","); + } + else + { + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}Display = \"{enumField.LocalizationValue}\","); + } + if (enumField.LocalizationValue != null) + { + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}LocalizationValue = \"{enumField.LocalizationValue}\","); + } + sb.AppendLine($"{tabString}{tabString}{tabString}}},"); + } + + private static void GenerateUpdateLabelsMethod( + StringBuilder sb, + string getTranslation, + string enumDataClassName, + string tabString) + { + sb.AppendLine($"{tabString}/// "); + sb.AppendLine($"{tabString}/// Update the labels of the enum values when culture info changes."); + sb.AppendLine($"{tabString}/// See for more details"); + sb.AppendLine($"{tabString}/// "); + sb.AppendLine($"{tabString}public static void UpdateLabels(List<{enumDataClassName}> options)"); + sb.AppendLine($"{tabString}{{"); + sb.AppendLine($"{tabString}{tabString}foreach (var item in options)"); + sb.AppendLine($"{tabString}{tabString}{{"); + // Users may use " " as a key, so we need to check if the key is not empty and not whitespace + sb.AppendLine($"{tabString}{tabString}{tabString}if (!string.IsNullOrWhiteSpace(item.LocalizationKey))"); + sb.AppendLine($"{tabString}{tabString}{tabString}{{"); + sb.AppendLine($"{tabString}{tabString}{tabString}{tabString}item.Display = {getTranslation}(item.LocalizationKey);"); + sb.AppendLine($"{tabString}{tabString}{tabString}}}"); + sb.AppendLine($"{tabString}{tabString}}}"); + sb.AppendLine($"{tabString}}}"); + } + + #endregion + + #region Classes + + public class EnumField + { + public string EnumFieldName { get; set; } + public string LocalizationKey { get; set; } + public string LocalizationValue { get; set; } + + // Users may use " " as a key, so we need to check if the key is not empty and not whitespace + public bool UseLocalizationKey => !string.IsNullOrWhiteSpace(LocalizationKey); + + public EnumField(string enumFieldName, string localizationValue) : this(enumFieldName, null, localizationValue) + { + } + + public EnumField(string enumFieldName, string localizationKey, string localizationValue) + { + EnumFieldName = enumFieldName; + LocalizationKey = localizationKey; + LocalizationValue = localizationValue; + } + } + + #endregion + } +} diff --git a/Flow.Launcher.Localization.SourceGenerators/Localize/LocalizeSourceGenerator.cs b/Flow.Launcher.Localization.SourceGenerators/Localize/LocalizeSourceGenerator.cs index 9b2e0ca..43589ce 100644 --- a/Flow.Launcher.Localization.SourceGenerators/Localize/LocalizeSourceGenerator.cs +++ b/Flow.Launcher.Localization.SourceGenerators/Localize/LocalizeSourceGenerator.cs @@ -97,16 +97,30 @@ private void Execute(SourceProductionContext spc, var usedKeys = data.Item1.Item1.Item1.Item1.InvocationKeys; var localizedStrings = data.Item1.Item1.Item1.Item1.LocalizableStrings; - var assemblyName = compilation.AssemblyName ?? Constants.DefaultNamespace; + var assemblyNamespace = compilation.AssemblyName ?? Constants.DefaultNamespace; var useDI = configOptions.GetFLLUseDependencyInjection(); - var pluginInfo = GetValidPluginInfo(pluginClasses, spc, useDI); + PluginClassInfo pluginInfo; + if (useDI) + { + // If we use dependency injection, we do not need to check if there is a valid plugin context + pluginInfo = null; + } + else + { + pluginInfo = PluginInfoHelper.GetValidPluginInfoAndReportDiagnostic(pluginClasses, spc); + if (pluginInfo == null) + { + // If we cannot find a valid plugin info, we do not need to generate the source + return; + } + } GenerateSource( spc, xamlFiles[0], localizedStrings, - assemblyName, + assemblyNamespace, useDI, pluginInfo, usedKeys); @@ -421,95 +435,13 @@ private static string GetLocalizationKeyFromInvocation(GeneratorSyntaxContext co #endregion - #region Get Plugin Class Info - - private static PluginClassInfo GetValidPluginInfo( - ImmutableArray pluginClasses, - SourceProductionContext context, - bool useDI) - { - // If p is null, this class does not implement IPluginI18n - var iPluginI18nClasses = pluginClasses.Where(p => p != null).ToArray(); - if (iPluginI18nClasses.Length == 0) - { - context.ReportDiagnostic(Diagnostic.Create( - SourceGeneratorDiagnostics.CouldNotFindPluginEntryClass, - Location.None - )); - return null; - } - - // If we use dependency injection, we do not need to check if there is a valid plugin context - // Also we do not need to return the plugin info - if (useDI) - { - return null; - } - - // If p.PropertyName is null, this class does not have PluginInitContext property - var iPluginI18nClassesWithContext = iPluginI18nClasses.Where(p => p.PropertyName != null).ToArray(); - if (iPluginI18nClassesWithContext.Length == 0) - { - foreach (var pluginClass in iPluginI18nClasses) - { - context.ReportDiagnostic(Diagnostic.Create( - SourceGeneratorDiagnostics.CouldNotFindContextProperty, - pluginClass.Location, - pluginClass.ClassName - )); - } - return null; - } - - // Rest classes have implemented IPluginI18n and have PluginInitContext property - // Check if the property is valid - foreach (var pluginClass in iPluginI18nClassesWithContext) - { - if (pluginClass.IsValid == true) - { - return pluginClass; - } - - if (!pluginClass.IsStatic) - { - context.ReportDiagnostic(Diagnostic.Create( - SourceGeneratorDiagnostics.ContextPropertyNotStatic, - pluginClass.Location, - pluginClass.PropertyName - )); - } - - if (pluginClass.IsPrivate) - { - context.ReportDiagnostic(Diagnostic.Create( - SourceGeneratorDiagnostics.ContextPropertyIsPrivate, - pluginClass.Location, - pluginClass.PropertyName - )); - } - - if (pluginClass.IsProtected) - { - context.ReportDiagnostic(Diagnostic.Create( - SourceGeneratorDiagnostics.ContextPropertyIsProtected, - pluginClass.Location, - pluginClass.PropertyName - )); - } - } - - return null; - } - - #endregion - #region Generate Source private static void GenerateSource( SourceProductionContext spc, AdditionalText xamlFile, ImmutableArray localizedStrings, - string assemblyName, + string assemblyNamespace, bool useDI, PluginClassInfo pluginInfo, IEnumerable usedKeys) @@ -538,7 +470,7 @@ private static void GenerateSource( sourceBuilder.AppendLine(); // Generate namespace - sourceBuilder.AppendLine($"namespace {assemblyName};"); + sourceBuilder.AppendLine($"namespace {assemblyNamespace};"); sourceBuilder.AppendLine(); // Uncomment them for debugging @@ -572,16 +504,14 @@ private static void GenerateSource( sourceBuilder.AppendLine($"public static class {Constants.ClassName}"); sourceBuilder.AppendLine("{"); - var tabString = Spacing(1); + var tabString = Helper.Spacing(1); // Generate API instance string getTranslation = null; if (useDI) { - sourceBuilder.AppendLine($"{tabString}private static Flow.Launcher.Plugin.IPublicAPI? api = null;"); - sourceBuilder.AppendLine($"{tabString}private static Flow.Launcher.Plugin.IPublicAPI Api => api ??= CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.GetRequiredService();"); - sourceBuilder.AppendLine(); - getTranslation = "Api.GetTranslation"; + // Use instance from PublicApiSourceGenerator + getTranslation = $"{assemblyNamespace}.{Constants.PublicApiClassName}.{Constants.PublicApiInternalPropertyName}.GetTranslation"; } else if (pluginInfo?.IsValid == true) { @@ -591,14 +521,15 @@ private static void GenerateSource( // Generate localization methods foreach (var ls in localizedStrings) { + var isLast = ls.Equals(localizedStrings.Last()); GenerateDocComments(sourceBuilder, ls, tabString); - GenerateLocalizationMethod(sourceBuilder, ls, getTranslation, tabString); + GenerateLocalizationMethod(sourceBuilder, ls, getTranslation, tabString, isLast); } sourceBuilder.AppendLine("}"); // Add source to context - spc.AddSource($"{Constants.ClassName}.{assemblyName}.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); + spc.AddSource($"{Constants.ClassName}.{assemblyNamespace}.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); } private static void GeneratedHeaderFromPath(StringBuilder sb, string xamlFilePath) @@ -649,7 +580,8 @@ private static void GenerateLocalizationMethod( StringBuilder sb, LocalizableString ls, string getTranslation, - string tabString) + string tabString, + bool last) { sb.Append($"{tabString}public static string {ls.Key}("); @@ -674,21 +606,10 @@ private static void GenerateLocalizationMethod( sb.AppendLine("\"LOCALIZATION_ERROR\";"); } - sb.AppendLine(); - } - - private static string Spacing(int n) - { - Span spaces = stackalloc char[n * 4]; - spaces.Fill(' '); - - var sb = new StringBuilder(n * 4); - foreach (var c in spaces) + if (!last) { - _ = sb.Append(c); + sb.AppendLine(); } - - return sb.ToString(); } #endregion diff --git a/Flow.Launcher.Localization.SourceGenerators/Localize/PublicApiSourceGenerator.cs b/Flow.Launcher.Localization.SourceGenerators/Localize/PublicApiSourceGenerator.cs new file mode 100644 index 0000000..92593b7 --- /dev/null +++ b/Flow.Launcher.Localization.SourceGenerators/Localize/PublicApiSourceGenerator.cs @@ -0,0 +1,106 @@ +using System; +using System.Text; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +namespace Flow.Launcher.Localization.SourceGenerators.Localize +{ + [Generator] + public partial class PublicApiSourceGenerator : IIncrementalGenerator + { + #region Fields + + private static readonly Version PackageVersion = typeof(PublicApiSourceGenerator).Assembly.GetName().Version; + + #endregion + + #region Incremental Generator + + /// + /// Initializes the generator and registers source output based on build property FLLUseDependencyInjection. + /// + /// The initialization context. + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var compilation = context.CompilationProvider; + + var configOptions = context.AnalyzerConfigOptionsProvider; + + var compilationEnums = configOptions.Combine(compilation); + + context.RegisterSourceOutput(compilationEnums, Execute); + } + + /// + /// Executes the generation of public api property based on the provided data. + /// + /// The source production context. + /// The provided data. + private void Execute(SourceProductionContext spc, + (AnalyzerConfigOptionsProvider ConfigOptionsProvider, Compilation Compilation) data) + { + var compilation = data.Compilation; + var configOptions = data.ConfigOptionsProvider; + + var assemblyNamespace = compilation.AssemblyName ?? Constants.DefaultNamespace; + var useDI = configOptions.GetFLLUseDependencyInjection(); + + // If we do not use dependency injection, we do not need to generate the public api property + if (!useDI) return; + + GenerateSource(spc, assemblyNamespace); + } + + #endregion + + #region Generate Source + + private void GenerateSource( + SourceProductionContext spc, + string assemblyNamespace) + { + var tabString = Helper.Spacing(1); + + var sourceBuilder = new StringBuilder(); + + // Generate header + GeneratedHeaderFromPath(sourceBuilder); + sourceBuilder.AppendLine(); + + // Generate nullable enable + sourceBuilder.AppendLine("#nullable enable"); + sourceBuilder.AppendLine(); + + // Generate namespace + sourceBuilder.AppendLine($"namespace {assemblyNamespace};"); + sourceBuilder.AppendLine(); + + // Generate class + sourceBuilder.AppendLine($"[System.CodeDom.Compiler.GeneratedCode(\"{nameof(PublicApiSourceGenerator)}\", \"{PackageVersion}\")]"); + sourceBuilder.AppendLine($"internal static class {Constants.PublicApiClassName}"); + sourceBuilder.AppendLine("{"); + + // Generate properties + sourceBuilder.AppendLine($"{tabString}private static Flow.Launcher.Plugin.IPublicAPI? {Constants.PublicApiPrivatePropertyName} = null;"); + sourceBuilder.AppendLine(); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}/// Get instance"); + sourceBuilder.AppendLine($"{tabString}/// "); + sourceBuilder.AppendLine($"{tabString}internal static Flow.Launcher.Plugin.IPublicAPI {Constants.PublicApiInternalPropertyName} =>" + + $"{Constants.PublicApiPrivatePropertyName} ??= CommunityToolkit.Mvvm.DependencyInjection.Ioc.Default.GetRequiredService();"); + sourceBuilder.AppendLine($"}}"); + + // Add source to context + spc.AddSource($"{Constants.PublicApiClassName}.{assemblyNamespace}.g.cs", SourceText.From(sourceBuilder.ToString(), Encoding.UTF8)); + } + + private static void GeneratedHeaderFromPath(StringBuilder sb) + { + sb.AppendLine("/// "); + } + + #endregion + } +} diff --git a/Flow.Launcher.Localization.SourceGenerators/PluginInfoHelper.cs b/Flow.Launcher.Localization.SourceGenerators/PluginInfoHelper.cs new file mode 100644 index 0000000..a26ee57 --- /dev/null +++ b/Flow.Launcher.Localization.SourceGenerators/PluginInfoHelper.cs @@ -0,0 +1,80 @@ +using System.Collections.Immutable; +using System.Linq; +using Flow.Launcher.Localization.Shared; +using Microsoft.CodeAnalysis; + +namespace Flow.Launcher.Localization.SourceGenerators +{ + internal class PluginInfoHelper + { + public static PluginClassInfo GetValidPluginInfoAndReportDiagnostic( + ImmutableArray pluginClasses, + SourceProductionContext context) + { + // If p is null, this class does not implement IPluginI18n + var iPluginI18nClasses = pluginClasses.Where(p => p != null).ToArray(); + if (iPluginI18nClasses.Length == 0) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.CouldNotFindPluginEntryClass, + Location.None + )); + return null; + } + + // If p.PropertyName is null, this class does not have PluginInitContext property + var iPluginI18nClassesWithContext = iPluginI18nClasses.Where(p => p.PropertyName != null).ToArray(); + if (iPluginI18nClassesWithContext.Length == 0) + { + foreach (var pluginClass in iPluginI18nClasses) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.CouldNotFindContextProperty, + pluginClass.Location, + pluginClass.ClassName + )); + } + return null; + } + + // Rest classes have implemented IPluginI18n and have PluginInitContext property + // Check if the property is valid + foreach (var pluginClass in iPluginI18nClassesWithContext) + { + if (pluginClass.IsValid == true) + { + return pluginClass; + } + + if (!pluginClass.IsStatic) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.ContextPropertyNotStatic, + pluginClass.Location, + pluginClass.PropertyName + )); + } + + if (pluginClass.IsPrivate) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.ContextPropertyIsPrivate, + pluginClass.Location, + pluginClass.PropertyName + )); + } + + if (pluginClass.IsProtected) + { + context.ReportDiagnostic(Diagnostic.Create( + SourceGeneratorDiagnostics.ContextPropertyIsProtected, + pluginClass.Location, + pluginClass.PropertyName + )); + } + } + + return null; + } + } +} diff --git a/Flow.Launcher.Localization.SourceGenerators/SourceGeneratorDiagnostics.cs b/Flow.Launcher.Localization.SourceGenerators/SourceGeneratorDiagnostics.cs index d162cdb..f8cde85 100644 --- a/Flow.Launcher.Localization.SourceGenerators/SourceGeneratorDiagnostics.cs +++ b/Flow.Launcher.Localization.SourceGenerators/SourceGeneratorDiagnostics.cs @@ -67,5 +67,14 @@ public static class SourceGeneratorDiagnostics DiagnosticSeverity.Warning, isEnabledByDefault: true ); + + public static readonly DiagnosticDescriptor EnumFieldLocalizationKeyValueInvalid = new DiagnosticDescriptor( + "FLSG0008", + "Enum field localization key and value invalid", + $"Enum field `{{0}}` does not have a valid localization key or value", + "Localization", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); } } diff --git a/Flow.Launcher.Localization.slnx b/Flow.Launcher.Localization.slnx index 27c8f44..779a1ca 100644 --- a/Flow.Launcher.Localization.slnx +++ b/Flow.Launcher.Localization.slnx @@ -1,5 +1,6 @@ + diff --git a/Flow.Launcher.Localization/Flow.Launcher.Localization.csproj b/Flow.Launcher.Localization/Flow.Launcher.Localization.csproj index b9ee6bc..5cf25ff 100644 --- a/Flow.Launcher.Localization/Flow.Launcher.Localization.csproj +++ b/Flow.Launcher.Localization/Flow.Launcher.Localization.csproj @@ -6,7 +6,7 @@ Flow.Launcher.Localization Flow.Launcher.Localization false - true + false true true @@ -33,6 +33,9 @@ All + + runtime + All @@ -47,6 +50,11 @@ analyzers/dotnet/cs false + + true + lib/$(TargetFramework) + true + true analyzers/dotnet/cs