< Summary

Information
Class: DotNetApiDiff.Reporting.HtmlFormatterScriban
Assembly: DotNetApiDiff
File(s): /home/runner/work/dotnet-api-diff/dotnet-api-diff/src/DotNetApiDiff/Reporting/HtmlFormatterScriban.cs
Line coverage
73%
Covered lines: 226
Uncovered lines: 80
Coverable lines: 306
Total lines: 423
Line coverage: 73.8%
Branch coverage
53%
Covered branches: 100
Total branches: 186
Branch coverage: 53.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor()100%1166.66%
Format(...)100%11100%
HtmlEscape(...)100%44100%
PrepareConfigData(...)50%150150100%
PrepareResultData(...)100%11100%
PrepareChangeSections(...)80%131067.69%
GroupChanges(...)0%620%
GroupChangesByType(...)100%11100%
ExtractContainingType(...)83.33%6686.66%
GetTypeDisplayName(...)50%22100%
GetMemberDisplayName(...)83.33%66100%
PrepareBreakingChangesData(...)100%11100%
FormatBooleanValue(...)100%22100%
RenderChangeGroup(...)0%2040%
GetCssStyles()100%1150%
GetJavaScriptCode()100%1150%
GetFallbackStyles()100%210%
GetFallbackJavaScript()100%210%

File(s)

/home/runner/work/dotnet-api-diff/dotnet-api-diff/src/DotNetApiDiff/Reporting/HtmlFormatterScriban.cs

#LineLine coverage
 1// Copyright DotNet API Diff Project Contributors - SPDX Identifier: MIT
 2using DotNetApiDiff.Interfaces;
 3using DotNetApiDiff.Models;
 4using DotNetApiDiff.Models.Configuration;
 5using Scriban;
 6using Scriban.Runtime;
 7using System.Linq;
 8
 9namespace DotNetApiDiff.Reporting;
 10
 11/// <summary>
 12/// Formatter for HTML output with rich formatting and interactive features using Scriban templates
 13/// </summary>
 14public class HtmlFormatterScriban : IReportFormatter
 15{
 16    private readonly Template _mainTemplate;
 17
 818    public HtmlFormatterScriban()
 819    {
 20        // Initialize main template from embedded resources
 21        try
 822        {
 823            _mainTemplate = Template.Parse(EmbeddedTemplateLoader.LoadTemplate("main-layout.scriban"));
 824        }
 025        catch (Exception ex)
 026        {
 027            throw new InvalidOperationException("Failed to initialize Scriban HTML formatter templates. Ensure template 
 28        }
 829    }
 30
 31    /// <summary>
 32    /// Formats a comparison result as HTML using Scriban templates
 33    /// </summary>
 34    /// <param name="result">The comparison result to format</param>
 35    /// <returns>Formatted HTML output as a string</returns>
 36    public string Format(ComparisonResult result)
 637    {
 38        // Create the template context with custom functions
 639        var context = new TemplateContext();
 640        var scriptObject = new ScriptObject();
 41
 42        // Add custom functions
 643        scriptObject.Import("format_boolean", new Func<bool, string>(FormatBooleanValue));
 644        scriptObject.Import("render_change_group", new Func<object, string>(RenderChangeGroup));
 45
 46        // Prepare data for the main template
 647        var resultData = PrepareResultData(result);
 648        var changeSections = PrepareChangeSections(result);
 649        var cssStyles = GetCssStyles();
 650        var javascriptCode = GetJavaScriptCode();
 51
 52        // Add template data to script object
 653        scriptObject.SetValue("result", resultData, true);
 654        scriptObject.SetValue("change_sections", changeSections, true);
 655        scriptObject.SetValue("css_styles", cssStyles, true);
 656        scriptObject.SetValue("javascript_code", javascriptCode, true);
 657        scriptObject.SetValue("config", PrepareConfigData(result.Configuration), true);
 58
 659        context.PushGlobal(scriptObject);
 60
 61        // Set up template loader for includes
 662        context.TemplateLoader = new CustomTemplateLoader();
 63
 664        return _mainTemplate.Render(context);
 665    }
 66
 67    private static string HtmlEscape(string? input)
 1068    {
 1069        if (string.IsNullOrEmpty(input))
 770        {
 771            return input ?? string.Empty;
 72        }
 73
 374        return input
 375            .Replace("&", "&amp;")
 376            .Replace("<", "&lt;")
 377            .Replace(">", "&gt;")
 378            .Replace("\"", "&quot;")
 379            .Replace("'", "&#39;");
 1080    }
 81
 82    private object PrepareConfigData(ComparisonConfiguration config)
 1283    {
 1284        var namespaceMappings = config?.Mappings?.NamespaceMappings ?? new Dictionary<string, List<string>>();
 85
 86        // Convert Dictionary to array of objects with key/value properties for Scriban
 1287        var namespaceMappingsArray = namespaceMappings.Select(kvp => new { key = kvp.Key, value = kvp.Value }).ToList();
 1288        var typeMappingsArray = (config?.Mappings?.TypeMappings ?? new Dictionary<string, string>()).Select(kvp => new {
 89
 1290        var mappingsResult = new
 1291        {
 1292            namespace_mappings = namespaceMappingsArray,
 1293            type_mappings = typeMappingsArray,
 1294            auto_map_same_name_types = config?.Mappings?.AutoMapSameNameTypes ?? false,
 1295            ignore_case = config?.Mappings?.IgnoreCase ?? false
 1296        };
 97
 1298        return new
 1299        {
 12100            filters = new
 12101            {
 12102                include_internals = config?.Filters?.IncludeInternals ?? false,
 12103                include_compiler_generated = config?.Filters?.IncludeCompilerGenerated ?? false,
 12104                include_namespaces = config?.Filters?.IncludeNamespaces?.ToList() ?? new List<string>(),
 12105                exclude_namespaces = config?.Filters?.ExcludeNamespaces?.ToList() ?? new List<string>(),
 12106                include_types = config?.Filters?.IncludeTypes?.ToList() ?? new List<string>(),
 12107                exclude_types = config?.Filters?.ExcludeTypes?.ToList() ?? new List<string>()
 12108            },
 12109            mappings = mappingsResult,
 12110            exclusions = new
 12111            {
 12112                excluded_types = config?.Exclusions?.ExcludedTypes?.ToList() ?? new List<string>(),
 12113                excluded_members = config?.Exclusions?.ExcludedMembers?.ToList() ?? new List<string>(),
 12114                excluded_type_patterns = config?.Exclusions?.ExcludedTypePatterns?.ToList() ?? new List<string>(),
 12115                excluded_member_patterns = config?.Exclusions?.ExcludedMemberPatterns?.ToList() ?? new List<string>(),
 12116                exclude_compiler_generated = config?.Exclusions?.ExcludeCompilerGenerated ?? false,
 12117                exclude_obsolete = config?.Exclusions?.ExcludeObsolete ?? false
 12118            },
 12119            breaking_change_rules = new
 12120            {
 12121                treat_type_removal_as_breaking = config?.BreakingChangeRules?.TreatTypeRemovalAsBreaking ?? true,
 12122                treat_member_removal_as_breaking = config?.BreakingChangeRules?.TreatMemberRemovalAsBreaking ?? true,
 12123                treat_signature_change_as_breaking = config?.BreakingChangeRules?.TreatSignatureChangeAsBreaking ?? true
 12124                treat_reduced_accessibility_as_breaking = config?.BreakingChangeRules?.TreatReducedAccessibilityAsBreaki
 12125                treat_added_type_as_breaking = config?.BreakingChangeRules?.TreatAddedTypeAsBreaking ?? false,
 12126                treat_added_member_as_breaking = config?.BreakingChangeRules?.TreatAddedMemberAsBreaking ?? false,
 12127                treat_added_interface_as_breaking = config?.BreakingChangeRules?.TreatAddedInterfaceAsBreaking ?? true,
 12128                treat_removed_interface_as_breaking = config?.BreakingChangeRules?.TreatRemovedInterfaceAsBreaking ?? tr
 12129                treat_parameter_name_change_as_breaking = config?.BreakingChangeRules?.TreatParameterNameChangeAsBreakin
 12130                treat_added_optional_parameter_as_breaking = config?.BreakingChangeRules?.TreatAddedOptionalParameterAsB
 12131            },
 12132            output_format = config?.OutputFormat.ToString() ?? "Console",
 12133            output_path = config?.OutputPath ?? string.Empty,
 12134            fail_on_breaking_changes = config?.FailOnBreakingChanges ?? false
 12135        };
 12136    }
 137
 138    private object PrepareResultData(ComparisonResult result)
 6139    {
 6140        return new
 6141        {
 6142            comparison_timestamp = result.ComparisonTimestamp,
 6143            old_assembly_name = Path.GetFileName(result.OldAssemblyPath),
 6144            old_assembly_path = result.OldAssemblyPath,
 6145            new_assembly_name = Path.GetFileName(result.NewAssemblyPath),
 6146            new_assembly_path = result.NewAssemblyPath,
 6147            total_differences = result.TotalDifferences,
 6148            has_breaking_changes = result.HasBreakingChanges,
 5149            breaking_changes_count = result.Differences.Count(d => d.IsBreakingChange),
 6150            summary = new
 6151            {
 6152                added_count = result.Summary.AddedCount,
 6153                removed_count = result.Summary.RemovedCount,
 6154                modified_count = result.Summary.ModifiedCount,
 6155                breaking_changes_count = result.Summary.BreakingChangesCount
 6156            },
 6157            configuration = PrepareConfigData(result.Configuration),
 5158            breaking_changes = PrepareBreakingChangesData(result.Differences.Where(d => d.IsBreakingChange))
 6159        };
 6160    }
 161
 162    private object[] PrepareChangeSections(ComparisonResult result)
 6163    {
 6164        var sections = new List<object>();
 165
 11166        var addedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Added).ToList();
 6167        if (addedItems.Any())
 2168        {
 2169            sections.Add(new
 2170            {
 2171                icon = "➕",
 2172                title = "Added Items",
 2173                count = addedItems.Count,
 2174                change_type = "added",
 2175                grouped_changes = GroupChangesByType(addedItems)
 2176            });
 2177        }
 178
 11179        var removedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Removed).ToList();
 6180        if (removedItems.Any())
 2181        {
 2182            sections.Add(new
 2183            {
 2184                icon = "➖",
 2185                title = "Removed Items",
 2186                count = removedItems.Count,
 2187                change_type = "removed",
 2188                grouped_changes = GroupChangesByType(removedItems)
 2189            });
 2190        }
 191
 11192        var modifiedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Modified).ToList();
 6193        if (modifiedItems.Any())
 1194        {
 1195            sections.Add(new
 1196            {
 1197                icon = "🔄",
 1198                title = "Modified Items",
 1199                count = modifiedItems.Count,
 1200                change_type = "modified",
 1201                grouped_changes = GroupChangesByType(modifiedItems)
 1202            });
 1203        }
 204
 11205        var movedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Moved).ToList();
 6206        if (movedItems.Any())
 0207        {
 0208            sections.Add(new
 0209            {
 0210                icon = "📦",
 0211                title = "Moved Items",
 0212                count = movedItems.Count,
 0213                change_type = "moved",
 0214                grouped_changes = GroupChangesByType(movedItems)
 0215            });
 0216        }
 217
 11218        var excludedItems = result.Differences.Where(d => d.ChangeType == ChangeType.Excluded).ToList();
 6219        if (excludedItems.Any())
 0220        {
 0221            sections.Add(new
 0222            {
 0223                icon = "🚫",
 0224                title = "Excluded Items",
 0225                count = excludedItems.Count,
 0226                change_type = "excluded",
 0227                description = "The following items were intentionally excluded from the comparison:",
 0228                grouped_changes = GroupChangesByType(excludedItems)
 0229            });
 0230        }
 231
 6232        return sections.ToArray();
 6233    }
 234
 235    private object[] GroupChanges(List<ApiDifference> changes)
 0236    {
 0237        return changes.GroupBy(c => c.ElementType)
 0238            .OrderBy(g => g.Key)
 0239            .Select(g => new
 0240            {
 0241                key = g.Key,
 0242                count = g.Count(),
 0243                changes = g.OrderBy(c => c.ElementName).Select(c => new
 0244                {
 0245                    element_name = c.ElementName,
 0246                    description = c.Description,
 0247                    is_breaking_change = c.IsBreakingChange,
 0248                    has_signatures = !string.IsNullOrEmpty(c.OldSignature) || !string.IsNullOrEmpty(c.NewSignature),
 0249                    old_signature = HtmlEscape(c.OldSignature),
 0250                    new_signature = HtmlEscape(c.NewSignature),
 0251                    details_id = $"details-{Guid.NewGuid():N}"
 0252                }).ToArray()
 0253            }).ToArray();
 0254    }
 255
 256    private object[] GroupChangesByType(List<ApiDifference> changes)
 5257    {
 258        // Group changes by the containing type first
 5259        return changes
 5260            .GroupBy(d => ExtractContainingType(d.ElementName, d.ElementType))
 5261            .OrderBy(g => g.Key)
 10262            .Select(typeGroup => new
 10263            {
 10264                key = GetTypeDisplayName(typeGroup.Key),
 10265                full_type_name = typeGroup.Key,
 10266                count = typeGroup.Count(),
 5267                breaking_changes_count = typeGroup.Count(c => c.IsBreakingChange),
 5268                has_breaking_changes = typeGroup.Any(c => c.IsBreakingChange),
 10269                changes = typeGroup.OrderBy(c => c.ElementName).Select(c => new
 5270                {
 5271                    element_name = GetMemberDisplayName(c.ElementName, c.ElementType),
 5272                    full_element_name = c.ElementName,
 5273                    element_type = c.ElementType.ToString(),
 5274                    description = c.Description,
 5275                    is_breaking_change = c.IsBreakingChange,
 5276                    has_signatures = !string.IsNullOrEmpty(c.OldSignature) || !string.IsNullOrEmpty(c.NewSignature),
 5277                    old_signature = HtmlEscape(c.OldSignature),
 5278                    new_signature = HtmlEscape(c.NewSignature),
 5279                    details_id = $"details-{Guid.NewGuid():N}",
 5280                    severity = c.Severity.ToString().ToLower()
 5281                }).ToArray()
 10282            }).ToArray();
 5283    }
 284
 285    private string ExtractContainingType(string elementName, ApiElementType elementType)
 5286    {
 287        // For type-level changes, the element name is the type name
 5288        if (elementType == ApiElementType.Type)
 1289        {
 1290            return elementName;
 291        }
 292
 293        // For member-level changes, extract the type from the full name
 294        // Expected format: TypeName.MemberName or Namespace.TypeName.MemberName
 4295        var lastDotIndex = elementName.LastIndexOf('.');
 4296        if (lastDotIndex > 0)
 1297        {
 1298            var typePart = elementName.Substring(0, lastDotIndex);
 299
 300            // Handle nested types (Type+NestedType.Member)
 1301            var plusIndex = typePart.LastIndexOf('+');
 1302            if (plusIndex > 0)
 0303            {
 0304                return typePart; // Keep the full nested type name
 305            }
 306
 1307            return typePart;
 308        }
 309
 310        // Fallback for malformed names
 3311        return elementName;
 5312    }
 313
 314    private string GetTypeDisplayName(string typeName)
 5315    {
 316        // Extract just the type name without namespace for display
 5317        var lastDotIndex = typeName.LastIndexOf('.');
 5318        return lastDotIndex > 0 ? typeName.Substring(lastDotIndex + 1) : typeName;
 5319    }
 320
 321    private string GetMemberDisplayName(string elementName, ApiElementType elementType)
 5322    {
 323        // For type-level changes, return the simple type name
 5324        if (elementType == ApiElementType.Type)
 1325        {
 1326            var lastDotIndex = elementName.LastIndexOf('.');
 1327            return lastDotIndex > 0 ? elementName.Substring(lastDotIndex + 1) : elementName;
 328        }
 329
 330        // For member-level changes, return just the member name
 4331        var memberDotIndex = elementName.LastIndexOf('.');
 4332        return memberDotIndex > 0 ? elementName.Substring(memberDotIndex + 1) : elementName;
 5333    }
 334
 335    private object[] PrepareBreakingChangesData(IEnumerable<ApiDifference> breakingChanges)
 6336    {
 7337        return breakingChanges.OrderBy(d => d.Severity)
 1338            .ThenBy(d => d.ElementType)
 1339            .ThenBy(d => d.ElementName)
 7340            .Select(change => new
 7341            {
 7342                severity = change.Severity.ToString(),
 7343                severity_class = change.Severity.ToString().ToLower(),
 7344                element_type = change.ElementType,
 7345                element_name = change.ElementName,
 7346                description = change.Description
 7347            }).ToArray();
 6348    }
 349
 350    private string FormatBooleanValue(bool value)
 102351    {
 102352        return value ? "<span class=\"boolean-true\">✓ True</span>" : "<span class=\"boolean-false\">✗ False</span>";
 102353    }
 354
 355    private string RenderChangeGroup(object sectionData)
 0356    {
 357        try
 0358        {
 359            // Load and parse the change-group template
 0360            var templateContent = EmbeddedTemplateLoader.LoadTemplate("change-group.scriban");
 0361            var template = Template.Parse(templateContent);
 362
 363            // Create a new context for the template with the section data as root
 0364            var context = new TemplateContext();
 0365            var scriptObject = new ScriptObject();
 366
 367            // Add the section data properties to the script object
 0368            if (sectionData != null)
 0369            {
 0370                var sectionType = sectionData.GetType();
 0371                foreach (var property in sectionType.GetProperties())
 0372                {
 0373                    var value = property.GetValue(sectionData);
 0374                    scriptObject.SetValue(property.Name.ToLowerInvariant(), value, true);
 0375                }
 0376            }
 377
 0378            context.PushGlobal(scriptObject);
 379
 0380            return template.Render(context);
 381        }
 0382        catch (Exception ex)
 0383        {
 0384            return $"<!-- Error rendering change group: {ex.Message} -->";
 385        }
 0386    }
 387
 388    private string GetCssStyles()
 6389    {
 390        try
 6391        {
 6392            return EmbeddedTemplateLoader.LoadStyles();
 393        }
 0394        catch (Exception ex)
 0395        {
 0396            System.Diagnostics.Debug.WriteLine($"Warning: Could not load CSS styles, using fallback: {ex.Message}");
 0397            return GetFallbackStyles();
 398        }
 6399    }
 400
 401    private string GetJavaScriptCode()
 6402    {
 403        try
 6404        {
 6405            return EmbeddedTemplateLoader.LoadScripts();
 406        }
 0407        catch (Exception ex)
 0408        {
 0409            System.Diagnostics.Debug.WriteLine($"Warning: Could not load JavaScript, using fallback: {ex.Message}");
 0410            return GetFallbackJavaScript();
 411        }
 6412    }
 413
 414    private string GetFallbackStyles()
 0415    {
 0416        return "body { font-family: Arial, sans-serif; margin: 20px; }";
 0417    }
 418
 419    private string GetFallbackJavaScript()
 0420    {
 0421        return "// Fallback JavaScript";
 0422    }
 423}