using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.Linq; using System.Text; using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; namespace SourceGeneratedMappings; /// /// Turns the code points of the Lucide icon font into icon strings MudBlazor shows as font icons. /// /// /// The input is Lucide/codepoints.json, copied unchanged from the npm package lucide-static and handed /// to the compiler as an additional file; Lucide/README.md describes the files and how to update them. /// MudIcon renders every icon string of the form "class/content" as a span with that class and that /// text. Each icon string is therefore "lucide/" followed by the glyph of its icon, and the CSS class /// lucide in wwwroot/app.css selects the font. The generator writes two classes: LucideIcons with a /// constant for every name, and LucideIconRegistry, which finds an icon by its name at runtime. /// [Generator] #pragma warning disable RS1036 public sealed class LucideIconGenerator : IIncrementalGenerator #pragma warning restore RS1036 { private const string GENERATED_NAMESPACE = "AIStudio.Tools"; private const string CODEPOINTS_PATH_SUFFIX = "/Lucide/codepoints.json"; private const string FONT_ICON_CLASS = "lucide"; private static readonly Regex ENTRY = new("\"(?[^\"]*)\"\\s*:\\s*(?[0-9]+)", RegexOptions.CultureInvariant); private static readonly Regex VALID_NAME = new("^[a-z][a-z0-9]*(-[a-z0-9]+)*$", RegexOptions.CultureInvariant); private static readonly DiagnosticDescriptor CODEPOINTS_MISSING = new( id: "LUC001", title: "The code points of the Lucide font were not found", messageFormat: "No additional file ending in '{0}' was handed to the compiler. LucideIcons and LucideIconRegistry were not generated.", category: "SourceGeneration", DiagnosticSeverity.Info, isEnabledByDefault: true); private static readonly DiagnosticDescriptor NO_ICONS_FOUND = new( id: "LUC002", title: "The code points of the Lucide font hold no icons", messageFormat: "The file '{0}' contains no icon. LucideIcons and LucideIconRegistry were not generated.", category: "SourceGeneration", DiagnosticSeverity.Warning, isEnabledByDefault: true); private static readonly DiagnosticDescriptor ICON_SKIPPED = new( id: "LUC003", title: "A Lucide icon was skipped", messageFormat: "The Lucide icon '{0}' with the code point {1} was skipped: {2}", category: "SourceGeneration", DiagnosticSeverity.Warning, isEnabledByDefault: true); public void Initialize(IncrementalGeneratorInitializationContext context) { var files = context.AdditionalTextsProvider .Where(static file => IsCodepointsFile(file.Path)) .Select(static (file, token) => new CodepointsFile(file.Path, file.GetText(token)?.ToString() ?? string.Empty)) .Collect(); context.RegisterSourceOutput(files, Generate); } private static bool IsCodepointsFile(string path) => path.Replace('\\', '/').EndsWith(CODEPOINTS_PATH_SUFFIX, StringComparison.OrdinalIgnoreCase); private static void Generate(SourceProductionContext context, ImmutableArray files) { if (files.IsDefaultOrEmpty) { context.ReportDiagnostic(Diagnostic.Create(CODEPOINTS_MISSING, Location.None, CODEPOINTS_PATH_SUFFIX)); return; } var file = files[0]; var icons = new List(); foreach (Match match in ENTRY.Matches(file.Content)) { var name = match.Groups["name"].Value; var codepointText = match.Groups["codepoint"].Value; if (!VALID_NAME.IsMatch(name)) { context.ReportDiagnostic(Diagnostic.Create(ICON_SKIPPED, Location.None, name, codepointText, "the name has to start with a lower-case letter and may only hold lower-case letters and digits, separated by single hyphens")); continue; } if (!int.TryParse(codepointText, NumberStyles.None, CultureInfo.InvariantCulture, out var codepoint) || !IsPrivateUse(codepoint)) { context.ReportDiagnostic(Diagnostic.Create(ICON_SKIPPED, Location.None, name, codepointText, "the code point lies outside the private use areas of Unicode")); continue; } icons.Add(new IconDefinition(name, codepoint)); } if (icons.Count == 0) { context.ReportDiagnostic(Diagnostic.Create(NO_ICONS_FOUND, Location.None, file.Path)); return; } icons.Sort(static (left, right) => string.CompareOrdinal(left.Name, right.Name)); context.AddSource("LucideIcons.g.cs", SourceText.From(RenderIcons(icons), Encoding.UTF8)); context.AddSource("LucideIconRegistry.g.cs", SourceText.From(RenderRegistry(icons), Encoding.UTF8)); } /// /// Icon fonts place their glyphs in the private use areas, where no real character can collide with them. /// private static bool IsPrivateUse(int codepoint) => codepoint is >= 0xE000 and <= 0xF8FF or >= 0xF0000 and <= 0x10FFFD; private static string RenderIcons(IReadOnlyList icons) { var namesByCodepoint = icons.ToLookup(static icon => icon.Codepoint, static icon => icon.Name); var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine(); builder.Append("namespace ").Append(GENERATED_NAMESPACE).AppendLine(";"); builder.AppendLine(); builder.AppendLine("/// "); builder.AppendLine("/// Every icon of the Lucide font, as an icon string for MudBlazor."); builder.AppendLine("/// "); builder.AppendLine("/// "); builder.AppendLine("/// Generated by LucideIconGenerator from Lucide/codepoints.json. The app picks its icons in AppIcons,"); builder.AppendLine("/// which is the only class that takes them from here."); builder.AppendLine("/// "); builder.AppendLine("public static class LucideIcons"); builder.AppendLine("{"); for (var index = 0; index < icons.Count; index++) { var icon = icons[index]; var sameGlyph = namesByCodepoint[icon.Codepoint].Where(name => name != icon.Name).ToList(); if (index > 0) builder.AppendLine(); builder.Append(" /// The Lucide icon \"").Append(icon.Name).Append('"'); if (sameGlyph.Count > 0) builder.Append(", the same glyph as \"").Append(string.Join("\", \"", sameGlyph)).Append('"'); builder.AppendLine("."); builder.Append(" public const string ").Append(icon.ConstantName).Append(" = \"").Append(FONT_ICON_CLASS).Append('/').Append(EscapeCodepoint(icon.Codepoint)).AppendLine("\";"); } builder.AppendLine("}"); return builder.ToString(); } private static string RenderRegistry(IReadOnlyList icons) { var builder = new StringBuilder(); builder.AppendLine("// "); builder.AppendLine("#nullable enable"); builder.AppendLine("using System;"); builder.AppendLine("using System.Collections.Generic;"); builder.AppendLine(); builder.Append("namespace ").Append(GENERATED_NAMESPACE).AppendLine(";"); builder.AppendLine(); builder.AppendLine("/// "); builder.AppendLine("/// Finds a Lucide icon by the name lucide.dev shows for it, such as \"house\"."); builder.AppendLine("/// "); builder.AppendLine("/// "); builder.AppendLine("/// Generated by LucideIconGenerator from Lucide/codepoints.json, for icons which are named at runtime,"); builder.AppendLine("/// such as those in assistant plugins. Aliases count as names of their own."); builder.AppendLine("/// "); builder.AppendLine("public static class LucideIconRegistry"); builder.AppendLine("{"); builder.Append(" private static readonly IReadOnlyDictionary ICONS_BY_NAME = new Dictionary(") .Append(icons.Count.ToString(CultureInfo.InvariantCulture)) .AppendLine(", StringComparer.Ordinal)"); builder.AppendLine(" {"); foreach (var icon in icons) builder.Append(" [\"").Append(icon.Name).Append("\"] = LucideIcons.").Append(icon.ConstantName).AppendLine(","); builder.AppendLine(" };"); builder.AppendLine(); builder.AppendLine(" /// "); builder.AppendLine(" /// Looks up an icon by its name, which has to match exactly."); builder.AppendLine(" /// "); builder.AppendLine(" /// The name of the icon, such as \"house\"."); builder.AppendLine(" /// The icon string for MudBlazor, when the name is known."); builder.AppendLine(" /// True when the font has an icon of that name."); builder.AppendLine(" public static bool TryGet(string name, out string icon) => ICONS_BY_NAME.TryGetValue(name, out icon!);"); builder.AppendLine("}"); return builder.ToString(); } private static string EscapeCodepoint(int codepoint) => codepoint <= 0xFFFF ? "\\u" + codepoint.ToString("X4", CultureInfo.InvariantCulture) : "\\U" + codepoint.ToString("X8", CultureInfo.InvariantCulture); private sealed class CodepointsFile(string path, string content) : IEquatable { public string Path { get; } = path; public string Content { get; } = content; public bool Equals(CodepointsFile? other) => other is not null && this.Path == other.Path && this.Content == other.Content; public override bool Equals(object? obj) => this.Equals(obj as CodepointsFile); public override int GetHashCode() => StringComparer.Ordinal.GetHashCode(this.Path) ^ StringComparer.Ordinal.GetHashCode(this.Content); } private sealed class IconDefinition(string name, int codepoint) { public string Name { get; } = name; public int Codepoint { get; } = codepoint; public string ConstantName { get; } = name.ToUpperInvariant().Replace('-', '_'); } }