From 36e1ae5f326e011e283f258fe00a2169e762eeaf Mon Sep 17 00:00:00 2001 From: GeWuYou <95328647+GeWuYou@users.noreply.github.com> Date: Sat, 2 May 2026 19:33:00 +0800 Subject: [PATCH] feat(godot): add configurable logger templates --- .../Logging/GodotLogTemplateTests.cs | 110 +++++++++ .../Logging/GodotLogRenderContext.cs | 11 + GFramework.Godot/Logging/GodotLogTemplate.cs | 226 ++++++++++++++++++ GFramework.Godot/Logging/GodotLogger.cs | 106 ++++---- .../Logging/GodotLoggerFactory.cs | 44 +++- .../Logging/GodotLoggerFactoryProvider.cs | 34 ++- GFramework.Godot/Logging/GodotLoggerMode.cs | 17 ++ .../Logging/GodotLoggerOptions.cs | 115 +++++++++ 8 files changed, 597 insertions(+), 66 deletions(-) create mode 100644 GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs create mode 100644 GFramework.Godot/Logging/GodotLogRenderContext.cs create mode 100644 GFramework.Godot/Logging/GodotLogTemplate.cs create mode 100644 GFramework.Godot/Logging/GodotLoggerMode.cs create mode 100644 GFramework.Godot/Logging/GodotLoggerOptions.cs diff --git a/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs b/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs new file mode 100644 index 00000000..adface63 --- /dev/null +++ b/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs @@ -0,0 +1,110 @@ +using System; +using GFramework.Core.Abstractions.Logging; +using GFramework.Godot.Logging; + +namespace GFramework.Godot.Tests.Logging; + +[TestFixture] +public sealed class GodotLogTemplateTests +{ + [Test] + public void Render_Should_Format_Timestamp_Level_Color_Category_And_Message() + { + var template = GodotLogTemplate.Parse("[{timestamp:yyyyMMdd}] [color={color}]{level:u3}[/color] [{category:l16}] {message}"); + var context = new GodotLogRenderContext( + new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc), + LogLevel.Warning, + "Game.Services.Inventory", + "Loaded", + "orange"); + + var result = template.Render(context); + + Assert.That(result, Is.EqualTo("[20260502] [color=orange]WRN[/color] [G.S.Inventory ] Loaded")); + } + + [Test] + public void Render_Should_Support_Lowercase_Level_Format() + { + var template = GodotLogTemplate.Parse("{level:l3}:{message}"); + var context = new GodotLogRenderContext( + new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc), + LogLevel.Debug, + "Game", + "Ready", + "cyan"); + + var result = template.Render(context); + + Assert.That(result, Is.EqualTo("dbg:Ready")); + } + + [Test] + public void Render_Should_Right_Align_Category() + { + var template = GodotLogTemplate.Parse("[{category:r10}]"); + var context = new GodotLogRenderContext( + new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc), + LogLevel.Info, + "UI", + "Ready", + "white"); + + var result = template.Render(context); + + Assert.That(result, Is.EqualTo("[ UI]")); + } + + [Test] + public void Render_Should_Preserve_Unknown_Placeholders() + { + var template = GodotLogTemplate.Parse("{message} {unknown}"); + var context = new GodotLogRenderContext( + new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc), + LogLevel.Info, + "Game", + "Ready", + "white"); + + var result = template.Render(context); + + Assert.That(result, Is.EqualTo("Ready {unknown}")); + } + + [Test] + public void Render_Should_Format_Padded_Level() + { + var template = GodotLogTemplate.Parse("{level:padded}"); + var context = new GodotLogRenderContext( + new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc), + LogLevel.Info, + "Game", + "Ready", + "white"); + + var result = template.Render(context); + + Assert.That(result, Is.EqualTo("INFO ")); + } + + [Test] + public void Options_ForMinimumLevel_Should_Preserve_Fixed_Minimum_Level() + { + var options = GodotLoggerOptions.ForMinimumLevel(LogLevel.Warning); + + Assert.That(options.Mode, Is.EqualTo(GodotLoggerMode.Debug)); + Assert.That(options.DebugMinLevel, Is.EqualTo(LogLevel.Warning)); + Assert.That(options.ReleaseMinLevel, Is.EqualTo(LogLevel.Warning)); + } + + [Test] + public void Options_Should_Use_Default_Color_When_Configured_Color_Is_Missing() + { + var options = new GodotLoggerOptions(); + options.Colors.Remove(LogLevel.Error); + + var result = options.GetColor(LogLevel.Error); + + Assert.That(result, Is.EqualTo("red")); + } +} diff --git a/GFramework.Godot/Logging/GodotLogRenderContext.cs b/GFramework.Godot/Logging/GodotLogRenderContext.cs new file mode 100644 index 00000000..8ea0460b --- /dev/null +++ b/GFramework.Godot/Logging/GodotLogRenderContext.cs @@ -0,0 +1,11 @@ +using System; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +internal readonly record struct GodotLogRenderContext( + DateTime Timestamp, + LogLevel Level, + string Category, + string Message, + string Color); diff --git a/GFramework.Godot/Logging/GodotLogTemplate.cs b/GFramework.Godot/Logging/GodotLogTemplate.cs new file mode 100644 index 00000000..c05fa5ba --- /dev/null +++ b/GFramework.Godot/Logging/GodotLogTemplate.cs @@ -0,0 +1,226 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +internal sealed class GodotLogTemplate +{ + private static readonly ConcurrentDictionary Cache = new(StringComparer.Ordinal); + + private readonly ConcurrentDictionary _categoryCache = new(StringComparer.Ordinal); + private readonly int _literalLength; + private readonly Action[] _segments; + + private GodotLogTemplate(string template) + { + (_segments, _literalLength) = ParseCore(template); + } + + public static GodotLogTemplate Parse(string template) + { + ArgumentNullException.ThrowIfNull(template); + return Cache.GetOrAdd(template, static value => new GodotLogTemplate(value)); + } + + public string Render(GodotLogRenderContext context) + { + var builder = new StringBuilder(_literalLength + context.Category.Length + context.Message.Length + 48); + foreach (var segment in _segments) + { + segment(builder, context); + } + + return builder.ToString(); + } + + private (Action[] Segments, int LiteralLength) ParseCore(string template) + { + var segments = new List>(); + var literalLength = 0; + var position = 0; + + while (position < template.Length) + { + var open = template.IndexOf('{', position); + if (open < 0) + { + AddLiteral(template[position..]); + break; + } + + if (open > position) + { + AddLiteral(template[position..open]); + } + + var close = template.IndexOf('}', open + 1); + if (close < 0) + { + AddLiteral(template[open..]); + break; + } + + var key = template.Substring(open + 1, close - open - 1); + segments.Add(CreateSegment(key)); + position = close + 1; + } + + return ([.. segments], literalLength); + + void AddLiteral(string literal) + { + if (literal.Length == 0) + { + return; + } + + literalLength += literal.Length; + segments.Add((builder, _) => builder.Append(literal)); + } + } + + private Action CreateSegment(string key) + { + return key switch + { + "category" => static (builder, context) => builder.Append(context.Category), + "color" => static (builder, context) => builder.Append(context.Color), + "level" => static (builder, context) => builder.Append(context.Level), + "message" => static (builder, context) => builder.Append(context.Message), + "timestamp" => static (builder, context) => builder.Append(context.Timestamp.ToString( + "yyyy-MM-dd HH:mm:ss.fff", + CultureInfo.InvariantCulture)), + not null when key.StartsWith("category:", StringComparison.Ordinal) => CreateCategorySegment(key[9..]), + not null when key.StartsWith("level:", StringComparison.Ordinal) => CreateLevelSegment(key[6..]), + not null when key.StartsWith("timestamp:", StringComparison.Ordinal) => CreateTimestampSegment(key[10..]), + _ => (builder, _) => builder.Append('{').Append(key).Append('}') + }; + } + + private Action CreateTimestampSegment(string format) + { + if (string.IsNullOrWhiteSpace(format)) + { + return static (builder, context) => builder.Append(context.Timestamp.ToString( + "yyyy-MM-dd HH:mm:ss.fff", + CultureInfo.InvariantCulture)); + } + + return (builder, context) => builder.Append(context.Timestamp.ToString(format, CultureInfo.InvariantCulture)); + } + + private static Action CreateLevelSegment(string format) + { + return format switch + { + "u3" or "U3" => static (builder, context) => builder.Append(ToShortLevel(context.Level, upper: true)), + "l3" or "L3" => static (builder, context) => builder.Append(ToShortLevel(context.Level, upper: false)), + "padded" or "Padded" => static (builder, context) => builder.Append(ToPaddedLevel(context.Level)), + _ => static (builder, context) => builder.Append(context.Level) + }; + } + + private Action CreateCategorySegment(string format) + { + if (format.Length < 2) + { + return static (builder, context) => builder.Append(context.Category); + } + + var alignment = format[0]; + if (alignment is not 'l' and not 'r') + { + return static (builder, context) => builder.Append(context.Category); + } + + if (!int.TryParse(format[1..], NumberStyles.None, CultureInfo.InvariantCulture, out var width) || width <= 0) + { + return static (builder, context) => builder.Append(context.Category); + } + + return alignment == 'l' + ? (builder, context) => builder.Append(GetFormattedCategory(context.Category, format, width, padLeft: false)) + : (builder, context) => builder.Append(GetFormattedCategory(context.Category, format, width, padLeft: true)); + } + + private string GetFormattedCategory(string category, string format, int width, bool padLeft) + { + var cacheKey = string.Concat(format, "\0", category); + return _categoryCache.GetOrAdd(cacheKey, _ => + { + var abbreviated = AbbreviateCategory(category, width); + return padLeft ? abbreviated.PadLeft(width) : abbreviated.PadRight(width); + }); + } + + private static string AbbreviateCategory(string category, int maxLength) + { + if (category.Length <= maxLength) + { + return category; + } + + var parts = category.Split('.'); + if (parts.Length == 1) + { + return category[..maxLength]; + } + + for (var i = 0; i < parts.Length - 1; i++) + { + if (parts[i].Length > 1) + { + parts[i] = parts[i][..1]; + } + } + + var start = 0; + while (start < parts.Length - 1) + { + var joined = string.Join(".", parts, start, parts.Length - start); + if (joined.Length <= maxLength) + { + return joined; + } + + start++; + } + + var last = parts[^1]; + return last.Length > maxLength ? last[..maxLength] : last; + } + + private static string ToShortLevel(LogLevel level, bool upper) + { + var value = level switch + { + LogLevel.Trace => "trc", + LogLevel.Debug => "dbg", + LogLevel.Info => "inf", + LogLevel.Warning => "wrn", + LogLevel.Error => "err", + LogLevel.Fatal => "ftl", + _ => "unk" + }; + + return upper ? value.ToUpperInvariant() : value; + } + + private static string ToPaddedLevel(LogLevel level) + { + return level switch + { + LogLevel.Trace => "TRACE ", + LogLevel.Debug => "DEBUG ", + LogLevel.Info => "INFO ", + LogLevel.Warning => "WARNING", + LogLevel.Error => "ERROR ", + LogLevel.Fatal => "FATAL ", + _ => level.ToString() + }; + } +} diff --git a/GFramework.Godot/Logging/GodotLogger.cs b/GFramework.Godot/Logging/GodotLogger.cs index e0fc2948..551a9dba 100644 --- a/GFramework.Godot/Logging/GodotLogger.cs +++ b/GFramework.Godot/Logging/GodotLogger.cs @@ -1,4 +1,4 @@ -using System.Globalization; +using System; using GFramework.Core.Abstractions.Logging; using GFramework.Core.Logging; using Godot; @@ -6,69 +6,79 @@ using Godot; namespace GFramework.Godot.Logging; /// -/// Godot平台的日志记录器实现。 -/// 该类继承自 ,用于在 Godot 引擎中输出日志信息。 -/// 支持不同日志级别的输出,并根据级别调用 Godot 的相应方法。 +/// Godot platform logger implementation. /// -/// 日志记录器的名称,默认为根日志记录器名称。 -/// 最低日志级别,默认为 。 -public sealed class GodotLogger( - string? name = null, - LogLevel minLevel = LogLevel.Info) : AbstractLogger(name ?? RootLoggerName, minLevel) +public sealed class GodotLogger : AbstractLogger { - // 静态缓存日志级别字符串,避免重复格式化 - private static readonly string[] LevelStrings = - [ - "TRACE ", - "DEBUG ", - "INFO ", - "WARNING", - "ERROR ", - "FATAL " - ]; + private readonly GodotLoggerOptions _options; /// - /// 写入日志的核心方法。 - /// 格式化日志消息并根据日志级别调用 Godot 的输出方法。 + /// Initializes a logger that preserves the historical fixed-format template. /// - /// 日志级别。 - /// 日志消息内容。 - /// 可选的异常信息。 + /// The logger name. + /// The minimum enabled log level. + public GodotLogger(string? name = null, LogLevel minLevel = LogLevel.Info) + : this(name, GodotLoggerOptions.ForMinimumLevel(minLevel)) + { + } + + /// + /// Initializes a logger with Godot-specific formatting options. + /// + /// The logger name. + /// The logger options. + public GodotLogger(string? name, GodotLoggerOptions options) + : base(name ?? RootLoggerName, (options ?? throw new ArgumentNullException(nameof(options))).GetEffectiveMinLevel()) + { + _options = options; + } + + /// + /// Writes a log entry to Godot. + /// + /// The log level. + /// The rendered message body. + /// The optional exception. protected override void Write(LogLevel level, string message, Exception? exception) { - // 构造时间戳和日志前缀 - var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); - var levelStr = LevelStrings[(int)level]; - var logPrefix = $"[{timestamp}] {levelStr} [{Name()}]"; + var templateText = _options.Mode == GodotLoggerMode.Debug + ? _options.DebugOutputTemplate + : _options.ReleaseOutputTemplate; + var context = new GodotLogRenderContext( + DateTime.UtcNow, + level, + Name(), + message, + _options.GetColor(level)); + var rendered = GodotLogTemplate.Parse(templateText).Render(context); - // 添加异常信息到日志消息中 - if (exception != null) message += "\n" + exception; + if (_options.Mode == GodotLoggerMode.Debug) + { + WriteDebug(level, rendered); + } + else + { + GD.Print(rendered); + } - var logMessage = $"{logPrefix} {message}"; + if (exception != null) + { + GD.PrintErr(exception.ToString()); + } + } + + private static void WriteDebug(LogLevel level, string rendered) + { + GD.PrintRich(rendered); - // 根据日志级别选择 Godot 输出方法 switch (level) { case LogLevel.Fatal: - GD.PushError(logMessage); - break; case LogLevel.Error: - GD.PrintErr(logMessage); + GD.PushError(rendered); break; case LogLevel.Warning: - GD.PushWarning(logMessage); - break; - case LogLevel.Trace: - GD.PrintRich($"[color=gray]{logMessage}[/color]"); - break; - case LogLevel.Debug: - GD.PrintRich($"[color=cyan]{logMessage}[/color]"); - break; - case LogLevel.Info: - GD.Print(logMessage); - break; - default: - GD.Print(logMessage); + GD.PushWarning(rendered); break; } } diff --git a/GFramework.Godot/Logging/GodotLoggerFactory.cs b/GFramework.Godot/Logging/GodotLoggerFactory.cs index efbfb7d9..dfc179a0 100644 --- a/GFramework.Godot/Logging/GodotLoggerFactory.cs +++ b/GFramework.Godot/Logging/GodotLoggerFactory.cs @@ -1,20 +1,46 @@ -using GFramework.Core.Abstractions.Logging; +using System; +using GFramework.Core.Abstractions.Logging; namespace GFramework.Godot.Logging; /// -/// Godot日志工厂类,用于创建Godot平台专用的日志记录器实例 +/// Creates Godot platform logger instances. /// -public class GodotLoggerFactory : ILoggerFactory +public sealed class GodotLoggerFactory : ILoggerFactory { + private readonly GodotLoggerOptions? _options; + /// - /// 获取指定名称的日志记录器实例 + /// Initializes a factory that preserves the historical fixed-format logger behavior. /// - /// 日志记录器的名称 - /// 日志记录器的最小日志级别 - /// 返回GodotLogger类型的日志记录器实例 + public GodotLoggerFactory() + { + } + + /// + /// Initializes a factory with Godot-specific formatting options. + /// + /// The logger options. + public GodotLoggerFactory(GodotLoggerOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets a logger with the specified name. + /// + /// The logger name. + /// The minimum enabled level. + /// A Godot logger instance. public ILogger GetLogger(string name, LogLevel minLevel = LogLevel.Info) { - return new GodotLogger(name, minLevel); + ArgumentNullException.ThrowIfNull(name); + + if (_options == null) + { + return new GodotLogger(name, minLevel); + } + + return new GodotLogger(name, _options.WithMinimumLevelFloor(minLevel)); } -} \ No newline at end of file +} diff --git a/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs b/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs index 9ce22a91..1b3ab0ed 100644 --- a/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs +++ b/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs @@ -1,35 +1,51 @@ -using GFramework.Core.Abstractions.Logging; +using System; +using GFramework.Core.Abstractions.Logging; using GFramework.Core.Logging; namespace GFramework.Godot.Logging; /// -/// Godot日志工厂提供程序,用于创建Godot日志记录器实例 +/// Provides cached Godot logger instances. /// public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider { private readonly ILoggerFactory _cachedFactory; /// - /// 初始化Godot日志记录器工厂提供程序 + /// Initializes a Godot logger provider with the default logger factory. /// public GodotLoggerFactoryProvider() { - _cachedFactory = new CachedLoggerFactory(new GodotLoggerFactory()); + _cachedFactory = CreateCachedFactory(new GodotLoggerFactory()); } /// - /// 获取或设置最小日志级别 + /// Initializes a Godot logger provider with Godot-specific formatting options. + /// + /// The logger options. + public GodotLoggerFactoryProvider(GodotLoggerOptions options) + { + _cachedFactory = CreateCachedFactory(new GodotLoggerFactory(options)); + } + + /// + /// Gets or sets the provider minimum level. /// public LogLevel MinLevel { get; set; } /// - /// 创建指定名称的日志记录器实例(带缓存) + /// Creates a cached logger with the specified name. /// - /// 日志记录器的名称 - /// 返回配置了最小日志级别的Godot日志记录器实例 + /// The logger name. + /// A logger configured with . public ILogger CreateLogger(string name) { return _cachedFactory.GetLogger(name, MinLevel); } -} \ No newline at end of file + + private static ILoggerFactory CreateCachedFactory(ILoggerFactory innerFactory) + { + ArgumentNullException.ThrowIfNull(innerFactory); + return new CachedLoggerFactory(innerFactory); + } +} diff --git a/GFramework.Godot/Logging/GodotLoggerMode.cs b/GFramework.Godot/Logging/GodotLoggerMode.cs new file mode 100644 index 00000000..8bde69fa --- /dev/null +++ b/GFramework.Godot/Logging/GodotLoggerMode.cs @@ -0,0 +1,17 @@ +namespace GFramework.Godot.Logging; + +/// +/// Selects the Godot logger output behavior. +/// +public enum GodotLoggerMode +{ + /// + /// Uses rich BBCode console output and mirrors warnings/errors to the Godot debugger panel. + /// + Debug, + + /// + /// Uses plain console output without rich text or debugger panel mirroring. + /// + Release +} diff --git a/GFramework.Godot/Logging/GodotLoggerOptions.cs b/GFramework.Godot/Logging/GodotLoggerOptions.cs new file mode 100644 index 00000000..ccf5a2f1 --- /dev/null +++ b/GFramework.Godot/Logging/GodotLoggerOptions.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +/// +/// Godot logger formatting and routing options. +/// +public sealed class GodotLoggerOptions +{ + private static readonly IReadOnlyDictionary DefaultColors = new Dictionary + { + [LogLevel.Trace] = "gray", + [LogLevel.Debug] = "cyan", + [LogLevel.Info] = "white", + [LogLevel.Warning] = "orange", + [LogLevel.Error] = "red", + [LogLevel.Fatal] = "deep_pink" + }; + + /// + /// Gets or sets the output mode. + /// + public GodotLoggerMode Mode { get; set; } = GodotLoggerMode.Debug; + + /// + /// Gets or sets the minimum level used by . + /// + public LogLevel DebugMinLevel { get; set; } = LogLevel.Debug; + + /// + /// Gets or sets the minimum level used by . + /// + public LogLevel ReleaseMinLevel { get; set; } = LogLevel.Info; + + /// + /// Gets or sets the BBCode-capable template used by . + /// +#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios. + public string DebugOutputTemplate { get; set; } = + "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}"; +#pragma warning restore MA0016 + + /// + /// Gets or sets the plain text template used by . + /// +#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios. + public string ReleaseOutputTemplate { get; set; } = + "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}"; +#pragma warning restore MA0016 + + /// + /// Gets or sets Godot named colors by log level. + /// +#pragma warning disable MA0016 // Keep configuration mutable for object initializer and serializer scenarios. + public Dictionary Colors { get; set; } = new(DefaultColors); +#pragma warning restore MA0016 + + /// + /// Creates options that preserve the previous Godot logger defaults for a fixed minimum level. + /// + /// The minimum enabled level. + /// Options equivalent to the previous fixed-format logger behavior. + public static GodotLoggerOptions ForMinimumLevel(LogLevel minLevel) + { + return new GodotLoggerOptions + { + Mode = GodotLoggerMode.Debug, + DebugMinLevel = minLevel, + ReleaseMinLevel = minLevel, + DebugOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}", + ReleaseOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}", + Colors = new Dictionary(DefaultColors) + }; + } + + /// + /// Returns the configured color for the specified level. + /// + /// The level. + /// The Godot named color. + public string GetColor(LogLevel level) + { + if (Colors.TryGetValue(level, out var color) && !string.IsNullOrWhiteSpace(color)) + { + return color; + } + + return DefaultColors[level]; + } + + internal LogLevel GetEffectiveMinLevel() + { + return Mode == GodotLoggerMode.Debug ? DebugMinLevel : ReleaseMinLevel; + } + + internal GodotLoggerOptions WithMinimumLevelFloor(LogLevel minLevel) + { + return new GodotLoggerOptions + { + Mode = Mode, + DebugMinLevel = Max(DebugMinLevel, minLevel), + ReleaseMinLevel = Max(ReleaseMinLevel, minLevel), + DebugOutputTemplate = DebugOutputTemplate, + ReleaseOutputTemplate = ReleaseOutputTemplate, + Colors = new Dictionary(Colors) + }; + } + + private static LogLevel Max(LogLevel left, LogLevel right) + { + return left > right ? left : right; + } +}