diff --git a/.github/workflows/diff-generated-code.yml b/.github/workflows/diff-generated-code.yml index 98b08b7..b58d6b1 100644 --- a/.github/workflows/diff-generated-code.yml +++ b/.github/workflows/diff-generated-code.yml @@ -25,7 +25,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@baa11fbfe1d6520db94683bd5c7a3818018e4309 # v5 with: - dotnet-version: '8.0.x' + dotnet-version: '10.0.x' - name: Build base branch to generate KnownMimeTypes.cs working-directory: base-branch @@ -38,9 +38,42 @@ jobs: - name: Generate diff id: diff run: | + echo "=== DEBUG: Searching for generated files ===" + echo "Base branch files:" + find base-branch -name "KnownMimeTypes.cs" -o -name "KnownMimeTypes.g.cs" 2>/dev/null || echo "No files found" + echo "" + echo "PR branch files:" + find pr-branch -name "KnownMimeTypes.cs" -o -name "KnownMimeTypes.g.cs" 2>/dev/null || echo "No files found" + echo "" + + # Find the generated file (supports both old T4 and new source generator locations) + BASE_FILE=$(find base-branch -name "KnownMimeTypes.cs" -o -name "KnownMimeTypes.g.cs" 2>/dev/null | head -1) + PR_FILE=$(find pr-branch -name "KnownMimeTypes.cs" -o -name "KnownMimeTypes.g.cs" 2>/dev/null | head -1) + + echo "=== DEBUG: Selected files ===" + echo "BASE_FILE: $BASE_FILE" + echo "PR_FILE: $PR_FILE" + echo "" + + if [ -z "$BASE_FILE" ] || [ -z "$PR_FILE" ]; then + echo "ERROR: Could not find generated files!" + echo "Listing all .cs files in obj directories:" + find base-branch -path "*/obj/*" -name "*.cs" 2>/dev/null | head -20 + find pr-branch -path "*/obj/*" -name "*.cs" 2>/dev/null | head -20 + exit 1 + fi + + echo "=== DEBUG: File sizes ===" + wc -l "$BASE_FILE" "$PR_FILE" + echo "" + # Extract only the public const string lines and diff them - grep 'public const string .* = "' base-branch/src/MimeMapping/KnownMimeTypes.cs | sort > base-consts.txt - grep 'public const string .* = "' pr-branch/src/MimeMapping/KnownMimeTypes.cs | sort > pr-consts.txt + grep 'public const string .* = "' "$BASE_FILE" | sort > base-consts.txt + grep 'public const string .* = "' "$PR_FILE" | sort > pr-consts.txt + + echo "=== DEBUG: Extracted constants count ===" + wc -l base-consts.txt pr-consts.txt + echo "" diff -u base-consts.txt pr-consts.txt > diff.txt || true diff --git a/.gitignore b/.gitignore index 3765485..2000604 100644 --- a/.gitignore +++ b/.gitignore @@ -274,3 +274,4 @@ __pycache__/ # Cake - Uncomment if you are using it # tools/ KnownMimeTypes.cs +mime-db.json diff --git a/MimeMapping.sln b/MimeMapping.sln index 0d598a6..f66e8e6 100644 --- a/MimeMapping.sln +++ b/MimeMapping.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 VisualStudioVersion = 15.0.26430.16 @@ -14,6 +14,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MimeMapping.Tests", "test\M EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MimeMapping", "src\MimeMapping\MimeMapping.csproj", "{42893F4D-DE5F-4132-A408-E90BFF840342}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MimeMapping.SourceGenerator", "src\MimeMapping.SourceGenerator\MimeMapping.SourceGenerator.csproj", "{B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,6 +50,18 @@ Global {42893F4D-DE5F-4132-A408-E90BFF840342}.Release|x64.Build.0 = Release|Any CPU {42893F4D-DE5F-4132-A408-E90BFF840342}.Release|x86.ActiveCfg = Release|Any CPU {42893F4D-DE5F-4132-A408-E90BFF840342}.Release|x86.Build.0 = Release|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Debug|x64.ActiveCfg = Debug|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Debug|x64.Build.0 = Debug|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Debug|x86.ActiveCfg = Debug|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Debug|x86.Build.0 = Debug|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Release|Any CPU.Build.0 = Release|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Release|x64.ActiveCfg = Release|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Release|x64.Build.0 = Release|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Release|x86.ActiveCfg = Release|Any CPU + {B5E3D7A1-8C4F-4F2E-9A1B-3C5D7E9F1A2B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/renovate.json b/renovate.json index 3db908c..426cee6 100644 --- a/renovate.json +++ b/renovate.json @@ -6,14 +6,8 @@ { "customType": "regex", "description": "Update mime-db", - "managerFilePatterns": [ - "/(^|/)KnownMimeTypes\\.tt$/", - "/(^|/)TemplateSourceTests\\.cs$/", - "/(^|/)MimeDbTestHelper\\.cs$/" - ], - "matchStrings": [ - "https://raw\\.githubusercontent\\.com/jshttp/mime-db/(?.+?)/db.json" - ], + "managerFilePatterns": ["/(^|/)MimeMapping\\.csproj$/"], + "matchStrings": ["(?.+?)"], "datasourceTemplate": "github-releases", "depNameTemplate": "mime-db", "packageNameTemplate": "jshttp/mime-db" diff --git a/src/MimeMapping.SourceGenerator/CodeGenerator.cs b/src/MimeMapping.SourceGenerator/CodeGenerator.cs new file mode 100644 index 0000000..4a9de41 --- /dev/null +++ b/src/MimeMapping.SourceGenerator/CodeGenerator.cs @@ -0,0 +1,184 @@ +using System.Text; + +namespace MimeMapping.SourceGenerator +{ + /// + /// Generates the KnownMimeTypes.cs source code from parsed MIME data + /// + internal static class CodeGenerator + { + public static string Generate(MimeDbData data) + { + var sb = new StringBuilder(); + + // Header + sb.AppendLine("using System;"); + sb.AppendLine(); + sb.AppendLine("#nullable enable"); + sb.AppendLine(); + sb.AppendLine("namespace MimeMapping"); + sb.AppendLine("{"); + + // Class documentation + sb.AppendLine(" ///"); + sb.AppendLine($" /// MIME type constants. Last updated on {data.GeneratedAt:s}Z. "); + sb.AppendLine($" /// Generated from the mime-db source"); + sb.AppendLine(" ///"); + sb.AppendLine(" public static class KnownMimeTypes"); + sb.AppendLine(" {"); + sb.AppendLine(); + + // Conflict resolution comments + foreach (var comment in data.ConflictComments) + { + sb.AppendLine($" {comment}"); + } + + // Summary comments + sb.AppendLine(); + sb.AppendLine($" // Generated {data.MimeTypeToExtensions.Count} unique mime type values"); + sb.AppendLine($" // Generated {data.ExtensionToMimeType.Count} type key pairs"); + sb.AppendLine(); + + // Source URL constant + sb.AppendLine(" ///The source URL of the mime-db data used to generate this file"); + sb.AppendLine($" internal const string MimeDbSourceUrl = \"{data.SourceUrl}\";"); + sb.AppendLine(); + + // MIME type constants + foreach (var kv in data.ExtensionToMimeType) + { + var fieldName = NameUtilities.GetMimeFieldName(kv.Key); + sb.AppendLine($" ///{kv.Key}"); + sb.AppendLine($" public const string {fieldName} = \"{kv.Value}\";"); + } + + // ALL_MIMETYPES lazy array + sb.AppendLine(" // List of all available mimetypes, used to build the dictionary"); + sb.AppendLine(" internal static readonly Lazy ALL_MIMETYPES = new Lazy(() => new [] {"); + foreach (var kv in data.ExtensionToMimeType) + { + var fieldName = NameUtilities.GetMimeFieldName(kv.Key); + sb.AppendLine($" {fieldName},"); + } + sb.AppendLine(" });"); + sb.AppendLine(); + sb.AppendLine(); + + // FileExtensions nested class + sb.AppendLine(" ///File extensions"); + sb.AppendLine(" public static class FileExtensions"); + sb.AppendLine(" {"); + foreach (var kv in data.ExtensionToMimeType) + { + var fieldName = NameUtilities.GetExtensionFieldName(kv.Key); + sb.AppendLine($" ///{kv.Key}"); + sb.AppendLine($" public const string {fieldName} = \"{kv.Key}\";"); + } + sb.AppendLine(" }"); + sb.AppendLine(); + + // ALL_EXTS lazy array + sb.AppendLine(" // List of all available extensions, used to build the dictionary"); + sb.AppendLine(" internal static readonly Lazy ALL_EXTS = new Lazy(() => new [] {"); + foreach (var kv in data.ExtensionToMimeType) + { + var fieldName = NameUtilities.GetExtensionFieldName(kv.Key); + sb.AppendLine($" FileExtensions.{fieldName},"); + } + sb.AppendLine(" });"); + sb.AppendLine(); + sb.AppendLine(); + + // LookupType switch statement + GenerateLookupTypeMethod(sb, data); + + // LookupMimeType switch statement + GenerateLookupMimeTypeMethod(sb, data); + + // Close class and namespace + sb.AppendLine(" }"); + sb.AppendLine("}"); + + return sb.ToString(); + } + + private static void GenerateLookupTypeMethod(StringBuilder sb, MimeDbData data) + { + sb.AppendLine(" // Switch-case instead of dictionary since it does the hashing at compile time rather than run time"); + sb.AppendLine(" internal static string? LookupType(string type)"); + sb.AppendLine(" {"); + sb.AppendLine(" switch (type)"); + sb.AppendLine(" {"); + + foreach (var kv in data.MimeTypeToExtensions) + { + var mimeType = kv.Key; + var extensions = kv.Value; + + // Generate case statements for all extensions that map to this MIME type + foreach (var ext in extensions) + { + var fieldName = NameUtilities.GetExtensionFieldName(ext); + sb.AppendLine($" case FileExtensions.{fieldName}:"); + } + + // Return the MIME type constant (use first extension's field name) + var firstFieldName = NameUtilities.GetMimeFieldName(extensions[0]); + sb.AppendLine($" return {firstFieldName};"); + sb.AppendLine(); + } + + sb.AppendLine(" default: "); + sb.AppendLine(" return null;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + sb.AppendLine(); + } + + private static void GenerateLookupMimeTypeMethod(StringBuilder sb, MimeDbData data) + { + sb.AppendLine(" // Switch-case instead of dictionary since it does the hashing at compile time rather than run time"); + sb.AppendLine(" internal static string[]? LookupMimeType(string mimeType)"); + sb.AppendLine(" {"); + sb.AppendLine(" switch (mimeType)"); + sb.AppendLine(" {"); + + foreach (var kv in data.MimeTypeToExtensions) + { + var extensions = kv.Value; + var first = true; + + // Generate case statements for each extension's MIME type constant + foreach (var ext in extensions) + { + var fieldName = NameUtilities.GetMimeFieldName(ext); + if (first) + { + sb.AppendLine($" case {fieldName}:"); + first = false; + } + else + { + // Comment out additional cases (they're duplicates pointing to same MIME type) + sb.AppendLine($" //case {fieldName}:"); + } + } + + // Return array of extension constants + var extFieldNames = new StringBuilder(); + for (int i = 0; i < extensions.Count; i++) + { + if (i > 0) extFieldNames.Append(", "); + extFieldNames.Append($"FileExtensions.{NameUtilities.GetExtensionFieldName(extensions[i])}"); + } + sb.AppendLine($" return new[] {{{extFieldNames}}};"); + } + + sb.AppendLine(" default: "); + sb.AppendLine(" return null;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); + } + } +} diff --git a/src/MimeMapping.SourceGenerator/KnownMimeTypesGenerator.cs b/src/MimeMapping.SourceGenerator/KnownMimeTypesGenerator.cs new file mode 100644 index 0000000..f987a8d --- /dev/null +++ b/src/MimeMapping.SourceGenerator/KnownMimeTypesGenerator.cs @@ -0,0 +1,78 @@ +using System; +using System.Linq; +using Microsoft.CodeAnalysis; + +namespace MimeMapping.SourceGenerator +{ + /// + /// Source generator that produces KnownMimeTypes.cs from mime-db JSON data + /// + [Generator] + public class KnownMimeTypesGenerator : IIncrementalGenerator + { + private const string MimeDbFileName = "mime-db.json"; + private const string MimeDbUrlPropertyName = "build_property.MimeDbUrl"; + private const string DefaultSourceUrl = "mime-db"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + // Find the mime-db.json from AdditionalFiles + var mimeDbProvider = context.AdditionalTextsProvider + .Where(file => file.Path.EndsWith(MimeDbFileName, StringComparison.OrdinalIgnoreCase)) + .Select((file, ct) => file.GetText(ct)?.ToString()) + .Where(content => !string.IsNullOrEmpty(content)) + .Collect() + .Select((contents, ct) => contents.FirstOrDefault()); + + // Get the MimeDbUrl from global options for documentation + var optionsProvider = context.AnalyzerConfigOptionsProvider + .Select((provider, ct) => + { + provider.GlobalOptions.TryGetValue(MimeDbUrlPropertyName, out var url); + return url ?? DefaultSourceUrl; + }); + + // Combine and generate + var combined = mimeDbProvider.Combine(optionsProvider); + + context.RegisterSourceOutput(combined, (spc, tuple) => + { + var (json, sourceUrl) = tuple; + if (string.IsNullOrEmpty(json)) + { + // Report diagnostic if mime-db.json not found + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "MIME001", + "mime-db.json not found", + "The mime-db.json file was not found in AdditionalFiles. Ensure the DownloadMimeDb MSBuild target runs before compilation.", + "MimeMapping.SourceGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true), + Location.None)); + return; + } + + try + { + var data = MimeDbParser.Parse(json!, sourceUrl); + var source = CodeGenerator.Generate(data); + spc.AddSource("KnownMimeTypes.g.cs", source); + } + catch (Exception ex) + { + spc.ReportDiagnostic(Diagnostic.Create( + new DiagnosticDescriptor( + "MIME002", + "Failed to parse mime-db.json", + "Failed to parse mime-db.json: {0}", + "MimeMapping.SourceGenerator", + DiagnosticSeverity.Error, + isEnabledByDefault: true), + Location.None, + ex.Message)); + } + }); + } + } +} diff --git a/src/MimeMapping.SourceGenerator/MimeDbData.cs b/src/MimeMapping.SourceGenerator/MimeDbData.cs new file mode 100644 index 0000000..b258a75 --- /dev/null +++ b/src/MimeMapping.SourceGenerator/MimeDbData.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; + +namespace MimeMapping.SourceGenerator +{ + /// + /// Data model containing parsed MIME type mappings from mime-db + /// + internal sealed class MimeDbData + { + /// + /// Extension to MIME type mapping (after conflict resolution) + /// Key: extension (e.g., "png"), Value: MIME type (e.g., "image/png") + /// + public Dictionary ExtensionToMimeType { get; } + + /// + /// MIME type to extensions mapping (reverse lookup) + /// Key: MIME type, Value: list of extensions + /// + public Dictionary> MimeTypeToExtensions { get; } + + /// + /// Conflict resolution comments to include in generated code + /// + public List ConflictComments { get; } + + /// + /// Timestamp when the data was generated + /// + public DateTime GeneratedAt { get; } + + /// + /// Source URL of the mime-db data + /// + public string SourceUrl { get; } + + public MimeDbData( + Dictionary extensionToMimeType, + Dictionary> mimeTypeToExtensions, + List conflictComments, + DateTime generatedAt, + string sourceUrl) + { + ExtensionToMimeType = extensionToMimeType ?? throw new ArgumentNullException(nameof(extensionToMimeType)); + MimeTypeToExtensions = mimeTypeToExtensions ?? throw new ArgumentNullException(nameof(mimeTypeToExtensions)); + ConflictComments = conflictComments ?? throw new ArgumentNullException(nameof(conflictComments)); + GeneratedAt = generatedAt; + SourceUrl = sourceUrl ?? throw new ArgumentNullException(nameof(sourceUrl)); + } + } +} diff --git a/src/MimeMapping.SourceGenerator/MimeDbParser.cs b/src/MimeMapping.SourceGenerator/MimeDbParser.cs new file mode 100644 index 0000000..ab868a6 --- /dev/null +++ b/src/MimeMapping.SourceGenerator/MimeDbParser.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; + +namespace MimeMapping.SourceGenerator +{ + /// + /// Parses mime-db JSON and applies conflict resolution logic + /// + internal static class MimeDbParser + { + /// + /// Get priority for source (higher is better) + /// iana=3, apache=2, nginx=1, other=0 + /// + public static int GetSourcePriority(string? source) => source switch + { + "iana" => 3, + "apache" => 2, + "nginx" => 1, + _ => 0 + }; + + /// + /// Get priority for mime type category (higher is better) + /// Prefer specific types (video/*, audio/*, image/*, text/*) over application/* + /// + public static int GetTypePriority(string mimeType) + { + if (mimeType.StartsWith("video/", StringComparison.Ordinal)) return 4; + if (mimeType.StartsWith("audio/", StringComparison.Ordinal)) return 4; + if (mimeType.StartsWith("image/", StringComparison.Ordinal)) return 4; + if (mimeType.StartsWith("text/", StringComparison.Ordinal)) return 3; + if (mimeType.StartsWith("font/", StringComparison.Ordinal)) return 3; + if (mimeType.StartsWith("model/", StringComparison.Ordinal)) return 2; + if (mimeType.StartsWith("application/", StringComparison.Ordinal)) return 1; + return 0; + } + + /// + /// Parse mime-db JSON and return structured data with conflict resolution applied + /// + public static MimeDbData Parse(string json, string sourceUrl) + { + var extensionToMimeType = new Dictionary(); + var mimeTypeToExtensions = new Dictionary>(); + var conflictComments = new List(); + + // Track source info for conflict resolution + var entryDict = new Dictionary(); + + using var doc = JsonDocument.Parse(json); + + foreach (var mimeTypeEntry in doc.RootElement.EnumerateObject()) + { + var mimeType = mimeTypeEntry.Name; + string? source = null; + + if (mimeTypeEntry.Value.TryGetProperty("source", out var sourceEl)) + { + source = sourceEl.GetString(); + } + + if (!mimeTypeEntry.Value.TryGetProperty("extensions", out var extensions)) + { + continue; + } + + foreach (var ext in extensions.EnumerateArray()) + { + var extStr = ext.GetString(); + if (string.IsNullOrEmpty(extStr)) + { + continue; + } + + // After the null check above, extStr is guaranteed non-null + var extension = extStr!; + + if (entryDict.TryGetValue(extension, out var existing)) + { + if (existing.mimeType != mimeType) + { + // Compare priorities to decide which one wins + var existingSourcePri = GetSourcePriority(existing.source); + var newSourcePri = GetSourcePriority(source); + var existingTypePri = GetTypePriority(existing.mimeType); + var newTypePri = GetTypePriority(mimeType); + + var shouldReplace = newSourcePri > existingSourcePri || + (newSourcePri == existingSourcePri && newTypePri > existingTypePri); + + if (shouldReplace) + { + // Remove from old reverse dict entry + if (mimeTypeToExtensions.TryGetValue(existing.mimeType, out var oldList)) + { + oldList.Remove(extension); + if (oldList.Count == 0) + { + mimeTypeToExtensions.Remove(existing.mimeType); + } + } + + // Update to new value + extensionToMimeType[extension] = mimeType; + entryDict[extension] = (mimeType, source); + + if (!mimeTypeToExtensions.TryGetValue(mimeType, out var keyList)) + { + keyList = new List(); + mimeTypeToExtensions.Add(mimeType, keyList); + } + keyList.Add(extension); + + // Add conflict comment + conflictComments.Add( + $"// Dupe for {extension}: using {mimeType} ({source ?? "unknown"}/type:{newTypePri}) over {existing.mimeType} ({existing.source ?? "unknown"}/type:{existingTypePri})"); + } + else + { + // Keep existing, add conflict comment + conflictComments.Add( + $"// Dupe for {extension}: keeping {existing.mimeType} ({existing.source ?? "unknown"}/type:{existingTypePri}) over {mimeType} ({source ?? "unknown"}/type:{newTypePri})"); + } + } + } + else + { + // First occurrence + extensionToMimeType[extension] = mimeType; + entryDict[extension] = (mimeType, source); + + if (!mimeTypeToExtensions.TryGetValue(mimeType, out var keyList)) + { + keyList = new List(); + mimeTypeToExtensions.Add(mimeType, keyList); + } + keyList.Add(extension); + } + } + } + + return new MimeDbData( + extensionToMimeType, + mimeTypeToExtensions, + conflictComments, + DateTime.UtcNow, + sourceUrl); + } + } +} diff --git a/src/MimeMapping.SourceGenerator/MimeMapping.SourceGenerator.csproj b/src/MimeMapping.SourceGenerator/MimeMapping.SourceGenerator.csproj new file mode 100644 index 0000000..e553d75 --- /dev/null +++ b/src/MimeMapping.SourceGenerator/MimeMapping.SourceGenerator.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0 + latest + enable + true + true + false + false + + + + + + + + + + + + + + diff --git a/src/MimeMapping.SourceGenerator/NameUtilities.cs b/src/MimeMapping.SourceGenerator/NameUtilities.cs new file mode 100644 index 0000000..64bac1c --- /dev/null +++ b/src/MimeMapping.SourceGenerator/NameUtilities.cs @@ -0,0 +1,48 @@ +using System.Text.RegularExpressions; + +namespace MimeMapping.SourceGenerator +{ + /// + /// Utilities for generating valid C# field names from extensions and MIME types + /// + internal static class NameUtilities + { + private static readonly Regex NonAlphanumeric = new Regex("[^a-zA-Z0-9]"); + + /// + /// Generates a C# field name for a MIME type constant based on the extension. + /// Examples: + /// - "png" -> "Png" + /// - "3dml" -> "_3dml" + /// - "vbox-extpack" -> "Vboxextpack" + /// + public static string GetMimeFieldName(string extension) + { + // Uppercase first char, remove non-alphanumeric characters + var result = NonAlphanumeric.Replace( + char.ToUpperInvariant(extension[0]) + extension.Substring(1), + ""); + + // Prefix with underscore if starts with digit + return char.IsDigit(result[0]) ? ("_" + result) : result; + } + + /// + /// Generates a C# field name for a file extension constant. + /// Examples: + /// - "png" -> "Png" + /// - "3dml" -> "_3dml" + /// - "vbox-extpack" -> "Vbox_extpack" + /// + public static string GetExtensionFieldName(string extension) + { + // Prefix with underscore if doesn't start with letter + var prefix = !char.IsLetter(extension[0]) ? "_" : string.Empty; + + // Uppercase first char, replace hyphens with underscores + var name = char.ToUpper(extension[0]) + extension.Substring(1).Replace('-', '_'); + + return prefix + name; + } + } +} diff --git a/src/MimeMapping/KnownMimeTypes.tt b/src/MimeMapping/KnownMimeTypes.tt deleted file mode 100644 index cb88e83..0000000 --- a/src/MimeMapping/KnownMimeTypes.tt +++ /dev/null @@ -1,278 +0,0 @@ -<#@ template debug="false" hostspecific="false" language="C#" #> -<#@ assembly name="System.Core" #> -<#@ assembly name="System.Net.Http" #> -<#@ assembly name="System.Text.Json" #> -<#@ import namespace="System.Collections.Generic" #> -<#@ import namespace="System.Net.Http" #> -<#@ import namespace="System.Linq" #> -<#@ import namespace="System.Text.RegularExpressions" #> -<#@ import namespace="System.Text.Json" #> -<#@ output extension="cs" #> -using System; - -namespace MimeMapping -{ -<# - const string MIMEDB_URL = "https://raw.githubusercontent.com/jshttp/mime-db/v1.54.0/db.json"; -#> - /// - /// MIME type constants. Last updated on <#= DateTime.UtcNow.ToString("s") + "Z" #>. - /// Generated from the mime-db source - /// - public static class KnownMimeTypes - { - -<# - Regex rgx = new Regex("[^a-zA-Z0-9]"); - - string GetMimeFieldName(string mimeKey) - { - var result = rgx.Replace(mimeKey[0].ToString().ToUpperInvariant() + mimeKey.Substring(1), ""); - return char.IsDigit(result[0]) ? ("_" + result) : result; - } - - string GetPageContent(string url) - { - using var client = new HttpClient(); - return client.GetStringAsync(url).GetAwaiter().GetResult(); - } - - // Get priority for source (higher is better) - // iana=3, apache=2, nginx=1, other=0 - Func GetSourcePriority = (source) => - { - if (source == "iana") return 3; - if (source == "apache") return 2; - if (source == "nginx") return 1; - return 0; - }; - - // Get priority for mime type category (higher is better) - // Prefer specific types (video/*, audio/*, image/*, text/*) over application/* - Func GetTypePriority = (mimeType) => - { - if (mimeType.StartsWith("video/")) return 4; - if (mimeType.StartsWith("audio/")) return 4; - if (mimeType.StartsWith("image/")) return 4; - if (mimeType.StartsWith("text/")) return 3; - if (mimeType.StartsWith("font/")) return 3; - if (mimeType.StartsWith("model/")) return 2; - if (mimeType.StartsWith("application/")) return 1; - return 0; - }; - - // Returns list of (mimeType, extension, source) - Func>> GetMimeTypesFromJson = (url) => - { - var content = GetPageContent(url); - if (string.IsNullOrEmpty(content)) return new List>(); - - using var doc = JsonDocument.Parse(content); - var results = new List>(); - - foreach (var mimeTypeEntry in doc.RootElement.EnumerateObject()) - { - var mimeType = mimeTypeEntry.Name; - var source = mimeTypeEntry.Value.TryGetProperty("source", out var sourceEl) - ? sourceEl.GetString() - : null; - - if (mimeTypeEntry.Value.TryGetProperty("extensions", out var extensions)) - { - foreach (var ext in extensions.EnumerateArray()) - { - results.Add(Tuple.Create(mimeType, ext.GetString(), source)); - } - } - } - - return results; - }; - - var entries = GetMimeTypesFromJson(MIMEDB_URL); - - // build dictionary from entries with conflict resolution - // _entryDict stores (mimeType, source) for each extension - var _dict = new Dictionary(); - var _entryDict = new Dictionary>(); - var _reverseDict = new Dictionary>(); - - foreach (var entry in entries) - { - var mimeType = entry.Item1; - var ext = entry.Item2; - var source = entry.Item3; - - if (_entryDict.TryGetValue(ext, out var existing)) - { - if (existing.Item1 != mimeType) - { - // Compare priorities to decide which one wins - var existingSourcePri = GetSourcePriority(existing.Item2); - var newSourcePri = GetSourcePriority(source); - var existingTypePri = GetTypePriority(existing.Item1); - var newTypePri = GetTypePriority(mimeType); - - var shouldReplace = newSourcePri > existingSourcePri || - (newSourcePri == existingSourcePri && newTypePri > existingTypePri); - - if (shouldReplace) - { - // Remove from old reverse dict entry - if (_reverseDict.TryGetValue(existing.Item1, out var oldList)) - { - oldList.Remove(ext); - if (oldList.Count == 0) - { - _reverseDict.Remove(existing.Item1); - } - } - - // Update to new value - _dict[ext] = mimeType; - _entryDict[ext] = Tuple.Create(mimeType, source); - - if (!_reverseDict.TryGetValue(mimeType, out var keyList)) - { - keyList = new List(); - _reverseDict.Add(mimeType, keyList); - } - keyList.Add(ext); -#> - // Dupe for <#= ext #>: using <#= mimeType #> (<#= source ?? "unknown" #>/type:<#= newTypePri #>) over <#= existing.Item1 #> (<#= existing.Item2 ?? "unknown" #>/type:<#= existingTypePri #>) -<# - } - else - { -#> - // Dupe for <#= ext #>: keeping <#= existing.Item1 #> (<#= existing.Item2 ?? "unknown" #>/type:<#= existingTypePri #>) over <#= mimeType #> (<#= source ?? "unknown" #>/type:<#= newTypePri #>) -<# - } - } - } - else - { - _dict[ext] = mimeType; - _entryDict[ext] = Tuple.Create(mimeType, source); - - if (!_reverseDict.TryGetValue(mimeType, out var keyList)) - { - keyList = new List(); - _reverseDict.Add(mimeType, keyList); - } - - keyList.Add(ext); - } - } -#> - - // Generated <#= _reverseDict.Count #> unique mime type values - // Generated <#= _dict.Count #> type key pairs - -<# - // Output constants for each type - foreach(var kp in _dict) - { -#> - ///<#= kp.Key #> - public const string <#= GetMimeFieldName(kp.Key) #> = "<#= kp.Value #>"; -<# - } -#> - // List of all available mimetypes, used to build the dictionary - internal static readonly Lazy ALL_MIMETYPES = new Lazy(() => new [] { -<# - // List constant field names - foreach (var kp in _dict) - { -#> - <#= GetMimeFieldName(kp.Key) #>, -<# - } -#> - }); - - - ///File extensions - public static class FileExtensions - { -<# - // List constant field names - foreach (var kp in _dict) - { -#> - ///<#= kp.Key #> - public const string <#= !Char.IsLetter(kp.Key[0]) ? "_" : string.Empty #><#= char.ToUpper(kp.Key[0]) + kp.Key.Substring(1).Replace('-', '_') #> = "<#= kp.Key #>"; -<# - } - -#> - } - - // List of all available extensions, used to build the dictionary - internal static readonly Lazy ALL_EXTS = new Lazy(() => new [] { -<# - // List constant field names - foreach (var kp in _dict) - { -#> - FileExtensions.<#= !Char.IsLetter(kp.Key[0]) ? "_" : string.Empty #><#= char.ToUpper(kp.Key[0]) + kp.Key.Substring(1).Replace('-', '_') #>, -<# - } -#> - }); - - - // Switch-case instead of dictionary since it does the hashing at compile time rather than run time - internal static string? LookupType(string type) - { - switch (type) - { -<# - // Output the actual literal C# dictionary - foreach (var kp in _reverseDict) - { - foreach (var mimeKey in kp.Value) - { -#> - case FileExtensions.<#= !Char.IsLetter(mimeKey[0]) ? "_" : string.Empty #><#= char.ToUpper(mimeKey[0]) + mimeKey.Substring(1).Replace('-', '_') #>: -<# - } -#> - return <#= GetMimeFieldName(kp.Value[0]) #>; - -<# - } -#> - default: - return null; - } - } - - // Switch-case instead of dictionary since it does the hashing at compile time rather than run time - internal static string[]? LookupMimeType(string mimeType) - { - switch (mimeType) - { -<# - // Output the actual literal C# dictionary - foreach (var kp in _reverseDict) - { - var first = true; - foreach (var mimeKey in kp.Value) - { -#> - <#= first ? "" : "//" #>case <#= GetMimeFieldName(mimeKey) #>: -<# - first = false; - } -#> return new[] {<#= string.Join(", ", kp.Value.Select(x => $"FileExtensions.{(!Char.IsLetter(x[0]) ? "_" : string.Empty)}{Char.ToUpper(x[0])}{x.Substring(1).Replace('-', '_')}")) #>}; -<# - } -#> - default: - return null; - } - } - } -} diff --git a/src/MimeMapping/MimeMapping.csproj b/src/MimeMapping/MimeMapping.csproj index ac7a478..9d91e38 100644 --- a/src/MimeMapping/MimeMapping.csproj +++ b/src/MimeMapping/MimeMapping.csproj @@ -5,6 +5,8 @@ netstandard2.0;net462 enable latest + + true Constants for (almost) all MIME types and method to determine MIME type from a file name. Contains just over 1000 mime types. @@ -33,7 +35,7 @@ https://learn.microsoft.com/dotnet/api/system.web.mimemapping.getmimemapping 2.0 - v + v @@ -44,42 +46,64 @@ https://learn.microsoft.com/dotnet/api/system.web.mimemapping.getmimemapping + + + + + - - TextTemplatingFileGenerator - KnownMimeTypes.cs - + + + + 1.54.0 + https://raw.githubusercontent.com/jshttp/mime-db/v$(MimeDbVersion)/db.json + $(MSBuildProjectDirectory)/../mime-db.json + + + - - True - True - KnownMimeTypes.tt - + + + + + + + - + + - - - - + + + + $([System.IO.Directory]::GetFiles('$(BaseIntermediateOutputPath)', 'KnownMimeTypes.g.cs', SearchOption.AllDirectories)[0]) + + - - + + + diff --git a/test/MimeMapping.Tests/GeneratedCodeTests.cs b/test/MimeMapping.Tests/GeneratedCodeTests.cs index 8522dcb..3a7ef50 100644 --- a/test/MimeMapping.Tests/GeneratedCodeTests.cs +++ b/test/MimeMapping.Tests/GeneratedCodeTests.cs @@ -1,7 +1,5 @@ -using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using MimeMapping; @@ -132,10 +130,10 @@ public void TypeMap_ContainsAllGeneratedExtensions() [TestMethod] public void TypeToExtensionsMap_ContainsAllGeneratedMimeTypes() { - // Get all unique mime type values from constants + // Get all unique mime type values from constants (excluding non-MIME type constants like MimeDbSourceUrl) var mimeTypeFields = typeof(KnownMimeTypes) .GetFields(BindingFlags.Public | BindingFlags.Static) - .Where(f => f.IsLiteral && f.FieldType == typeof(string)) + .Where(f => f.IsLiteral && f.FieldType == typeof(string) && f.Name != nameof(KnownMimeTypes.MimeDbSourceUrl)) .Select(f => (string)f.GetValue(null)!) .Distinct() .ToList(); @@ -147,25 +145,6 @@ public void TypeToExtensionsMap_ContainsAllGeneratedMimeTypes() } } - [TestMethod] - public async Task GeneratedCode_MatchesSourceData() - { - // Fetch the source data with conflict resolution matching the generator - var sourceDict = await MimeDbTestHelper.FetchMimeTypesWithResolutionAsync(); - - // Verify TypeMap matches source data - Assert.HasCount(sourceDict.Count, MimeUtility.TypeMap, - $"TypeMap count ({MimeUtility.TypeMap.Count}) doesn't match source ({sourceDict.Count})"); - - foreach (var kvp in sourceDict) - { - Assert.IsTrue(MimeUtility.TypeMap.TryGetValue(kvp.Key, out var actualMimeType), - $"Extension '{kvp.Key}' missing from TypeMap"); - Assert.AreEqual(kvp.Value, actualMimeType, - $"Mime type mismatch for extension '{kvp.Key}': expected '{kvp.Value}', got '{actualMimeType}'"); - } - } - [TestMethod] public void NumericExtensions_AreHandledCorrectly() { diff --git a/test/MimeMapping.Tests/MimeDbTestHelper.cs b/test/MimeMapping.Tests/MimeDbTestHelper.cs deleted file mode 100644 index 5f2f808..0000000 --- a/test/MimeMapping.Tests/MimeDbTestHelper.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Text.Json; -using System.Threading.Tasks; - -namespace Test -{ - internal static class MimeDbTestHelper - { - public const string MIMEDB_URL = "https://raw.githubusercontent.com/jshttp/mime-db/v1.54.0/db.json"; - - // Get priority for source (higher is better) - // iana=3, apache=2, nginx=1, other=0 - private static int GetSourcePriority(string source) => source switch - { - "iana" => 3, - "apache" => 2, - "nginx" => 1, - _ => 0 - }; - - // Get priority for mime type category (higher is better) - // Prefer specific types (video/*, audio/*, image/*, text/*) over application/* - private static int GetTypePriority(string mimeType) - { - if (mimeType.StartsWith("video/")) return 4; - if (mimeType.StartsWith("audio/")) return 4; - if (mimeType.StartsWith("image/")) return 4; - if (mimeType.StartsWith("text/")) return 3; - if (mimeType.StartsWith("font/")) return 3; - if (mimeType.StartsWith("model/")) return 2; - if (mimeType.StartsWith("application/")) return 1; - return 0; - } - - /// - /// Fetches mime types with conflict resolution matching the generator logic. - /// Returns a dictionary of extension -> mime type. - /// - public static async Task> FetchMimeTypesWithResolutionAsync() - { - using var client = new HttpClient(); - var content = await client.GetStringAsync(MIMEDB_URL); - using var doc = JsonDocument.Parse(content); - - var result = new Dictionary(); - var entryDict = new Dictionary(); - - foreach (var mimeTypeEntry in doc.RootElement.EnumerateObject()) - { - var mimeType = mimeTypeEntry.Name; - var source = mimeTypeEntry.Value.TryGetProperty("source", out var sourceEl) - ? sourceEl.GetString() - : null; - - if (mimeTypeEntry.Value.TryGetProperty("extensions", out var extensions)) - { - foreach (var ext in extensions.EnumerateArray()) - { - var extStr = ext.GetString()!; - - if (entryDict.TryGetValue(extStr, out var existing)) - { - if (existing.mimeType != mimeType) - { - var existingSourcePri = GetSourcePriority(existing.source); - var newSourcePri = GetSourcePriority(source); - var existingTypePri = GetTypePriority(existing.mimeType); - var newTypePri = GetTypePriority(mimeType); - - var shouldReplace = newSourcePri > existingSourcePri || - (newSourcePri == existingSourcePri && newTypePri > existingTypePri); - - if (shouldReplace) - { - result[extStr] = mimeType; - entryDict[extStr] = (mimeType, source); - } - } - } - else - { - result[extStr] = mimeType; - entryDict[extStr] = (mimeType, source); - } - } - } - } - - return result; - } - - /// - /// Legacy method that returns raw entries without conflict resolution. - /// - public static async Task> FetchMimeTypesAsync() - { - using var client = new HttpClient(); - var content = await client.GetStringAsync(MIMEDB_URL); - using var doc = JsonDocument.Parse(content); - var results = new List(); - - foreach (var mimeTypeEntry in doc.RootElement.EnumerateObject()) - { - var mimeType = mimeTypeEntry.Name; - if (mimeTypeEntry.Value.TryGetProperty("extensions", out var extensions)) - { - foreach (var ext in extensions.EnumerateArray()) - { - results.Add(new[] { mimeType, ext.GetString()! }); - } - } - } - - return results; - } - } -} diff --git a/test/MimeMapping.Tests/TemplateSourceTests.cs b/test/MimeMapping.Tests/TemplateSourceTests.cs deleted file mode 100644 index 06e4d3e..0000000 --- a/test/MimeMapping.Tests/TemplateSourceTests.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.VisualStudio.TestTools.UnitTesting; - -namespace Test -{ - [TestClass] - public class TemplateSourceTests - { - [TestMethod] - public async Task TestMimeDbMimeTypesAsync() - { - var keyPairs = await MimeDbTestHelper.FetchMimeTypesAsync(); - Assert.IsNotEmpty(keyPairs); - } - } -}