diff --git a/.github/workflows/license-compliance.yml b/.github/workflows/license-compliance.yml index 2ba55482..b5099fad 100644 --- a/.github/workflows/license-compliance.yml +++ b/.github/workflows/license-compliance.yml @@ -62,6 +62,7 @@ jobs: # with: 配置上传的具体内容 # name: 工件名称,用于标识上传的文件集合 # path: 指定需要上传的文件路径列表(支持多行格式) + # third-party-licenses/**: 手工维护的参考源码许可证原文 - name: Upload compliance artifacts uses: actions/upload-artifact@v7 with: @@ -69,6 +70,7 @@ jobs: path: | NOTICE THIRD_PARTY_LICENSES.md + third-party-licenses/** sbom.spdx.json sbom.cyclonedx.json sbom-spdx-validation.txt @@ -79,15 +81,17 @@ jobs: # 压缩包中包含以下文件: # - NOTICE: 项目声明文件 # - THIRD_PARTY_LICENSES.md: 第三方许可证列表 + # - third-party-licenses/: 手工维护的参考源码许可证原文 # - sbom.spdx.json: SPDX 格式的软件物料清单 # - sbom.cyclonedx.json: CycloneDX 格式的软件物料清单 # - sbom-spdx-validation.txt: SPDX 格式验证结果 # - sbom-cyclonedx-validation.txt: CycloneDX 格式验证结果 - name: Package compliance bundle run: | - zip license-compliance.zip \ + zip -r license-compliance.zip \ NOTICE \ THIRD_PARTY_LICENSES.md \ + third-party-licenses \ sbom.spdx.json \ sbom.cyclonedx.json \ sbom-spdx-validation.txt \ diff --git a/GFramework.Core/Logging/AbstractLogger.cs b/GFramework.Core/Logging/AbstractLogger.cs index 3ca42a22..90dee078 100644 --- a/GFramework.Core/Logging/AbstractLogger.cs +++ b/GFramework.Core/Logging/AbstractLogger.cs @@ -6,16 +6,42 @@ namespace GFramework.Core.Logging; /// 日志抽象基类,封装日志级别判断、格式化与异常处理逻辑。 /// 平台日志器只需实现 Write 方法即可。 /// -public abstract class AbstractLogger( - string? name = null, - LogLevel minLevel = LogLevel.Info) : IStructuredLogger +public abstract class AbstractLogger : IStructuredLogger { /// /// 根日志记录器的名称常量 /// public const string RootLoggerName = "ROOT"; - private readonly string _name = name ?? RootLoggerName; + private readonly Func _minLevelProvider; + private readonly string _name; + + /// + /// 使用固定最小日志级别初始化日志器。 + /// + /// 日志器名称。 + /// 最小日志级别。 + protected AbstractLogger( + string? name = null, + LogLevel minLevel = LogLevel.Info) + : this(name, () => minLevel) + { + } + + /// + /// 使用动态最小日志级别提供器初始化日志器。 + /// + /// 日志器名称。 + /// 最小日志级别提供器。 + protected AbstractLogger( + string? name, + Func minLevelProvider) + { + ArgumentNullException.ThrowIfNull(minLevelProvider); + + _name = name ?? RootLoggerName; + _minLevelProvider = minLevelProvider; + } #region Metadata @@ -47,7 +73,7 @@ public abstract class AbstractLogger( /// 如果指定级别大于等于最小级别则返回true,否则返回false protected bool IsEnabled(LogLevel level) { - return level >= minLevel; + return level >= _minLevelProvider(); } /// @@ -568,4 +594,4 @@ public abstract class AbstractLogger( #region Core Pipeline (Private) #endregion -} \ No newline at end of file +} diff --git a/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs b/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs new file mode 100644 index 00000000..a1a34e3b --- /dev/null +++ b/GFramework.Godot.Tests/Logging/GodotLogTemplateTests.cs @@ -0,0 +1,142 @@ +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", + string.Empty); + + 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", + string.Empty); + + 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", + string.Empty); + + 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", + string.Empty); + + 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", + string.Empty); + + var result = template.Render(context); + + Assert.That(result, Is.EqualTo("INFO ")); + } + + [Test] + public void Render_Should_Append_Properties_Placeholder() + { + var template = GodotLogTemplate.Parse("{message}{properties}"); + var context = new GodotLogRenderContext( + new DateTime(2026, 5, 2, 1, 2, 3, DateTimeKind.Utc), + LogLevel.Info, + "Game", + "Ready", + "white", + " | Scene=Boot"); + + var result = template.Render(context); + + Assert.That(result, Is.EqualTo("Ready | Scene=Boot")); + } + + [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")); + } + + [Test] + public void Options_Should_Use_White_Color_When_Level_Is_Not_Defined() + { + var options = new GodotLoggerOptions(); + + var result = options.GetColor((LogLevel)999); + + Assert.That(result, Is.EqualTo("white")); + } +} diff --git a/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs b/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs new file mode 100644 index 00000000..749ac715 --- /dev/null +++ b/GFramework.Godot.Tests/Logging/GodotLoggerSettingsLoaderTests.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text.Json; +using GFramework.Core.Abstractions.Logging; +using GFramework.Godot.Logging; + +namespace GFramework.Godot.Tests.Logging; + +/// +/// Verifies Godot logging configuration discovery, parsing, normalization, and live settings propagation. +/// +[TestFixture] +public sealed class GodotLoggerSettingsLoaderTests +{ + /// + /// Verifies that configuration discovery honors the environment path, executable directory, and project path order. + /// + [Test] + public void DiscoverConfigurationPath_Should_Prefer_EnvironmentVariable_Then_ProcessPath_Then_ProjectPath() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + + try + { + var envPath = Path.Combine(root, "env.json"); + File.WriteAllText(envPath, "{}"); + + var executableDirectory = Path.Combine(root, "bin"); + Directory.CreateDirectory(executableDirectory); + var processPath = Path.Combine(executableDirectory, "game.exe"); + var executableConfigPath = Path.Combine(executableDirectory, "appsettings.json"); + File.WriteAllText(executableConfigPath, "{}"); + + var projectPath = Path.Combine(root, "project-appsettings.json"); + File.WriteAllText(projectPath, "{}"); + + var discoveredFromEnvironment = GodotLoggerSettingsLoader.DiscoverConfigurationPath( + environmentPath: envPath, + processPath: processPath, + projectPathResolver: _ => projectPath); + var discoveredFromProcess = GodotLoggerSettingsLoader.DiscoverConfigurationPath( + environmentPath: Path.Combine(root, "missing-env.json"), + processPath: processPath, + projectPathResolver: _ => projectPath); + var discoveredFromProject = GodotLoggerSettingsLoader.DiscoverConfigurationPath( + environmentPath: Path.Combine(root, "missing-env.json"), + processPath: Path.Combine(root, "missing", "game.exe"), + projectPathResolver: _ => projectPath); + + Assert.That(discoveredFromEnvironment, Is.EqualTo(envPath)); + Assert.That(discoveredFromProcess, Is.EqualTo(executableConfigPath)); + Assert.That(discoveredFromProject, Is.EqualTo(projectPath)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + /// + /// Verifies that JSON settings bind Godot logger options and category log-level overrides. + /// + [Test] + public void LoadFromJsonString_Should_Read_GodotLogger_Options_And_Category_Levels() + { + const string json = """ + { + "Logging": { + "LogLevel": { + "Default": "Warning", + "Game.Services": "Error" + }, + "GodotLogger": { + "Mode": "Release", + "DebugMinLevel": "Debug", + "ReleaseMinLevel": "Information", + "DebugOutputTemplate": "[dbg] {message}", + "ReleaseOutputTemplate": "[rel] {message}{properties}", + "Colors": { + "Info": "aqua" + } + } + } + } + """; + + var settings = GodotLoggerSettingsLoader.LoadFromJsonString(json); + + Assert.Multiple(() => + { + Assert.That(settings.Options.Mode, Is.EqualTo(GodotLoggerMode.Release)); + Assert.That(settings.Options.ReleaseOutputTemplate, Is.EqualTo("[rel] {message}{properties}")); + Assert.That(settings.Options.GetColor(LogLevel.Info), Is.EqualTo("aqua")); + Assert.That(settings.GetEffectiveMinLevel("Game.Services.Inventory", LogLevel.Trace), Is.EqualTo(LogLevel.Error)); + Assert.That(settings.GetEffectiveMinLevel("Game.Other", LogLevel.Trace), Is.EqualTo(LogLevel.Warning)); + }); + } + + /// + /// Verifies that nullable JSON option fields are normalized before the runtime receives the settings snapshot. + /// + [Test] + public void LoadFromJsonString_Should_Normalize_Null_GodotLogger_Options() + { + const string json = """ + { + "Logging": { + "GodotLogger": { + "DebugOutputTemplate": null, + "ReleaseOutputTemplate": null, + "Colors": null + } + } + } + """; + + var settings = GodotLoggerSettingsLoader.LoadFromJsonString(json); + + Assert.Multiple(() => + { + Assert.That(settings.Options.DebugOutputTemplate, Is.Not.Null.And.Not.Empty); + Assert.That(settings.Options.ReleaseOutputTemplate, Is.Not.Null.And.Not.Empty); + Assert.That(settings.Options.GetColor(LogLevel.Info), Is.EqualTo("white")); + Assert.That(settings.Options.GetColor(LogLevel.Error), Is.EqualTo("red")); + }); + } + + /// + /// Verifies that numeric JSON log levels must map to defined values. + /// + [Test] + public void LoadFromJsonString_Should_Reject_Invalid_Numeric_LogLevel() + { + const string json = """ + { + "Logging": { + "LogLevel": { + "Default": 999 + } + } + } + """; + + var error = Assert.Throws(() => GodotLoggerSettingsLoader.LoadFromJsonString(json)); + + Assert.That(error?.Message, Does.Contain("Unsupported numeric LogLevel value '999'")); + } + + /// + /// Verifies that cached provider loggers read the latest settings after the provider snapshot changes. + /// + [Test] + public void Provider_Should_Apply_Updated_Settings_To_Existing_Loggers() + { + var settings = new GodotLoggerSettings(new GodotLoggerOptions + { + Mode = GodotLoggerMode.Debug, + DebugMinLevel = LogLevel.Error, + ReleaseMinLevel = LogLevel.Error + }); + var provider = new GodotLoggerFactoryProvider(() => settings); + var logger = provider.CreateLogger("Game.Services.Inventory"); + + Assert.That(logger.IsInfoEnabled(), Is.False); + + settings = new GodotLoggerSettings( + new GodotLoggerOptions + { + Mode = GodotLoggerMode.Debug, + DebugMinLevel = LogLevel.Info, + ReleaseMinLevel = LogLevel.Info + }, + defaultLogLevel: LogLevel.Trace, + loggerLevels: new Dictionary(StringComparer.Ordinal) + { + ["Game.Services"] = LogLevel.Debug + }); + + Assert.Multiple(() => + { + Assert.That(logger.IsInfoEnabled(), Is.True); + Assert.That(logger.IsDebugEnabled(), Is.False); + }); + } + + /// + /// Verifies that caller-supplied structured property keys cannot break Godot log rendering. + /// + [Test] + public void StructuredProperties_Should_Skip_Blank_Keys_And_Trim_Valid_Keys() + { + var formatProperties = typeof(GodotLogger).GetMethod( + "FormatProperties", + BindingFlags.NonPublic | BindingFlags.Static); + var properties = new (string Key, object? Value)[] + { + (null!, "ignored"), + (" ", "ignored"), + (" Player ", 42) + }; + + var result = formatProperties?.Invoke(null, [properties]); + + Assert.That(result, Is.EqualTo(" | Player=42")); + } +} diff --git a/GFramework.Godot/Logging/DeferredLogger.cs b/GFramework.Godot/Logging/DeferredLogger.cs new file mode 100644 index 00000000..35215030 --- /dev/null +++ b/GFramework.Godot/Logging/DeferredLogger.cs @@ -0,0 +1,597 @@ +using System; +using System.Linq; +using System.Threading; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +/// +/// Defers resolving the real Godot logger until the first logging operation needs it. +/// +/// +/// This wrapper allows static logger fields to be created before or +/// runs. The resolved inner logger is published with an atomic compare +/// exchange so concurrent first-use calls converge on one cached instance without relying on the non-atomic +/// null-coalescing assignment pattern. +/// +/// The category passed to the provider when the real logger is first needed. +/// The accessor that returns the current provider at first use. +internal sealed class DeferredLogger(string category, Func providerAccessor) : IStructuredLogger +{ + private ILogger? _inner; + + /// + /// Gets the resolved inner logger, creating and atomically publishing it on first use. + /// + /// + /// The property is intentionally the single resolution gate so all delegated members share the same thread-safe + /// lazy initialization behavior. + /// + private ILogger Inner + { + get + { + var current = Volatile.Read(ref _inner); + if (current != null) + { + return current; + } + + var createdLogger = ResolveLogger(); + + // Multiple callers can resolve concurrently; only one publishes the cached reference. + return Interlocked.CompareExchange(ref _inner, createdLogger, null) ?? createdLogger; + } + } + + /// + /// Gets the category name reported by the resolved logger. + /// + /// The logger category name. + public string Name() + { + return Inner.Name(); + } + + /// + /// Returns whether trace messages are enabled by the current provider settings. + /// + /// true when trace messages should be emitted; otherwise false. + public bool IsTraceEnabled() + { + return Inner.IsTraceEnabled(); + } + + /// + /// Returns whether debug messages are enabled by the current provider settings. + /// + /// true when debug messages should be emitted; otherwise false. + public bool IsDebugEnabled() + { + return Inner.IsDebugEnabled(); + } + + /// + /// Returns whether informational messages are enabled by the current provider settings. + /// + /// true when informational messages should be emitted; otherwise false. + public bool IsInfoEnabled() + { + return Inner.IsInfoEnabled(); + } + + /// + /// Returns whether warning messages are enabled by the current provider settings. + /// + /// true when warning messages should be emitted; otherwise false. + public bool IsWarnEnabled() + { + return Inner.IsWarnEnabled(); + } + + /// + /// Returns whether error messages are enabled by the current provider settings. + /// + /// true when error messages should be emitted; otherwise false. + public bool IsErrorEnabled() + { + return Inner.IsErrorEnabled(); + } + + /// + /// Returns whether fatal messages are enabled by the current provider settings. + /// + /// true when fatal messages should be emitted; otherwise false. + public bool IsFatalEnabled() + { + return Inner.IsFatalEnabled(); + } + + /// + /// Returns whether the specified log level is enabled by the current provider settings. + /// + /// The level to check. + /// true when the level should be emitted; otherwise false. + public bool IsEnabledForLevel(LogLevel level) + { + return Inner.IsEnabledForLevel(level); + } + + /// + /// Writes a trace message through the resolved logger. + /// + /// The message to write. + public void Trace(string msg) + { + Inner.Trace(msg); + } + + /// + /// Writes a formatted trace message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + public void Trace(string format, object arg) + { + Inner.Trace(format, arg); + } + + /// + /// Writes a formatted trace message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + /// The second format argument. + public void Trace(string format, object arg1, object arg2) + { + Inner.Trace(format, arg1, arg2); + } + + /// + /// Writes a formatted trace message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The format arguments. + public void Trace(string format, params object[] arguments) + { + Inner.Trace(format, arguments); + } + + /// + /// Writes a trace message and exception through the resolved logger. + /// + /// The message to write. + /// The exception to attach. + public void Trace(string msg, Exception t) + { + Inner.Trace(msg, t); + } + + /// + /// Writes a debug message through the resolved logger. + /// + /// The message to write. + public void Debug(string msg) + { + Inner.Debug(msg); + } + + /// + /// Writes a formatted debug message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + public void Debug(string format, object arg) + { + Inner.Debug(format, arg); + } + + /// + /// Writes a formatted debug message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + /// The second format argument. + public void Debug(string format, object arg1, object arg2) + { + Inner.Debug(format, arg1, arg2); + } + + /// + /// Writes a formatted debug message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The format arguments. + public void Debug(string format, params object[] arguments) + { + Inner.Debug(format, arguments); + } + + /// + /// Writes a debug message and exception through the resolved logger. + /// + /// The message to write. + /// The exception to attach. + public void Debug(string msg, Exception t) + { + Inner.Debug(msg, t); + } + + /// + /// Writes an informational message through the resolved logger. + /// + /// The message to write. + public void Info(string msg) + { + Inner.Info(msg); + } + + /// + /// Writes a formatted informational message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + public void Info(string format, object arg) + { + Inner.Info(format, arg); + } + + /// + /// Writes a formatted informational message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + /// The second format argument. + public void Info(string format, object arg1, object arg2) + { + Inner.Info(format, arg1, arg2); + } + + /// + /// Writes a formatted informational message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The format arguments. + public void Info(string format, params object[] arguments) + { + Inner.Info(format, arguments); + } + + /// + /// Writes an informational message and exception through the resolved logger. + /// + /// The message to write. + /// The exception to attach. + public void Info(string msg, Exception t) + { + Inner.Info(msg, t); + } + + /// + /// Writes a warning message through the resolved logger. + /// + /// The message to write. + public void Warn(string msg) + { + Inner.Warn(msg); + } + + /// + /// Writes a formatted warning message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + public void Warn(string format, object arg) + { + Inner.Warn(format, arg); + } + + /// + /// Writes a formatted warning message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + /// The second format argument. + public void Warn(string format, object arg1, object arg2) + { + Inner.Warn(format, arg1, arg2); + } + + /// + /// Writes a formatted warning message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The format arguments. + public void Warn(string format, params object[] arguments) + { + Inner.Warn(format, arguments); + } + + /// + /// Writes a warning message and exception through the resolved logger. + /// + /// The message to write. + /// The exception to attach. + public void Warn(string msg, Exception t) + { + Inner.Warn(msg, t); + } + + /// + /// Writes an error message through the resolved logger. + /// + /// The message to write. + public void Error(string msg) + { + Inner.Error(msg); + } + + /// + /// Writes a formatted error message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + public void Error(string format, object arg) + { + Inner.Error(format, arg); + } + + /// + /// Writes a formatted error message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + /// The second format argument. + public void Error(string format, object arg1, object arg2) + { + Inner.Error(format, arg1, arg2); + } + + /// + /// Writes a formatted error message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The format arguments. + public void Error(string format, params object[] arguments) + { + Inner.Error(format, arguments); + } + + /// + /// Writes an error message and exception through the resolved logger. + /// + /// The message to write. + /// The exception to attach. + public void Error(string msg, Exception t) + { + Inner.Error(msg, t); + } + + /// + /// Writes a fatal message through the resolved logger. + /// + /// The message to write. + public void Fatal(string msg) + { + Inner.Fatal(msg); + } + + /// + /// Writes a formatted fatal message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + public void Fatal(string format, object arg) + { + Inner.Fatal(format, arg); + } + + /// + /// Writes a formatted fatal message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The first format argument. + /// The second format argument. + public void Fatal(string format, object arg1, object arg2) + { + Inner.Fatal(format, arg1, arg2); + } + + /// + /// Writes a formatted fatal message through the resolved logger. + /// + /// The format string interpreted by the resolved logger. + /// The format arguments. + public void Fatal(string format, params object[] arguments) + { + Inner.Fatal(format, arguments); + } + + /// + /// Writes a fatal message and exception through the resolved logger. + /// + /// The message to write. + /// The exception to attach. + public void Fatal(string msg, Exception t) + { + Inner.Fatal(msg, t); + } + + /// + /// Writes a message at the specified level through the resolved logger. + /// + /// The level to write. + /// The message to write. + public void Log(LogLevel level, string message) + { + LogFallback(level, message, exception: null); + } + + /// + /// Writes a formatted message at the specified level while preserving deferred formatting semantics. + /// + /// The level to write. + /// The format string interpreted by the resolved logger. + /// The first format argument. + public void Log(LogLevel level, string format, object arg) + { + Inner.Log(level, format, arg); + } + + /// + /// Writes a formatted message at the specified level while preserving deferred formatting semantics. + /// + /// The level to write. + /// The format string interpreted by the resolved logger. + /// The first format argument. + /// The second format argument. + public void Log(LogLevel level, string format, object arg1, object arg2) + { + Inner.Log(level, format, arg1, arg2); + } + + /// + /// Writes a formatted message at the specified level while preserving deferred formatting semantics. + /// + /// The level to write. + /// The format string interpreted by the resolved logger. + /// The format arguments. + public void Log(LogLevel level, string format, params object[] arguments) + { + Inner.Log(level, format, arguments); + } + + /// + /// Writes a message and exception at the specified level through the resolved logger. + /// + /// The level to write. + /// The message to write. + /// The exception to attach. + public void Log(LogLevel level, string message, Exception exception) + { + LogFallback(level, message, exception); + } + + /// + /// Writes a structured message through the resolved logger when it supports structured properties. + /// + /// The level to write. + /// The message to write. + /// The structured properties to attach. + public void Log(LogLevel level, string message, params (string Key, object? Value)[] properties) + { + if (Inner is IStructuredLogger structuredLogger) + { + structuredLogger.Log(level, message, properties); + return; + } + + LogFallback(level, message, exception: null, properties); + } + + /// + /// Writes a structured message and exception through the resolved logger when it supports structured properties. + /// + /// The level to write. + /// The message to write. + /// The optional exception to attach. + /// The structured properties to attach. + public void Log(LogLevel level, string message, Exception? exception, params (string Key, object? Value)[] properties) + { + if (Inner is IStructuredLogger structuredLogger) + { + structuredLogger.Log(level, message, exception, properties); + return; + } + + LogFallback(level, message, exception, properties); + } + + /// + /// Resolves the real logger from the current provider for the deferred category. + /// + /// The logger created by the current provider. + private ILogger ResolveLogger() + { + return providerAccessor().CreateLogger(category); + } + + /// + /// Routes a message through the non-structured logger surface when the resolved logger lacks structured support. + /// + /// The level to write. + /// The message to write. + /// The optional exception to attach. + /// The structured properties rendered into a suffix for fallback loggers. + /// Thrown when is not supported. + private void LogFallback( + LogLevel level, + string message, + Exception? exception, + params (string Key, object? Value)[] properties) + { + var suffix = properties.Length == 0 + ? string.Empty + : " | " + string.Join(", ", properties.Select(static property => $"{property.Key}={property.Value}")); + var rendered = message + suffix; + + switch (level) + { + case LogLevel.Trace: + WriteFallback(rendered, exception, Inner.Trace, Inner.Trace); + break; + case LogLevel.Debug: + WriteFallback(rendered, exception, Inner.Debug, Inner.Debug); + break; + case LogLevel.Info: + WriteFallback(rendered, exception, Inner.Info, Inner.Info); + break; + case LogLevel.Warning: + WriteFallback(rendered, exception, Inner.Warn, Inner.Warn); + break; + case LogLevel.Error: + WriteFallback(rendered, exception, Inner.Error, Inner.Error); + break; + case LogLevel.Fatal: + WriteFallback(rendered, exception, Inner.Fatal, Inner.Fatal); + break; + default: + throw new ArgumentOutOfRangeException(nameof(level), level, "Unsupported log level."); + } + } + + /// + /// Routes a non-structured message through the fallback path. + /// + /// The level to write. + /// The message to write. + /// The optional exception to attach. + /// Thrown when is not supported. + private void LogFallback(LogLevel level, string message, Exception? exception) + { + LogFallback(level, message, exception, []); + } + + /// + /// Chooses the message-only or exception-aware write delegate for fallback logging. + /// + /// The rendered fallback message. + /// The optional exception to attach. + /// The delegate used when no exception is present. + /// The delegate used when an exception is present. + private static void WriteFallback( + string message, + Exception? exception, + Action writeMessage, + Action writeException) + { + if (exception == null) + { + writeMessage(message); + } + else + { + writeException(message, exception); + } + } +} diff --git a/GFramework.Godot/Logging/GodotLog.cs b/GFramework.Godot/Logging/GodotLog.cs new file mode 100644 index 00000000..f305168a --- /dev/null +++ b/GFramework.Godot/Logging/GodotLog.cs @@ -0,0 +1,127 @@ +using System; +using System.Threading; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +/// +/// Static Godot logging entry point with auto-discovered configuration and deferred logger creation. +/// +public static class GodotLog +{ +#if NET9_0_OR_GREATER + private static readonly System.Threading.Lock ConfigureLock = new(); +#else + private static readonly object ConfigureLock = new(); +#endif + private static Action? _configure; + + private static readonly Lazy LazyConfigurationSource = new( + CreateConfigurationSource, + LazyThreadSafetyMode.ExecutionAndPublication); + + private static readonly Lazy LazyProvider = new( + static () => new GodotLoggerFactoryProvider(() => LazyConfigurationSource.Value.CurrentSettings), + LazyThreadSafetyMode.ExecutionAndPublication); + + /// + /// Applies imperative option overrides before the global Godot logger provider is materialized. + /// + /// The options mutator. + public static void Configure(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + + lock (ConfigureLock) + { + if (LazyProvider.IsValueCreated || LazyConfigurationSource.IsValueCreated) + { + throw new InvalidOperationException( + "GodotLog.Configure must be called before any GodotLog provider or configuration source is materialized."); + } + + _configure = configure; + } + } + + /// + /// Gets the lazily-configured Godot logger provider. + /// + public static ILoggerFactoryProvider Provider + { + get + { + lock (ConfigureLock) + { + return LazyProvider.Value; + } + } + } + + /// + /// Gets the discovered configuration file path, if any, without materializing the global configuration source. + /// + /// + /// This property is safe for diagnostics before runs. When the source is not created + /// yet, it performs discovery directly instead of touching LazyConfigurationSource.Value, so callers do + /// not accidentally lock in the default options before configuring . + /// + public static string? ConfigurationPath => LazyConfigurationSource.IsValueCreated + ? LazyConfigurationSource.Value.ConfigurationPath + : GodotLoggerSettingsLoader.DiscoverConfigurationPath(); + + /// + /// Creates a logger for the specified category without materializing the provider until first use. + /// + public static ILogger CreateLogger(string category) + { + ArgumentException.ThrowIfNullOrWhiteSpace(category); + return new DeferredLogger(category, static () => Provider); + } + + /// + /// Creates a logger for the specified type without materializing the provider until first use. + /// + public static ILogger CreateLogger() + { + return CreateLogger(GetCategoryName(typeof(T))); + } + + /// + /// Installs the Godot provider as the current global resolver provider. + /// + public static void UseAsDefaultProvider() + { + LoggerFactoryResolver.Provider = Provider; + } + + /// + /// Stops the file watcher owned by the materialized configuration source, if the source has been created. + /// + /// + /// Godot hosts often keep process-wide logging for the whole game lifetime. Dedicated servers and tests can call + /// this method during teardown to release the watcher handle deterministically. The static lazy source is not + /// reset; later logger usage continues with the last published settings snapshot but no longer receives reload + /// notifications from the disposed watcher. + /// + public static void Shutdown() + { + if (LazyConfigurationSource.IsValueCreated) + { + LazyConfigurationSource.Value.Dispose(); + } + } + + private static GodotLogConfigurationSource CreateConfigurationSource() + { + lock (ConfigureLock) + { + return new GodotLogConfigurationSource(_configure); + } + } + + private static string GetCategoryName(Type type) + { + return type.FullName ?? type.Name; + } +} diff --git a/GFramework.Godot/Logging/GodotLogConfigurationSource.cs b/GFramework.Godot/Logging/GodotLogConfigurationSource.cs new file mode 100644 index 00000000..7ee68d25 --- /dev/null +++ b/GFramework.Godot/Logging/GodotLogConfigurationSource.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +/// +/// Owns discovery, loading, hot reload, and publication of the current Godot logger settings snapshot. +/// +/// +/// Construction follows a fixed lifecycle: discover the configuration path, perform an initial strict load, then +/// subscribe a when a concrete file exists. is +/// published through so cached loggers can read a last-good immutable snapshot without +/// locking. Hot reload keeps the previous settings when a transient parse or file-system error occurs. +/// +internal sealed class GodotLogConfigurationSource : IDisposable +{ + private readonly Action? _configure; + private readonly FileSystemWatcher? _watcher; + private GodotLoggerSettings _currentSettings = GodotLoggerSettings.Default; + + /// + /// Initializes the configuration source and starts watching the discovered file when one is available. + /// + /// Optional imperative option overrides applied after file settings are loaded. + /// Thrown during initial loading when the configuration file cannot be read. + /// Thrown during initial loading when the configuration file is locked. + /// + /// Initial loading uses retry/backoff and propagates the final error because startup configuration failures should + /// be visible. Watcher callbacks use the hot-reload path and preserve the previous snapshot on failure. + /// + public GodotLogConfigurationSource(Action? configure) + { + _configure = configure; + + // Discovery is done before the first strict reload so startup reports invalid files immediately. + ConfigurationPath = GodotLoggerSettingsLoader.DiscoverConfigurationPath(); + Reload(throwOnError: true); + _watcher = CreateWatcher(ConfigurationPath); + } + + /// + /// Gets the discovered configuration file path, or null when no supported location contains a file. + /// + public string? ConfigurationPath { get; } + + /// + /// Gets the last successfully loaded settings snapshot. + /// + /// + /// The snapshot is read through Volatile.Read so logger instances running on other + /// threads observe settings published by reload callbacks without taking the configuration lock. + /// + public GodotLoggerSettings CurrentSettings => Volatile.Read(ref _currentSettings); + + /// + /// Stops the file watcher before the source is abandoned. + /// + /// + /// Disposal does not clear ; existing loggers can continue using the last published + /// snapshot after watcher notifications have been stopped. + /// + public void Dispose() + { + _watcher?.Dispose(); + } + + /// + /// Creates the watcher that drives hot reload for the discovered configuration file. + /// + /// The configuration file to watch. + /// A configured watcher, or null when no stable directory and file name can be resolved. + private FileSystemWatcher? CreateWatcher(string? configurationPath) + { + if (string.IsNullOrWhiteSpace(configurationPath)) + { + return null; + } + + var directory = Path.GetDirectoryName(configurationPath); + var fileName = Path.GetFileName(configurationPath); + if (string.IsNullOrWhiteSpace(directory) || string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + // FileSystemWatcher raises callbacks on thread-pool threads; callbacks keep reload work short and non-blocking. + var watcher = new FileSystemWatcher(directory, fileName) + { + EnableRaisingEvents = true, + NotifyFilter = NotifyFilters.CreationTime + | NotifyFilters.FileName + | NotifyFilters.LastWrite + | NotifyFilters.Size + }; + + watcher.Changed += OnConfigurationChanged; + watcher.Created += OnConfigurationChanged; + watcher.Deleted += OnConfigurationChanged; + watcher.Renamed += OnConfigurationRenamed; + return watcher; + } + + private void OnConfigurationChanged(object sender, FileSystemEventArgs e) + { + Reload(throwOnError: false); + } + + private void OnConfigurationRenamed(object sender, RenamedEventArgs e) + { + Reload(throwOnError: false); + } + + /// + /// Reloads settings and publishes them when loading succeeds. + /// + /// Whether load errors should escape to the caller. + private void Reload(bool throwOnError) + { + try + { + var settings = throwOnError ? LoadSettingsWithRetry() : LoadSettings(); + + // Volatile publication gives cached loggers a coherent replacement snapshot without per-log locks. + Volatile.Write(ref _currentSettings, settings); + } + catch when (!throwOnError) + { + // Ignore transient parse or file-lock failures during hot reload and keep the last good snapshot. + } + } + + /// + /// Loads settings with short retry/backoff for startup races with file writers or deployment tools. + /// + /// The loaded settings snapshot. + /// Thrown when no retry produced a usable settings snapshot. + private GodotLoggerSettings LoadSettingsWithRetry() + { + Exception? lastError = null; + + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + return LoadSettings(); + } + catch (IOException ex) + { + lastError = ex; + } + catch (UnauthorizedAccessException ex) + { + lastError = ex; + } + + if (attempt < 2) + { + // Startup can race with a writer finishing appsettings.json; keep the retry bounded and deterministic. + Thread.Sleep(50); + } + } + + throw lastError ?? new InvalidOperationException("Failed to load Godot logging configuration."); + } + + /// + /// Loads settings from disk or defaults, then applies imperative overrides. + /// + /// The settings snapshot to publish. + private GodotLoggerSettings LoadSettings() + { + var settings = string.IsNullOrWhiteSpace(ConfigurationPath) || !File.Exists(ConfigurationPath) + ? GodotLoggerSettings.Default + : GodotLoggerSettingsLoader.LoadFromJsonFile(ConfigurationPath); + + if (_configure == null) + { + return settings; + } + + var configuredOptions = CloneOptions(settings.Options); + _configure(configuredOptions); + return new GodotLoggerSettings( + configuredOptions.CreateNormalizedCopy(), + settings.DefaultLogLevel, + CopyLoggerLevels(settings)); + } + + /// + /// Creates a mutable options copy before user overrides are applied. + /// + /// The options from the file or default settings. + /// A normalized mutable copy. + private static GodotLoggerOptions CloneOptions(GodotLoggerOptions options) + { + return new GodotLoggerOptions + { + Mode = options.Mode, + DebugMinLevel = options.DebugMinLevel, + ReleaseMinLevel = options.ReleaseMinLevel, + DebugOutputTemplate = options.DebugOutputTemplate, + ReleaseOutputTemplate = options.ReleaseOutputTemplate, + Colors = options.Colors is { } colors + ? new Dictionary(colors) + : [] + }.CreateNormalizedCopy(); + } + + /// + /// Copies category log level overrides into an ordinal dictionary. + /// + /// The source settings snapshot. + /// A copy that preserves exact and prefix matching semantics. + private static IReadOnlyDictionary CopyLoggerLevels( + GodotLoggerSettings settings) + { + var levels = new Dictionary(StringComparer.Ordinal); + foreach (var pair in settings.LoggerLevels) + { + levels[pair.Key] = pair.Value; + } + + return levels; + } +} diff --git a/GFramework.Godot/Logging/GodotLogRenderContext.cs b/GFramework.Godot/Logging/GodotLogRenderContext.cs new file mode 100644 index 00000000..095706cc --- /dev/null +++ b/GFramework.Godot/Logging/GodotLogRenderContext.cs @@ -0,0 +1,21 @@ +using System; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +/// +/// Carries the already-resolved values that needs to render one log line. +/// +/// The UTC timestamp assigned to the log entry. +/// The severity level used for filtering, formatting, and Godot debug routing. +/// The source logger category name. +/// The formatted log message body. +/// The Godot BBCode color name resolved for . +/// The preformatted structured property suffix, or an empty string when none exist. +internal readonly record struct GodotLogRenderContext( + DateTime Timestamp, + LogLevel Level, + string Category, + string Message, + string Color, + string Properties); diff --git a/GFramework.Godot/Logging/GodotLogTemplate.cs b/GFramework.Godot/Logging/GodotLogTemplate.cs new file mode 100644 index 00000000..5bc147a2 --- /dev/null +++ b/GFramework.Godot/Logging/GodotLogTemplate.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Threading; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +/// +/// Parses and renders Godot logger output templates. +/// +/// +/// Supported placeholders include {timestamp}, {timestamp:format}, {level}, +/// {level:u3}, {level:l3}, {level:padded}, {category}, +/// {category:lN}, {category:rN}, {color}, {message}, and +/// {properties}. Unknown placeholders are rendered back as {key} so configuration mistakes stay +/// visible instead of silently deleting text. Parsed templates and category formatting results use bounded +/// concurrent caches to avoid unbounded growth across hot reloads or dynamic category names. +/// +internal sealed class GodotLogTemplate +{ + /// + /// Caches parsed template instances by the raw template text. + /// + /// + /// The cache is process-wide because templates are immutable after parsing. It is bounded so repeated hot reloads + /// with unique template strings cannot grow memory without limit. + /// + private static readonly BoundedCache Cache = new(maxEntries: 256); + + /// + /// Caches formatted category names for this template instance. + /// + /// + /// Category formatting depends on the template segment and category name. The per-template cache is bounded to + /// protect long-running hosts that create loggers with dynamic category names. + /// + private readonly BoundedCache _categoryCache = new(maxEntries: 1024); + private readonly int _literalLength; + private readonly Action[] _segments; + + private GodotLogTemplate(string template) + { + (_segments, _literalLength) = ParseCore(template); + } + + /// + /// Parses or retrieves a cached template. + /// + /// The template text. + /// An immutable parsed template. + public static GodotLogTemplate Parse(string template) + { + ArgumentNullException.ThrowIfNull(template); + return Cache.GetOrAdd(template, () => new GodotLogTemplate(template)); + } + + /// + /// Renders the template against a concrete log context. + /// + /// The resolved values for the log entry. + /// The rendered Godot log line. + 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(); + } + + /// + /// Converts template text into literal and placeholder render segments. + /// + /// The template text to parse. + /// The render segments and total literal length used to size the output builder. + private (Action[] Segments, int LiteralLength) ParseCore(string template) + { + var segments = new List>(); + var literalLength = 0; + var position = 0; + + while (position < template.Length) + { + // The parser is deliberately small: scan literal runs, then turn balanced placeholders into delegates. + 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; + // Capturing the literal string once avoids reparsing or slicing it on each rendered log entry. + segments.Add((builder, _) => builder.Append(literal)); + } + } + + /// + /// Creates the render delegate for one placeholder key. + /// + /// The placeholder name and optional format suffix. + /// A delegate that appends the placeholder value. + 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), + "properties" => static (builder, context) => builder.Append(context.Properties), + "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..]), + // Preserve unknown placeholders so configuration errors are visible in the rendered log line. + _ => (builder, _) => builder.Append('{').Append(key).Append('}') + }; + } + + /// + /// Creates the render delegate for a timestamp placeholder. + /// + /// The optional .NET timestamp format. + /// A delegate that appends the formatted timestamp using invariant culture. + 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)); + } + + /// + /// Creates the render delegate for a level placeholder. + /// + /// The level format, such as u3, l3, or padded. + /// A delegate that appends the formatted level. + 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) + }; + } + + /// + /// Creates the render delegate for a category placeholder. + /// + /// The category alignment format, such as l16 or r32. + /// A delegate that appends the category with optional abbreviation and padding. + 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)); + } + + /// + /// Formats and caches one category for a category alignment segment. + /// + /// The full category name. + /// The original segment format used as part of the cache key. + /// The desired category width. + /// Whether the result is left-padded instead of right-padded. + /// The abbreviated and padded category string. + private string GetFormattedCategory(string category, string format, int width, bool padLeft) + { + // Include the format in the key because the same category can render differently per width and alignment. + var cacheKey = string.Concat(format, "\0", category); + return _categoryCache.GetOrAdd(cacheKey, () => + { + var abbreviated = AbbreviateCategory(category, width); + return padLeft ? abbreviated.PadLeft(width) : abbreviated.PadRight(width); + }); + } + + /// + /// Abbreviates dotted category names to fit a target width. + /// + /// The category to abbreviate. + /// The maximum rendered length. + /// The category shortened by initials, dropped prefixes, or final-segment truncation. + 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++) + { + // Collapse namespace-like prefixes first so the most specific final segment remains readable. + 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; + } + + /// + /// Converts a level to its three-character form. + /// + /// The level to format. + /// Whether the result should use uppercase letters. + /// A three-character level label, or unk for undefined enum values. + 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; + } + + /// + /// Converts a level to the fixed-width historical Godot logger label. + /// + /// The level to format. + /// A padded level label, or output for undefined enum values. + 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() + }; + } + + private sealed class BoundedCache + { + private readonly ConcurrentDictionary> _entries = new(StringComparer.Ordinal); + private readonly int _maxEntries; + private long _sequence; + + internal BoundedCache(int maxEntries) + { + _maxEntries = maxEntries; + } + + internal TValue GetOrAdd(string key, Func valueFactory) + { + if (_entries.TryGetValue(key, out var existing)) + { + return existing.Value; + } + + var created = new CacheEntry(valueFactory(), Interlocked.Increment(ref _sequence)); + var stored = _entries.GetOrAdd(key, created); + if (stored.Sequence == created.Sequence) + { + Trim(); + } + + return stored.Value; + } + + private void Trim() + { + while (_entries.Count > _maxEntries) + { + var oldestKey = string.Empty; + var oldestSequence = long.MaxValue; + + foreach (var pair in _entries) + { + if (pair.Value.Sequence >= oldestSequence) + { + continue; + } + + oldestKey = pair.Key; + oldestSequence = pair.Value.Sequence; + } + + if (oldestSequence == long.MaxValue || !_entries.TryRemove(oldestKey, out _)) + { + break; + } + } + } + } + + private readonly record struct CacheEntry(TValue Value, long Sequence); +} diff --git a/GFramework.Godot/Logging/GodotLogger.cs b/GFramework.Godot/Logging/GodotLogger.cs index e0fc2948..d6dbe974 100644 --- a/GFramework.Godot/Logging/GodotLogger.cs +++ b/GFramework.Godot/Logging/GodotLogger.cs @@ -1,4 +1,7 @@ -using System.Globalization; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; using GFramework.Core.Abstractions.Logging; using GFramework.Core.Logging; using Godot; @@ -6,69 +9,215 @@ 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 Func _optionsProvider; /// - /// 写入日志的核心方法。 - /// 格式化日志消息并根据日志级别调用 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 ?? RootLoggerName, + CreateFixedOptionsProvider(minLevel), + () => minLevel) + { + } + + /// + /// Initializes a logger with Godot-specific formatting options. + /// + /// The logger name. + /// The logger options. + public GodotLogger(string? name, GodotLoggerOptions options) + : this( + name ?? RootLoggerName, + CreateOptionsProvider(options), + CreateMinLevelProvider(options)) + { + } + + /// + /// Initializes the core logger with dynamic options and level providers. + /// + /// The resolved logger name used in rendered output. + /// The provider that supplies the latest rendering options for each write. + /// The provider that supplies the latest effective minimum level. + /// + /// The Godot factory uses this constructor so cached logger instances can observe hot-reloaded settings without + /// being recreated. The default public constructor supplies a fixed provider to avoid allocation on the log path. + /// + internal GodotLogger( + string name, + Func optionsProvider, + Func minLevelProvider) + : base(name, minLevelProvider ?? throw new ArgumentNullException(nameof(minLevelProvider))) + { + _optionsProvider = optionsProvider ?? throw new ArgumentNullException(nameof(optionsProvider)); + } + + /// + /// 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()}]"; + WriteEntry(level, message, exception, properties: null); + } - // 添加异常信息到日志消息中 - if (exception != null) message += "\n" + exception; + /// + /// Uses Godot-aware structured rendering instead of the base string concatenation fallback. + /// + public override void Log(LogLevel level, string message, params (string Key, object? Value)[] properties) + { + if (!IsEnabled(level)) + { + return; + } - var logMessage = $"{logPrefix} {message}"; + WriteEntry(level, message, exception: null, properties); + } + + /// + /// Uses Godot-aware structured rendering instead of the base string concatenation fallback. + /// + public override void Log( + LogLevel level, + string message, + Exception? exception, + params (string Key, object? Value)[] properties) + { + if (!IsEnabled(level)) + { + return; + } + + WriteEntry(level, message, exception, properties); + } + + private void WriteEntry( + LogLevel level, + string message, + Exception? exception, + (string Key, object? Value)[]? properties) + { + var options = _optionsProvider(); + var templateText = options.Mode == GodotLoggerMode.Debug + ? options.DebugOutputTemplate + : options.ReleaseOutputTemplate; + var context = new GodotLogRenderContext( + DateTime.UtcNow, + level, + Name(), + message, + options.GetColor(level), + FormatProperties(properties)); + var rendered = GodotLogTemplate.Parse(templateText).Render(context); + + if (options.Mode == GodotLoggerMode.Debug) + { + WriteDebug(level, rendered); + } + else + { + GD.Print(rendered); + } + + if (exception != null) + { + GD.PrintErr(exception.ToString()); + } + } + + private static string FormatProperties((string Key, object? Value)[]? properties) + { + var merged = MergeProperties(properties); + if (merged.Count == 0) + { + return string.Empty; + } + + return " | " + string.Join(", ", merged.Select(static pair => $"{pair.Key}={FormatValue(pair.Value)}")); + } + + private static IReadOnlyDictionary MergeProperties((string Key, object? Value)[]? properties) + { + var contextProperties = LogContext.Current; + if ((properties == null || properties.Length == 0) && contextProperties.Count == 0) + { + return EmptyProperties; + } + + var merged = new Dictionary(contextProperties, StringComparer.Ordinal); + if (properties != null) + { + foreach (var property in properties) + { + if (string.IsNullOrWhiteSpace(property.Key)) + { + continue; + } + + merged[property.Key.Trim()] = property.Value; + } + } + + return merged; + } + + private static readonly IReadOnlyDictionary EmptyProperties = + new Dictionary(StringComparer.Ordinal); + + private static Func CreateFixedOptionsProvider(LogLevel minLevel) + { + var options = GodotLoggerOptions.ForMinimumLevel(minLevel); + return () => options; + } + + private static Func CreateOptionsProvider(GodotLoggerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return () => options; + } + + private static Func CreateMinLevelProvider(GodotLoggerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + return () => options.GetEffectiveMinLevel(); + } + + private static string FormatValue(object? value) + { + if (value == null) + { + return "null"; + } + + return value switch + { + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), + _ => value.ToString() ?? string.Empty + }; + } + + 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..5dfb48fa 100644 --- a/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs +++ b/GFramework.Godot/Logging/GodotLoggerFactoryProvider.cs @@ -1,35 +1,76 @@ -using GFramework.Core.Abstractions.Logging; -using GFramework.Core.Logging; +using System; +using System.Collections.Concurrent; +using GFramework.Core.Abstractions.Logging; namespace GFramework.Godot.Logging; /// -/// Godot日志工厂提供程序,用于创建Godot日志记录器实例 +/// Provides cached Godot logger instances. /// public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider { - private readonly ILoggerFactory _cachedFactory; + private readonly ConcurrentDictionary _loggers = new(StringComparer.Ordinal); + private readonly Func _settingsProvider; /// - /// 初始化Godot日志记录器工厂提供程序 + /// Initializes a Godot logger provider with the default logger factory. /// public GodotLoggerFactoryProvider() + : this(static () => GodotLoggerSettings.Default) { - _cachedFactory = new CachedLoggerFactory(new GodotLoggerFactory()); } /// - /// 获取或设置最小日志级别 + /// Initializes a Godot logger provider with Godot-specific formatting options. + /// + /// The logger options. + public GodotLoggerFactoryProvider(GodotLoggerOptions options) + : this(CreateStaticSettingsProvider(options)) + { + } + + internal GodotLoggerFactoryProvider(Func settingsProvider) + { + _settingsProvider = settingsProvider ?? throw new ArgumentNullException(nameof(settingsProvider)); + } + + /// + /// 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); + ArgumentNullException.ThrowIfNull(name); + + return _loggers.GetOrAdd( + name, + static (loggerName, provider) => new GodotLogger( + loggerName, + provider.GetOptions, + () => provider.GetEffectiveMinLevel(loggerName)), + this); } -} \ No newline at end of file + + private GodotLoggerOptions GetOptions() + { + return _settingsProvider().Options; + } + + private LogLevel GetEffectiveMinLevel(string categoryName) + { + return _settingsProvider().GetEffectiveMinLevel(categoryName, MinLevel); + } + + private static Func CreateStaticSettingsProvider(GodotLoggerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + var settings = GodotLoggerSettings.FromOptions(options); + return () => settings; + } +} 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..25be0962 --- /dev/null +++ b/GFramework.Godot/Logging/GodotLoggerOptions.cs @@ -0,0 +1,182 @@ +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}{properties}"; +#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}{properties}"; +#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}{properties}", + ReleaseOutputTemplate = "[{timestamp:yyyy-MM-dd HH:mm:ss.fff}] {level:padded} [{category}] {message}{properties}", + 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 is { } colors && colors.TryGetValue(level, out var color) && !string.IsNullOrWhiteSpace(color)) + { + return color; + } + + return DefaultColors.TryGetValue(level, out var fallback) ? fallback : "white"; + } + + /// + /// Gets the active minimum level for the current . + /// + /// + /// when is ; otherwise + /// . + /// + /// + /// Factories use this value as the option-level floor before category-specific settings are applied. + /// + internal LogLevel GetEffectiveMinLevel() + { + return Mode == GodotLoggerMode.Debug ? DebugMinLevel : ReleaseMinLevel; + } + + /// + /// Creates a copy whose debug and release floors are at least . + /// + /// The minimum level that both mode-specific floors must satisfy. + /// A normalized copy with stricter or equal mode-specific minimum levels. + /// + /// The operation can raise and through + /// , but it never lowers them. , + /// , and are preserved through a defensive copy. + /// + internal GodotLoggerOptions WithMinimumLevelFloor(LogLevel minLevel) + { + return new GodotLoggerOptions + { + Mode = Mode, + DebugMinLevel = Max(DebugMinLevel, minLevel), + ReleaseMinLevel = Max(ReleaseMinLevel, minLevel), + DebugOutputTemplate = DebugOutputTemplate, + ReleaseOutputTemplate = ReleaseOutputTemplate, + Colors = CopyColorsWithDefaults(Colors) + }; + } + + /// + /// Creates a copy that replaces missing templates or color mappings with safe defaults. + /// + /// A normalized copy suitable for runtime rendering. + /// + /// JSON input can set , , or + /// to null even though the public API treats them as non-null. This method keeps + /// deserialization and imperative configuration from publishing values that would fail during rendering. + /// + internal GodotLoggerOptions CreateNormalizedCopy() + { + var defaults = new GodotLoggerOptions(); + + return new GodotLoggerOptions + { + Mode = Mode, + DebugMinLevel = DebugMinLevel, + ReleaseMinLevel = ReleaseMinLevel, + DebugOutputTemplate = string.IsNullOrWhiteSpace(DebugOutputTemplate) + ? defaults.DebugOutputTemplate + : DebugOutputTemplate, + ReleaseOutputTemplate = string.IsNullOrWhiteSpace(ReleaseOutputTemplate) + ? defaults.ReleaseOutputTemplate + : ReleaseOutputTemplate, + Colors = CopyColorsWithDefaults(Colors) + }; + } + + private static LogLevel Max(LogLevel left, LogLevel right) + { + return left > right ? left : right; + } + + private static Dictionary CopyColorsWithDefaults(Dictionary? colors) + { + var merged = new Dictionary(DefaultColors); + if (colors == null) + { + return merged; + } + + foreach (var pair in colors) + { + if (!string.IsNullOrWhiteSpace(pair.Value)) + { + merged[pair.Key] = pair.Value; + } + } + + return merged; + } +} diff --git a/GFramework.Godot/Logging/GodotLoggerSettings.cs b/GFramework.Godot/Logging/GodotLoggerSettings.cs new file mode 100644 index 00000000..e95962fe --- /dev/null +++ b/GFramework.Godot/Logging/GodotLoggerSettings.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using GFramework.Core.Abstractions.Logging; + +namespace GFramework.Godot.Logging; + +/// +/// Represents one immutable Godot logger configuration snapshot. +/// +/// +/// A snapshot combines mode-specific , an optional default log level, and category overrides. +/// Category matching is ordinal and deterministic: exact matches win first, then the longest dotted prefix such as +/// Game.Services for Game.Services.Inventory, and finally is used when +/// present. +/// +internal sealed class GodotLoggerSettings +{ + private readonly IReadOnlyDictionary _loggerLevels; + + /// + /// Gets the default settings snapshot used when no configuration file is available. + /// + public static GodotLoggerSettings Default { get; } = new(new GodotLoggerOptions()); + + /// + /// Creates a settings snapshot from normalized options and optional category thresholds. + /// + /// The formatting and mode options for this snapshot. + /// The optional fallback level used when no category override matches. + /// Exact category names or dotted prefixes mapped to minimum levels. + public GodotLoggerSettings( + GodotLoggerOptions options, + LogLevel? defaultLogLevel = null, + IReadOnlyDictionary? loggerLevels = null) + { + Options = (options ?? throw new ArgumentNullException(nameof(options))).CreateNormalizedCopy(); + DefaultLogLevel = defaultLogLevel; + _loggerLevels = loggerLevels ?? new Dictionary(StringComparer.Ordinal); + } + + /// + /// Gets the optional fallback minimum level for categories without exact or prefix overrides. + /// + public LogLevel? DefaultLogLevel { get; } + + /// + /// Gets normalized rendering and mode options for this snapshot. + /// + public GodotLoggerOptions Options { get; } + + /// + /// Gets exact and dotted-prefix category level overrides. + /// + /// + /// Keys are interpreted with semantics. A key only matches a child category + /// when the category starts with the key plus a dot, which prevents Game.Service from matching + /// Game.Services accidentally. + /// + public IReadOnlyDictionary LoggerLevels => _loggerLevels; + + /// + /// Creates a settings snapshot from options without any category overrides. + /// + /// The options to normalize and wrap. + /// A settings snapshot that relies only on the option-level minimum level. + public static GodotLoggerSettings FromOptions(GodotLoggerOptions options) + { + return new GodotLoggerSettings(options); + } + + /// + /// Calculates the effective minimum level for a category. + /// + /// The logger category name. + /// The provider-level floor captured by the logger. + /// The strictest level selected from options, provider floor, and category configuration. + /// + /// The merge starts with and + /// , then applies when it returns a + /// value. is used at each step so configuration can only make a logger + /// stricter, never more verbose than the active floor. + /// + public LogLevel GetEffectiveMinLevel(string categoryName, LogLevel providerMinLevel) + { + ArgumentNullException.ThrowIfNull(categoryName); + + var effective = Max(Options.GetEffectiveMinLevel(), providerMinLevel); + var configuredLevel = GetConfiguredMinLevel(categoryName); + return configuredLevel.HasValue ? Max(effective, configuredLevel.Value) : effective; + } + + /// + /// Finds the configured category level using exact match, longest dotted-prefix match, then default fallback. + /// + /// The category to resolve. + /// The configured level, or null when no default or override applies. + private LogLevel? GetConfiguredMinLevel(string categoryName) + { + // Exact category configuration is the most specific and avoids unnecessary prefix scans. + if (_loggerLevels.TryGetValue(categoryName, out var exactLevel)) + { + return exactLevel; + } + + var bestMatchLength = -1; + LogLevel? bestMatchLevel = DefaultLogLevel; + + foreach (var pair in _loggerLevels) + { + // The dotted boundary keeps sibling categories from matching by raw string prefix alone. + if (!categoryName.StartsWith(pair.Key + ".", StringComparison.Ordinal)) + { + continue; + } + + if (pair.Key.Length <= bestMatchLength) + { + continue; + } + + bestMatchLength = pair.Key.Length; + bestMatchLevel = pair.Value; + } + + return bestMatchLevel; + } + + /// + /// Returns the stricter of two log levels. + /// + /// The first level. + /// The second level. + /// The level with the higher severity ordering. + private static LogLevel Max(LogLevel left, LogLevel right) + { + return left > right ? left : right; + } +} diff --git a/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs b/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs new file mode 100644 index 00000000..d460ba84 --- /dev/null +++ b/GFramework.Godot/Logging/GodotLoggerSettingsLoader.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; +using GFramework.Core.Abstractions.Logging; +using Godot; + +namespace GFramework.Godot.Logging; + +/// +/// Discovers and parses Godot logging configuration documents. +/// +/// +/// The loader treats JSON as external input: enum values are validated, nullable serializer output is normalized, +/// and unsupported values produce clear exceptions before a settings snapshot reaches runtime log rendering. +/// +internal static class GodotLoggerSettingsLoader +{ + /// + /// Names the environment variable that can point to an explicit Godot logging configuration file. + /// + internal const string ConfigEnvironmentVariableName = "GODOT_LOGGER_CONFIG"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + AllowTrailingCommas = true, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + Converters = + { + new GodotLogLevelJsonConverter(), + new JsonStringEnumConverter(JsonNamingPolicy.CamelCase, allowIntegerValues: true) + } + }; + + /// + /// Finds the first supported configuration file location. + /// + /// Optional explicit path used instead of reading the environment variable. + /// Optional process path used when checking the executable directory. + /// Optional resolver for Godot res:// paths. + /// The first existing configuration path, or null when none exists. + public static string? DiscoverConfigurationPath( + string? environmentPath = null, + string? processPath = null, + Func? projectPathResolver = null) + { + var envPath = environmentPath ?? System.Environment.GetEnvironmentVariable(ConfigEnvironmentVariableName); + if (!string.IsNullOrWhiteSpace(envPath) && File.Exists(envPath)) + { + return envPath; + } + + var resolvedProcessPath = processPath ?? System.Environment.ProcessPath; + if (!string.IsNullOrWhiteSpace(resolvedProcessPath)) + { + var executableDirectory = Path.GetDirectoryName(resolvedProcessPath); + if (!string.IsNullOrWhiteSpace(executableDirectory)) + { + var candidate = Path.Combine(executableDirectory, "appsettings.json"); + if (File.Exists(candidate)) + { + return candidate; + } + } + } + + var resolver = projectPathResolver ?? SafeGlobalizeProjectPath; + var projectCandidate = resolver("res://appsettings.json"); + if (!string.IsNullOrWhiteSpace(projectCandidate) && File.Exists(projectCandidate)) + { + return projectCandidate; + } + + return null; + } + + /// + /// Loads a settings snapshot from a JSON file. + /// + /// The configuration file path. + /// The parsed and normalized settings snapshot. + /// Thrown when does not exist. + public static GodotLoggerSettings LoadFromJsonFile(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException($"Configuration file not found: {filePath}", filePath); + } + + return LoadFromJsonString(File.ReadAllText(filePath)); + } + + /// + /// Parses a settings snapshot from a JSON string. + /// + /// The JSON configuration content. + /// The parsed and normalized settings snapshot. + /// Thrown when an unsupported log level or malformed document is encountered. + public static GodotLoggerSettings LoadFromJsonString(string json) + { + ArgumentNullException.ThrowIfNull(json); + + var root = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException("Failed to deserialize Godot logging configuration."); + + var logging = root.Logging; + var options = (logging?.GodotLogger ?? new GodotLoggerOptions()).CreateNormalizedCopy(); + + LogLevel? defaultLogLevel = null; + var loggerLevels = new Dictionary(StringComparer.Ordinal); + + if (logging?.LogLevel != null) + { + foreach (var pair in logging.LogLevel) + { + if (string.Equals(pair.Key, "Default", StringComparison.OrdinalIgnoreCase)) + { + defaultLogLevel = pair.Value; + } + else + { + loggerLevels[pair.Key] = pair.Value; + } + } + } + + return new GodotLoggerSettings(options, defaultLogLevel, loggerLevels); + } + + private static string? SafeGlobalizeProjectPath(string path) + { + try + { + return ProjectSettings.GlobalizePath(path); + } + catch + { + return null; + } + } + + private sealed class GodotLoggerSettingsDocument + { + public LoggingDocument? Logging { get; set; } + } + + private sealed class LoggingDocument + { + public GodotLoggerOptions? GodotLogger { get; set; } + + public Dictionary? LogLevel { get; set; } + } + + private sealed class GodotLogLevelJsonConverter : JsonConverter + { + public override LogLevel Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Number && reader.TryGetInt32(out var numericValue)) + { + if (!Enum.IsDefined(typeof(LogLevel), numericValue)) + { + throw new JsonException( + $"Unsupported numeric {nameof(LogLevel)} value '{numericValue}'. Expected a defined {nameof(LogLevel)} value."); + } + + return (LogLevel)numericValue; + } + + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException($"Unexpected token {reader.TokenType} when parsing {nameof(LogLevel)}."); + } + + return Parse(reader.GetString()); + } + + public override void Write(Utf8JsonWriter writer, LogLevel value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + + public override LogLevel ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) + { + return Parse(reader.GetString()); + } + + public override void WriteAsPropertyName(Utf8JsonWriter writer, LogLevel value, JsonSerializerOptions options) + { + writer.WritePropertyName(value.ToString()); + } + + private static LogLevel Parse(string? value) + { + return value?.Trim() switch + { + "Trace" or "trace" => LogLevel.Trace, + "Debug" or "debug" => LogLevel.Debug, + "Info" or "info" or "Information" or "information" => LogLevel.Info, + "Warning" or "warning" or "Warn" or "warn" => LogLevel.Warning, + "Error" or "error" => LogLevel.Error, + "Fatal" or "fatal" or "Critical" or "critical" => LogLevel.Fatal, + _ => throw new JsonException($"Unsupported log level '{value}'.") + }; + } + } +} diff --git a/ai-plan/public/README.md b/ai-plan/public/README.md index 1aeea93f..447d2d23 100644 --- a/ai-plan/public/README.md +++ b/ai-plan/public/README.md @@ -38,6 +38,10 @@ help the current worktree land on the right recovery documents without scanning - Purpose: continue the data repository persistence hardening plus the settings / serialization follow-up backlog. - Tracking: `ai-plan/public/data-repository-persistence/todos/data-repository-persistence-tracking.md` - Trace: `ai-plan/public/data-repository-persistence/traces/data-repository-persistence-trace.md` +- `godot-logging-compliance-polish` + - Purpose: continue Godot logging host integration, configuration reload, structured-output polish, and follow-up work without forking the Core logging model. + - Tracking: `ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md` + - Trace: `ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md` - `semantic-release-versioning` - Purpose: migrate release version calculation from fixed patch bumps to semantic-release while keeping the existing tag-driven NuGet publish flow. - Tracking: `ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md` @@ -59,10 +63,10 @@ help the current worktree land on the right recovery documents without scanning - Branch: `feat/data-repository-persistence` - Worktree hint: `GFramework-data-repository-persistence` - Priority 1: `data-repository-persistence` -- Branch: `feat/semantic-release-versioning` +- Branch: `feat/godot-logging-compliance-polish` - Worktree hint: `GFramework` - - Priority 1: `semantic-release-versioning` -- Branch: `feat/release-summary-notes` + - Priority 1: `godot-logging-compliance-polish` +- Branch: `feat/semantic-release-versioning` - Worktree hint: `GFramework` - Priority 1: `semantic-release-versioning` - Branch: `docs/sdk-update-documentation` diff --git a/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md b/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md new file mode 100644 index 00000000..b2b18dcd --- /dev/null +++ b/ai-plan/public/godot-logging-compliance-polish/todos/godot-logging-compliance-polish-tracking.md @@ -0,0 +1,90 @@ +# Godot Logging Compliance Polish 跟踪 + +## 目标 + +继续把 `GFramework.Godot.Logging` 从“基础可用的 Godot 输出适配”收敛成“对齐 `GodotLogger` 优点、但保持 +GFramework 自身日志抽象不分叉”的稳定宿主层,并为后续 Godot / Core 日志统一留下清晰恢复点。 + +## 当前恢复点 + +- 恢复点编号:`GODOT-LOGGING-COMPLIANCE-POLISH-RP-003` +- 当前阶段:`PR review follow-up` +- 当前焦点: + - 已补齐 `GodotLog` 静态入口、延迟 logger 解析、配置自动发现与热重载 + - 已让 `GodotLoggerFactoryProvider` 对已缓存 logger 生效动态配置,而不是只在新建 logger 时读快照 + - 已让 `GodotLogger` 支持 `{properties}` 占位符,并把 `IStructuredLogger` / `LogContext` 属性落到 Godot 输出 + - 已兼容 `GodotLogger` 风格配置值,如 `Information` / `Critical` + - 已处理 PR #314 最新 AI review 中仍适用的 XML docs、热路径分配、结构化属性兜底、文档示例和 tracking 精简问题 + - 下一轮优先刷新 PR review / CI 反馈,避免继续扩大 Godot logging API 面 + +## 当前状态摘要 + +- `GFramework.Core` 仍是主日志框架;`GFramework.Godot` 没有引入第二套业务日志 API +- `GFramework.Godot.Logging` 现在已经补上原 `GodotLogger` 项目最有价值的宿主便利层: + - `GodotLog` + - `DeferredLogger` + - 配置文件自动发现 + - 文件热重载 + - 结构化属性渲染 +- 本轮没有把 `Microsoft.Extensions.Logging` 的 `ILoggingBuilder` / `ILoggerProvider` 生态原样搬入 GFramework +- `AbstractLogger` 已支持动态最小级别提供器,为 Godot 配置热更新生效打通基础能力 + +## 当前活跃事实 + +- 当前主题由分支 `feat/godot-logging-compliance-polish` 驱动,并已在 `ai-plan/public/README.md` 建立映射 +- `ai-libs/GodotLogger` 的 MIT 许可证已复制到 `third-party-licenses/GodotLogger/LICENSE` +- `GodotLog` 当前的配置发现顺序为: + - `GODOT_LOGGER_CONFIG` + - 可执行目录 `appsettings.json` + - `res://appsettings.json` +- `GodotLog.Configure(...)` 仍要求在首次 materialize provider 前调用;延迟 logger 会避免 `static readonly` 字段过早锁死配置 +- `GodotLoggerFactoryProvider` 当前按 logger 名称缓存实例,但每次判定级别和写日志都会读取最新 `GodotLoggerSettings` +- Godot 模板默认已扩展为包含 `{properties}`,因此结构化属性和 `LogContext` 会进入渲染结果 +- 配置加载兼容两套级别命名: + - GFramework 风格:`Info` / `Fatal` + - `GodotLogger` 风格:`Information` / `Critical` +- 现有设计仍保留 UTC 时间戳语义,没有为了对齐原项目而默认切回本地时间 +- `GodotLog.ConfigurationPath` 现在不会提前 materialize 全局配置源;`GodotLog.Shutdown()` 可释放已创建配置源的 watcher +- 配置 JSON 会先归一化模板和颜色字典,并拒绝未定义的数字 `LogLevel` +- `GodotLogTemplate` 的模板缓存和分类格式缓存已改为有界并发缓存,避免热重载或动态 category 长期单向增长 +- `refactor-scripts/update-namespaces.py` 已移除本机绝对路径默认值,并会把文件处理失败汇总成非零退出码 +- PR #314 最新 review 中,GodotLog watcher 释放、热重载回调阻塞、配置归一化、数字 `LogLevel` 校验、 + 模板缓存和脚本健壮性问题已在当前 head 验证为已处理;本轮只继续修仍适用的问题 +- PR #314 最新 follow-up 中,`DeferredLogger` 格式化重载现在委托给 inner logger,`GodotLogger` 默认 options + provider 已改为构造时缓存,结构化属性会跳过空白 key 并使用 trimmed key + +## 当前风险 + +- 双入口生命周期风险:如果同一宿主同时混用 `LoggerFactoryResolver.Provider` 与 `GodotLog`,需要明确谁是最终默认 provider + - 缓解措施:当前文档与实现都保留 `GodotLog.UseAsDefaultProvider()`,并继续把 `ArchitectureConfiguration` 方式写成默认推荐路径 +- Core / Godot 管线分离风险:Godot 侧虽然已有热重载与配置发现,但还没有变成 Core 可组合 appender + - 缓解措施:下一轮只评估“Godot sink / appender 化”,不再继续扩张独立的 Godot logging 面 +- 配置热重载的宿主差异风险:Godot 编辑器、导出包和测试宿主的文件系统语义不完全一致 + - 缓解措施:active 入口先锁定 discovery / reload 语义,后续若遇到平台差异,再用定向回归和文档补充收口 +- `GodotLog.ConfigurationPath` 的“不会 materialize”语义没有加入自动化测试 + - 缓解措施:直接调用会触碰 Godot project path resolver,在普通 test host 中可能崩溃;当前以实现和文档约束记录,后续若增加 Godot 宿主集成测试再覆盖 + +## 活跃文档 + +- 当前跟踪:[godot-logging-compliance-polish-tracking.md](./godot-logging-compliance-polish-tracking.md) +- 当前 trace:[godot-logging-compliance-polish-trace.md](../traces/godot-logging-compliance-polish-trace.md) + +## 验证说明 + +- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo` + - 结果:通过 + - 备注:RP-003 follow-up 后 Godot logging 定向测试共 `15` 项通过 +- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo` + - 结果:通过 + - 备注:RP-003 follow-up 后 Godot 测试项目共 `73` 项通过 +- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore --include ...` + - 结果:通过 + - 备注:include 范围为本轮修改的 C# 文件;全项目 format 仍命中既有行尾 / 编码问题,详见 trace +- 历史验证明细已保留在 [执行 trace](../traces/godot-logging-compliance-polish-trace.md) 的 `RP-001 验证` 与 + `RP-002 验证` 小节,active tracking 入口只保留当前恢复点相关结果 + +## 下一步 + +1. 提交 RP-003 review follow-up 改动 +2. 刷新 PR review / CI 状态,确认最新 head 上 CodeRabbit 与 Greptile 线程是否关闭或变为 stale +3. 若 CI 仍报 MegaLinter `dotnet-format` restore 失败,优先复核 Actions restore 环境,而不是继续改本地格式 diff --git a/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md b/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md new file mode 100644 index 00000000..915585c8 --- /dev/null +++ b/ai-plan/public/godot-logging-compliance-polish/traces/godot-logging-compliance-polish-trace.md @@ -0,0 +1,119 @@ +# Godot Logging Compliance Polish 追踪 + +## 2026-05-02 + +### 阶段:GodotLogger 优点吸收与宿主层补齐(RP-001) + +- 复核 `GodotLogger` 与 `GFramework.Godot.Logging` 后确认: + - 模板、颜色、Debug/Release 双模式、类别缩写和 Godot 输出路由已基本吸收 + - 真正缺口主要在宿主接入与运行期配置层,而不是输出格式层 +- 本轮新建 `godot-logging-compliance-polish` topic,并将当前分支 + `feat/godot-logging-compliance-polish` 映射到该主题 +- 为 `GFramework.Godot.Logging` 新增: + - `GodotLog` + - `DeferredLogger` + - `GodotLogConfigurationSource` + - `GodotLoggerSettings` + - `GodotLoggerSettingsLoader` +- 关键实现决策: + - 不把 `Microsoft.Extensions.Logging` 的 builder / provider 生态整套移植进来 + - 保持 `LoggerFactoryResolver` 与 `ArchitectureConfiguration` 仍是主接线方式 + - 只吸收 `GodotLogger` 里对 GFramework 现有模型真正有价值的部分: + - 配置自动发现 + - 热重载 + - 延迟 logger 初始化 + - 配置命名兼容 +- 为让热重载作用于已缓存 logger,调整 `AbstractLogger` 支持动态最小级别提供器,并让 + `GodotLoggerFactoryProvider` / `GodotLogger` 在写入和级别判定时读取最新设置 +- 为让结构化日志在 Godot 侧不再退化成纯字符串,扩展: + - `GodotLogRenderContext` + - `GodotLogTemplate` + - `GodotLoggerOptions` + - `GodotLogger` + 使默认模板支持 `{properties}`,并将 `IStructuredLogger` / `LogContext` 属性渲染到输出中 +- 为兼容 `GodotLogger` 原项目配置习惯,在 `GodotLoggerSettingsLoader` 中补充枚举解析兼容: + - `Info` / `Information` + - `Fatal` / `Critical` + - `Warn` / `Warning` +- 同步更新 `docs/zh-CN/godot/logging.md`,把文档结论从“只有薄适配层”刷新成“已具备宿主便利层和热重载语义” +- 已从 `ai-libs/GodotLogger` 复制 MIT 许可证到 `third-party-licenses/GodotLogger/LICENSE` + +### RP-001 验证 + +- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo` + - 结果:通过(11/11) +- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo` + - 结果:通过(69/69) +- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter FullyQualifiedName~Logging -nologo` + - 结果:通过(214/214) + +### RP-001 下一步 + +1. 若继续推进本主题,优先评估 Godot 输出是否应变成 Core 可组合 appender / sink +2. 若出现后续 review 反馈,直接在本 topic 追加 RP-002,而不是重新开临时 local-plan +3. 若本主题阶段性完成,再把详细实现 history 迁入 `archive/`,active 入口只保留恢复点与风险 + +### 阶段:PR review hardening(RP-002) + +- 使用 `$gframework-pr-review` 抓取 PR #314 最新 review payload,确认当前 head 上仍有 CodeRabbit 与 Greptile + 未解决线程 +- 接受并处理仍适用的 review 结论: + - `GodotLog.ConfigurationPath` 不应提前创建全局配置源,`Configure(...)` 需要在 provider 或配置源已创建后 fail-fast + - 静态配置源需要可显式释放 watcher,因此新增 `GodotLog.Shutdown()` + - `DeferredLogger` 首次解析改为 `Interlocked.CompareExchange` 发布,避免 `_inner ??=` 并发竞态 + - `GodotLogger` 结构化 `Log(...)` 覆写改为复用 `IsEnabled(level)`,删除重复的最小级别 provider 字段 + - JSON 配置输入需要归一化模板和颜色字典,并拒绝未定义的数字 `LogLevel` + - `GodotLogTemplate` 模板缓存和分类缓存需要有界,避免热重载或动态 category 长期增长 + - `refactor-scripts/update-namespaces.py` 不能依赖本机绝对路径,也不能把文件处理异常吞成 0 次替换 +- 同步补充 Godot logging 内部类型和关键方法 XML 文档,说明热重载、快照发布、分类匹配和模板缓存语义 +- 同步更新 `docs/zh-CN/godot/logging.md`,记录 `ConfigurationPath` 的诊断语义和 `Shutdown()` teardown 用法 + +### RP-002 验证 + +- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo` + - 结果:通过(15/15) +- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo` + - 结果:通过(73/73) +- `python3 -B refactor-scripts/update-namespaces.py --help` + - 结果:通过 + +### RP-002 下一步 + +1. 提交 RP-002 review hardening 改动 +2. 刷新 PR review / CI,确认最新 head 是否关闭已处理线程 +3. 若 CI 仍只有 MegaLinter `dotnet-format` restore 失败,优先定位 Actions restore 环境 + +## 2026-05-03 + +### 阶段:PR review follow-up(RP-003) + +- 再次使用 `$gframework-pr-review` 抓取 PR #314 最新 review payload,确认当前 head 上仍有 CodeRabbit 与 + Greptile 未解决线程 +- 本轮验证后接受并处理仍适用的 review 结论: + - `GodotLoggerSettingsLoaderTests` 公开测试类型与公开测试方法需要 XML 文档 + - `DeferredLogger` 的公开接口成员需要 XML 文档,并且格式化 `Log(...)` 重载不应提前执行 `string.Format` + - `GodotLogger` 默认构造器不应在每条日志上重新创建 options,结构化属性 key 需要跳过空白并做 trim + - Godot logging 文档需要给出最小 `appsettings.json` 示例、放置约定和热重载覆盖说明 + - active tracking 不应同时保留 RP-001 与 RP-002 的详细验证计数,trace 重复标题需要消除 +- 本轮验证后确认以下旧 review 结论在当前 head 已处理,无需重复改动: + - `GodotLog.Shutdown()` 已可释放 materialized configuration source 的 watcher + - hot-reload callback 已走无 `Thread.Sleep` 的 `LoadSettings()`,`Thread.Sleep` 只保留在 startup strict load retry + - JSON options 归一化、数字 `LogLevel` 校验、GodotLogTemplate 缓存和 namespace 脚本健壮性已在当前 head 存在 + +### RP-003 验证 + +- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release --filter FullyQualifiedName~GodotLog -nologo` + - 结果:通过(14/14) +- `dotnet test GFramework.Godot.Tests/GFramework.Godot.Tests.csproj -c Release -nologo` + - 结果:通过(72/72) +- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore --include ...` + - 结果:通过,include 范围为本轮修改的三个 C# 文件 +- `dotnet format GFramework.Godot.Tests/GFramework.Godot.Tests.csproj --verify-no-changes --no-restore` + - 结果:未通过;命中既有 `GFramework.Godot.Tests/Coroutine/GodotTimeSourceTests.cs` 行尾与 + `GFramework.Godot.Tests/GlobalUsings.cs` 编码问题,本轮未把该历史格式清理并入 PR review follow-up + +### RP-003 下一步 + +1. 提交 RP-003 review follow-up 改动 +2. 刷新 PR review,确认 CodeRabbit / Greptile 线程是否关闭或 stale +3. 若 CI 仍只有 MegaLinter `dotnet-format` restore 失败,继续定位 Actions restore 环境而不是扩大本地格式清理范围 diff --git a/ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md b/ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md new file mode 100644 index 00000000..b3ad830e --- /dev/null +++ b/ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md @@ -0,0 +1,23 @@ +# Semantic Release 版本迁移归档(SEMREL-RP-004,2026-05-02) + +## 归档范围 + +- `feat/release-summary-notes` 分支的 release notes 模板修复 +- PR 归属展示与重复 commit 输出问题收敛 +- `SEMREL-RP-004` 的本地验证与合并后分支收尾 + +## 历史完成项 + +- 已确认 `.github/cliff.toml` 中旧模板会先输出未分组 commit 列表,再输出 grouped commit 列表,导致同一批变更重复出现。 +- 已移除未分组 commit 循环,只保留按 Conventional Commit group 分类后的 `What's Changed` 输出。 +- 已保留每条变更末尾的 `by @user in #PR` 输出,避免新增独立 PR 索引章节造成重复。 +- 已将 `.github/workflows/publish.yml` 的 GitHub Release 正文改为 `body_path: RELEASE_NOTES.md`,复用 `git-cliff-action` 生成的文件。 +- 已将 `feat/release-summary-notes` 合入 `main`,本地 `main` 已快进到合并提交 `35a62e6b`。 +- 已从 `ai-plan/public/README.md` 移除 `feat/release-summary-notes` 的 active topic 映射;`semantic-release-versioning` 主题本身仍保持 active。 + +## 历史验证 + +- `.github/cliff.toml` 通过 Python `tomllib` 解析。 +- `.github/workflows/publish.yml` 通过 PyYAML 解析。 +- `yq` 确认 GitHub Release step 使用 `body_path: RELEASE_NOTES.md`。 +- `dotnet build GFramework.sln -c Release` 通过,`0 warning / 0 error`。 diff --git a/ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md b/ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md index a13acfa8..972d439b 100644 --- a/ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md +++ b/ai-plan/public/semantic-release-versioning/todos/semantic-release-versioning-tracking.md @@ -13,12 +13,12 @@ ## 当前恢复点 -- 恢复点编号:`SEMREL-RP-004` -- 当前阶段:`Phase 2` +- 恢复点编号:暂无 +- 当前阶段:等待下一轮 semantic-release 维护任务 - 当前焦点: - - 收敛 release notes 的 PR 归属展示方式 - - 确保 `.github/cliff.toml` 不新增独立 PR 索引导致重复输出同一批 commits - - 让分类变更列表本身承担 `What's Changed` 语义,并继续在每条 entry 末尾展示作者与 PR 链接 + - `SEMREL-RP-004` 已归档到 + `ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md` + - 后续如 CI 或发布流程继续暴露 semantic-release 问题,再从新的恢复点编号开始记录 ### 已知风险 @@ -28,7 +28,6 @@ - `semantic-release` 的版本判断完全依赖 Conventional Commits;不规范提交会直接影响版本计算 - `cycjimmy/semantic-release-action@v6` 需要在 preview / release 两端都安装 `conventional-changelog-conventionalcommits` 以保证 `conventionalcommits` preset 在 GitHub Actions 中可解析 -- 当前仓库本地 `dotnet clean/build` 会带出既有 analyzer warnings;本轮仅修正发版配置与文档,不额外处理这些历史 warning - `git-cliff-action` 的 `OUTPUT` 文件需要在 `softprops/action-gh-release` 执行时保留在当前工作目录,后续如调整 working-directory 或 artifact 路径,需要同步复查 `body_path` @@ -40,39 +39,16 @@ - 已在 PAT 校验中补充 `permissions.push` 断言,避免 read-only token 通过 API 探活却在 `semantic-release` 的 `git push --dry-run` 阶段才失败 - 已为 PAT 校验的 `mktemp` 文件补充 `trap` 清理,避免异常退出时遗留临时文件路径干扰日志 -- 已同步更新 active trace 到 `SEMREL-RP-004`,记录本轮 PR review 收敛结果 -- 已用 `$gframework-pr-review` 抓取 PR #312 最新 review payload,确认未失败测试、未发现 MegaLinter 明细,仍有 - CodeRabbit / Greptile 针对 release notes 的未解决线程 -- 已移除 `.github/cliff.toml` 中 `## What's Changed` 下的未分组 commit 循环,仅保留按 Conventional Commit group - 分类后的输出,避免每个 commit 在生成的 changelog 中出现两次 -- 已将 `.github/cliff.toml` 的分类变更列表重新置于 `## What's Changed` 下,但没有恢复未分组平铺列表; - 每条变更继续通过 `print_commit` 输出 `by @user in #PR` 链接,满足 PR 追溯需求同时避免重复章节 -- 已将 `.github/workflows/publish.yml` 的 GitHub Release 正文从多行 expression 改为 `body_path: RELEASE_NOTES.md`, - 复用 `git-cliff-action` 写出的 release notes 文件 -- 已在 `ai-plan/public/README.md` 中将 `feat/release-summary-notes` 映射到 `semantic-release-versioning`,便于后续 - `boot` 直接找到本次发布说明模板上下文 +- `SEMREL-RP-004` 的 release notes 模板修复、验证和合并后分支收尾已归档到 + `ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md` ## 验证 -- `python3 -c 'import tomllib; tomllib.load(open(".github/cliff.toml", "rb")); print("cliff.toml OK")'` - - 结果:通过 - - 备注:确认 `.github/cliff.toml` 仍为合法 TOML -- `command -v git-cliff` - - 结果:未找到本地 `git-cliff` - - 备注:本地无法直接预览 git-cliff 输出,发布 workflow 仍通过 `orhun/git-cliff-action@v4` 提供运行时二进制 -- `python3 -c 'import yaml; yaml.safe_load(open(".github/workflows/publish.yml", encoding="utf-8")); print("publish.yml OK")'` - - 结果:通过 - - 备注:确认 `.github/workflows/publish.yml` 仍可解析为 YAML -- `yq '.jobs."create-release".steps[] | select(.name == "Create GitHub Release and Upload Assets") | .with' .github/workflows/publish.yml` - - 结果:通过 - - 备注:确认 release step 现在使用 `body_path: RELEASE_NOTES.md` -- `dotnet build GFramework.sln -c Release` - - 结果:通过 - - 备注:Release 构建通过,`0 warning / 0 error`;本轮只改动 GitHub Actions / git-cliff 配置与恢复文档 +- `SEMREL-RP-004` 的本地验证结果已归档。 - 更早阶段的 dry-run / tag /抽象项目验证已归档到 `ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-2026-04-26.md` ## 下一步 -1. 提交并推送本轮 release notes 模板调整 -2. 如 CI 仍报告 release notes 发布问题,再优先复查 `git-cliff-action` 输出文件路径与模板渲染结果 +1. 如 CI 仍报告 release notes 发布问题,再优先复查 `git-cliff-action` 输出文件路径与模板渲染结果 +2. 下一轮 semantic-release 调整开始时创建新的恢复点编号 diff --git a/ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md b/ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md index 2917ddd0..94150cc3 100644 --- a/ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md +++ b/ai-plan/public/semantic-release-versioning/traces/semantic-release-versioning-trace.md @@ -40,6 +40,16 @@ - `dotnet build GFramework.sln -c Release` 通过,`0 warning / 0 error`。 - 下一步是提交并推送本轮 PR review 修复,然后重新抓取 PR review 确认相关线程状态。 +## 2026-05-02 + +### SEMREL-RP-004 合并后归档 + +- `feat/release-summary-notes` 已合入 `main`,本地 `main` 快进到合并提交 `35a62e6b`。 +- 已将 `SEMREL-RP-004` 的 release notes 模板修复、验证和分支收尾记录归档到 + `ai-plan/public/semantic-release-versioning/archive/todos/semantic-release-versioning-rp004-2026-05-02.md`。 +- 已从 `ai-plan/public/README.md` 移除 `feat/release-summary-notes` 到 `semantic-release-versioning` 的 active topic 映射。 +- `semantic-release-versioning` 主题仍保持 active,等待下一轮 semantic-release 维护任务。 + ## 2026-04-26 ### 当前恢复点(SEMREL-RP-004) diff --git a/docs/zh-CN/godot/logging.md b/docs/zh-CN/godot/logging.md index 039f9472..b6a71b44 100644 --- a/docs/zh-CN/godot/logging.md +++ b/docs/zh-CN/godot/logging.md @@ -5,11 +5,16 @@ description: 以当前 GFramework.Godot.Logging 源码与 CoreGrid 接线为准 # Godot 日志系统 -`GFramework.Godot` 当前的日志能力很收敛:它不是一套独立于 Core 的新日志框架,而是把现有 `ILogger` 调用面接到 -Godot 控制台。 +`GFramework.Godot` 当前的日志能力仍然以 Core 的 `ILogger` 调用面为中心,但已经不再只是一个薄输出适配层。 +除了把日志写到 Godot 控制台,它现在还补上了 Godot 宿主常见的接入便利层: -换句话说,Godot 侧真正新增的是 provider / factory / logger 这层输出适配,而不是新的日志 API。业务代码仍然继续使用 -`LoggerFactoryResolver.Provider.CreateLogger(...)` 或 `[Log]` 生成的 `ILogger` 字段。 +- `GodotLog` 静态入口 +- 配置文件自动发现 +- 运行期配置热重载 +- 延迟 logger 解析,适合 `static readonly` 字段 + +业务代码仍然继续使用 `LoggerFactoryResolver.Provider.CreateLogger(...)`、`GodotLog.CreateLogger(...)` 或 `[Log]` +生成的 `ILogger` 字段;Godot 侧没有额外引入第二套业务日志 API。 ## 当前公开入口 @@ -57,11 +62,79 @@ public sealed class GodotLoggerFactoryProvider : ILoggerFactoryProvider } ``` -它内部用 `CachedLoggerFactory` 包装 `GodotLoggerFactory`。缓存 key 由 `name` 和 `MinLevel` 共同组成,所以: +当前 provider 会按 logger 名称缓存实例,但 logger 本身会在写入时读取当前配置快照,所以: -- 同名、同 `MinLevel` 的 logger 会复用实例 -- 调整 `MinLevel` 后,新创建的 logger 会走新的缓存 key -- 已经持有的旧 logger 不会被原地改写 +- 同名 logger 会复用实例 +- 调整 provider 最小级别或热更新配置后,已持有的 logger 会立即看到新行为 +- 不需要为了刷新模板、颜色或级别而重新创建 logger + +### `GodotLog` + +`GodotLog` 是新增的 Godot 宿主友好入口: + +```csharp +using GFramework.Godot.Logging; + +GodotLog.Configure(options => +{ + options.Mode = GodotLoggerMode.Debug; +}); + +GodotLog.UseAsDefaultProvider(); + +var logger = GodotLog.CreateLogger
(); +``` + +它提供三件事: + +- 在第一次真正创建 provider 前允许代码覆写 `GodotLoggerOptions` +- 自动按 `GODOT_LOGGER_CONFIG` -> 可执行目录 `appsettings.json` -> `res://appsettings.json` 顺序发现配置 +- 返回延迟解析 logger,避免 `static readonly` 字段过早锁死配置 + +`GodotLog.ConfigurationPath` 可以用于诊断当前会命中的配置文件路径;读取它不会提前创建全局配置源,也不会让后续 +`GodotLog.Configure(...)` 失效。长生命周期服务器或测试宿主如果需要在退出时主动释放配置文件 watcher,可以调用 +`GodotLog.Shutdown()`;它会停止热重载监听,已创建 logger 仍然继续使用最后一次成功发布的配置快照。 + +最小可复制的 `appsettings.json` 可以只包含 `Logging` 根节点。`LogLevel` 使用 `Default` 和类别名控制过滤阈值, +`GodotLogger` 控制 Godot 输出模式、模板和颜色: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Info", + "Game.Services": "Debug" + }, + "GodotLogger": { + "Mode": "Debug", + "DebugMinLevel": "Debug", + "ReleaseMinLevel": "Info", + "DebugOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [color={color}][{level:u3}][/color] [{category:l16}] {message}{properties}", + "ReleaseOutputTemplate": "[{timestamp:HH:mm:ss.fff}] [{level:u3}] [{category:l16}] {message}{properties}", + "Colors": { + "Info": "white", + "Warning": "orange", + "Error": "red" + } + } + } +} +``` + +配置文件发现顺序固定为: + +1. `GODOT_LOGGER_CONFIG` 指向的文件 +2. 导出程序或测试进程所在目录的 `appsettings.json` +3. Godot 项目资源根目录的 `res://appsettings.json` + +在编辑器项目里,`res://appsettings.json` 放在项目根目录;在导出包或专用服务器里,优先把 +`appsettings.json` 放到可执行文件同目录,便于运维脚本替换。运行中修改已发现的配置文件会热重载 +`Logging:LogLevel` 与 `Logging:GodotLogger` 下的模式、最小级别、模板和颜色;已创建 logger 不会重新实例化, +但下一次级别判定和写入会读取最新成功发布的配置快照。热重载解析失败或文件被短暂锁定时会保留上一份可用配置。 + +`GodotLog.Configure(...)` 适合在没有配置文件或需要代码覆盖默认值时使用,并且必须在首次创建 provider 或配置源前调用。 +`GodotLog.ConfigurationPath` 适合启动诊断和测试断言;`GodotLog.Shutdown()` 适合测试 teardown 或长生命周期服务器退出时释放 +文件 watcher,不会清空已经发布给 logger 的最后一份配置。 ## 最小接入路径 @@ -121,7 +194,7 @@ public partial class SettingsPanel : Control ``` 如果你已经在用 `GFramework.Core.SourceGenerators`,也可以继续让 `[Log]` 生成字段。Godot provider 只改变输出落点, -不会改变 `[Log]` 的生成契约。 +不会改变 `[Log]` 的生成契约。需要静态字段延迟初始化时,也可以直接用 `GodotLog.CreateLogger()`。 ### 3. Scene / UI 迁移日志会自动复用同一套 provider @@ -152,7 +225,9 @@ RegisterHandler(new LoggingTransitionHandler()); | `Error` | `GD.PrintErr(...)` | 输出到错误流 | | `Fatal` | `GD.PushError(...)` | 进入 Godot 错误通道 | -异常追加格式也来自当前实现本身: +结构化属性如果通过 `IStructuredLogger` 或 `LogContext` 传入,也会追加到模板里的 `{properties}` 占位符。 + +异常追加格式仍然来自当前实现本身: ```text [2026-04-22 10:30:47.012] ERROR [SaveSystem] 保存游戏失败 @@ -182,14 +257,16 @@ System.IO.IOException: ... ## 当前边界 -- 当前推荐接法是把 `GodotLoggerFactoryProvider` 放进 `ArchitectureConfiguration.LoggerProperties`;直接赋值 - `LoggerFactoryResolver.Provider` 仍然可用,但不该再写成默认采用路径 +- 当前推荐接法仍然是把 `GodotLoggerFactoryProvider` 放进 `ArchitectureConfiguration.LoggerProperties`;如果项目是纯 + Godot 宿主,也可以在入口直接调用 `GodotLog.UseAsDefaultProvider()` - `GFramework.Godot.Logging` 只解决 Godot 控制台输出,不提供文件落盘、JSON formatter、异步 appender 或按 namespace 的复杂过滤 - `GodotLogger` 只改变输出方式,不改变 `ILogger` 接口本身;业务代码不需要切换到 Godot 专用日志 API - `[Log]`、`[ContextAware]` 这类字段注入能力不属于 `GFramework.Godot.Logging` - Scene / UI 的 `LoggingTransitionHandler` 位于 `GFramework.Game`,Godot 侧只是通过 provider 让它们输出到 Godot 控制台 - 当前 `GodotLogger` 使用的是 UTC 时间戳;如果项目需要本地时区展示,需要自定义 provider / logger,而不是假定当前实现会自动转换 +- 当前配置热重载只覆盖 Godot logger 自身的模板、颜色、模式和级别;它没有把 `Microsoft.Extensions.Logging` 的整个 + options / builder 模型搬进来 ## 继续阅读 diff --git a/refactor-scripts/update-namespaces.py b/refactor-scripts/update-namespaces.py index b9b7c205..c836d35d 100644 --- a/refactor-scripts/update-namespaces.py +++ b/refactor-scripts/update-namespaces.py @@ -1,154 +1,179 @@ -#!/usr/bin/env python3 -""" -更新所有 C# 文件中的命名空间声明和 using 语句 -""" - -import os -import re -from pathlib import Path - -ROOT_DIR = "/mnt/f/gewuyou/System/Documents/WorkSpace/GameDev/GFramework" - -# 命名空间替换规则(按优先级排序,长的先匹配) -NAMESPACE_RULES = [ - # CQRS 子命名空间 - (r'\.cqrs\.notification\b', '.CQRS.Notification'), - (r'\.cqrs\.command\b', '.CQRS.Command'), - (r'\.cqrs\.request\b', '.CQRS.Request'), - (r'\.cqrs\.query\b', '.CQRS.Query'), - (r'\.cqrs\.behaviors\b', '.CQRS.Behaviors'), - (r'\.cqrs\b', '.CQRS'), - - # 嵌套命名空间 - (r'\.coroutine\.instructions\b', '.Coroutine.Instructions'), - (r'\.coroutine\.extensions\b', '.Coroutine.Extensions'), - (r'\.coroutine\b', '.Coroutine'), - - (r'\.events\.filters\b', '.Events.Filters'), - (r'\.events\b', '.Events'), - - (r'\.logging\.appenders\b', '.Logging.Appenders'), - (r'\.logging\.filters\b', '.Logging.Filters'), - (r'\.logging\.formatters\b', '.Logging.Formatters'), - (r'\.logging\b', '.Logging'), - - (r'\.functional\.async\b', '.Functional.Async'), - (r'\.functional\.control\b', '.Functional.Control'), - (r'\.functional\.functions\b', '.Functional.Functions'), - (r'\.functional\.pipe\b', '.Functional.Pipe'), - (r'\.functional\.result\b', '.Functional.Result'), - (r'\.functional\b', '.Functional'), - - (r'\.services\.modules\b', '.Services.Modules'), - (r'\.services\b', '.Services'), - - (r'\.extensions\.signal\b', '.Extensions.Signal'), - (r'\.extensions\b', '.Extensions'), - - (r'\.setting\.data\b', '.Setting.Data'), - (r'\.setting\.events\b', '.Setting.Events'), - (r'\.setting\b', '.Setting'), - - (r'\.scene\.handler\b', '.Scene.Handler'), - (r'\.scene\b', '.Scene'), - - (r'\.ui\.handler\b', '.UI.Handler'), - (r'\.ui\b', '.UI'), - - (r'\.data\.events\b', '.Data.Events'), - (r'\.data\b', '.Data'), - - # 单层命名空间 - (r'\.architecture\b', '.Architecture'), - (r'\.bases\b', '.Bases'), - (r'\.command\b', '.Command'), - (r'\.configuration\b', '.Configuration'), - (r'\.constants\b', '.Constants'), - (r'\.enums\b', '.Enums'), - (r'\.environment\b', '.Environment'), - (r'\.internals\b', '.Internals'), - (r'\.ioc\b', '.IoC'), - (r'\.lifecycle\b', '.Lifecycle'), - (r'\.model\b', '.Model'), - (r'\.pause\b', '.Pause'), - (r'\.pool\b', '.Pool'), - (r'\.properties\b', '.Properties'), - (r'\.property\b', '.Property'), - (r'\.query\b', '.Query'), - (r'\.registries\b', '.Registries'), - (r'\.resource\b', '.Resource'), - (r'\.rule\b', '.Rule'), - (r'\.serializer\b', '.Serializer'), - (r'\.state\b', '.State'), - (r'\.storage\b', '.Storage'), - (r'\.system\b', '.System'), - (r'\.time\b', '.Time'), - (r'\.utility\b', '.Utility'), - (r'\.versioning\b', '.Versioning'), - (r'\.asset\b', '.Asset'), - (r'\.components\b', '.Components'), - (r'\.systems\b', '.Systems'), - (r'\.ecs\b', '.ECS'), - (r'\.integration\b', '.Integration'), - (r'\.mediator\b', '.Mediator'), - (r'\.tests\b', '.Tests'), - (r'\.analyzers\b', '.Analyzers'), - (r'\.diagnostics\b', '.Diagnostics'), - (r'\.generator\b', '.Generator'), - (r'\.info\b', '.Info'), -] - -def update_file(file_path): - """更新单个文件中的命名空间""" - try: - with open(file_path, 'r', encoding='utf-8') as f: - content = f.read() - - original_content = content - replacements = 0 - - for pattern, replacement in NAMESPACE_RULES: - matches = re.findall(pattern, content, re.IGNORECASE) - if matches: - content = re.sub(pattern, replacement, content, flags=re.IGNORECASE) - replacements += len(matches) - - if content != original_content: - with open(file_path, 'w', encoding='utf-8') as f: - f.write(content) - return replacements - - return 0 - except Exception as e: - print(f"错误处理文件 {file_path}: {e}") - return 0 - -def main(): - print("开始更新命名空间...") - - # 查找所有 C# 文件 - cs_files = [] - for root, dirs, files in os.walk(ROOT_DIR): - # 跳过 bin, obj, Generated 目录 - dirs[:] = [d for d in dirs if d not in ['bin', 'obj', 'Generated', '.git', 'node_modules']] - - for file in files: - if file.endswith('.cs'): - cs_files.append(os.path.join(root, file)) - - print(f"找到 {len(cs_files)} 个 C# 文件") - - updated_files = 0 - total_replacements = 0 - - for file_path in cs_files: - replacements = update_file(file_path) - if replacements > 0: - updated_files += 1 - total_replacements += replacements - print(f"更新: {os.path.basename(file_path)} ({replacements} 处替换)") - - print(f"\n完成!更新了 {updated_files} 个文件,共 {total_replacements} 处替换") - -if __name__ == '__main__': - main() +#!/usr/bin/env python3 +""" +更新所有 C# 文件中的命名空间声明和 using 语句 +""" + +import os +import re +import sys +from argparse import ArgumentParser + +DEFAULT_ROOT_DIR = os.getcwd() + +# 命名空间替换规则(按优先级排序,长的先匹配) +NAMESPACE_RULES = [ + # CQRS 子命名空间 + (r'\.cqrs\.notification\b', '.CQRS.Notification'), + (r'\.cqrs\.command\b', '.CQRS.Command'), + (r'\.cqrs\.request\b', '.CQRS.Request'), + (r'\.cqrs\.query\b', '.CQRS.Query'), + (r'\.cqrs\.behaviors\b', '.CQRS.Behaviors'), + (r'\.cqrs\b', '.CQRS'), + + # 嵌套命名空间 + (r'\.coroutine\.instructions\b', '.Coroutine.Instructions'), + (r'\.coroutine\.extensions\b', '.Coroutine.Extensions'), + (r'\.coroutine\b', '.Coroutine'), + + (r'\.events\.filters\b', '.Events.Filters'), + (r'\.events\b', '.Events'), + + (r'\.logging\.appenders\b', '.Logging.Appenders'), + (r'\.logging\.filters\b', '.Logging.Filters'), + (r'\.logging\.formatters\b', '.Logging.Formatters'), + (r'\.logging\b', '.Logging'), + + (r'\.functional\.async\b', '.Functional.Async'), + (r'\.functional\.control\b', '.Functional.Control'), + (r'\.functional\.functions\b', '.Functional.Functions'), + (r'\.functional\.pipe\b', '.Functional.Pipe'), + (r'\.functional\.result\b', '.Functional.Result'), + (r'\.functional\b', '.Functional'), + + (r'\.services\.modules\b', '.Services.Modules'), + (r'\.services\b', '.Services'), + + (r'\.extensions\.signal\b', '.Extensions.Signal'), + (r'\.extensions\b', '.Extensions'), + + (r'\.setting\.data\b', '.Setting.Data'), + (r'\.setting\.events\b', '.Setting.Events'), + (r'\.setting\b', '.Setting'), + + (r'\.scene\.handler\b', '.Scene.Handler'), + (r'\.scene\b', '.Scene'), + + (r'\.ui\.handler\b', '.UI.Handler'), + (r'\.ui\b', '.UI'), + + (r'\.data\.events\b', '.Data.Events'), + (r'\.data\b', '.Data'), + + # 单层命名空间 + (r'\.architecture\b', '.Architecture'), + (r'\.bases\b', '.Bases'), + (r'\.command\b', '.Command'), + (r'\.configuration\b', '.Configuration'), + (r'\.constants\b', '.Constants'), + (r'\.enums\b', '.Enums'), + (r'\.environment\b', '.Environment'), + (r'\.internals\b', '.Internals'), + (r'\.ioc\b', '.IoC'), + (r'\.lifecycle\b', '.Lifecycle'), + (r'\.model\b', '.Model'), + (r'\.pause\b', '.Pause'), + (r'\.pool\b', '.Pool'), + (r'\.properties\b', '.Properties'), + (r'\.property\b', '.Property'), + (r'\.query\b', '.Query'), + (r'\.registries\b', '.Registries'), + (r'\.resource\b', '.Resource'), + (r'\.rule\b', '.Rule'), + (r'\.serializer\b', '.Serializer'), + (r'\.state\b', '.State'), + (r'\.storage\b', '.Storage'), + (r'\.system\b', '.System'), + (r'\.time\b', '.Time'), + (r'\.utility\b', '.Utility'), + (r'\.versioning\b', '.Versioning'), + (r'\.asset\b', '.Asset'), + (r'\.components\b', '.Components'), + (r'\.systems\b', '.Systems'), + (r'\.ecs\b', '.ECS'), + (r'\.integration\b', '.Integration'), + (r'\.mediator\b', '.Mediator'), + (r'\.tests\b', '.Tests'), + (r'\.analyzers\b', '.Analyzers'), + (r'\.diagnostics\b', '.Diagnostics'), + (r'\.generator\b', '.Generator'), + (r'\.info\b', '.Info'), +] + +def update_file(file_path): + """更新单个文件中的命名空间""" + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + + original_content = content + replacements = 0 + + for pattern, replacement in NAMESPACE_RULES: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + content = re.sub(pattern, replacement, content, flags=re.IGNORECASE) + replacements += len(matches) + + if content != original_content: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + return replacements + + return 0 + except Exception as e: + raise RuntimeError(f"错误处理文件 {file_path}: {e}") from e + +def main(): + parser = ArgumentParser(description="更新 C# 文件中的命名空间声明和 using 语句") + parser.add_argument( + "--root-dir", + default=os.getenv("ROOT_DIR", DEFAULT_ROOT_DIR), + help="要扫描的仓库根目录,默认使用 ROOT_DIR 环境变量或当前工作目录") + args = parser.parse_args() + root_dir = os.path.abspath(args.root_dir) + + if not os.path.isdir(root_dir): + print(f"根目录不存在或不是目录: {root_dir}", file=sys.stderr) + return 2 + + print("开始更新命名空间...") + + # 查找所有 C# 文件 + cs_files = [] + for root, dirs, files in os.walk(root_dir): + # 跳过 bin, obj, Generated 目录 + dirs[:] = [d for d in dirs if d not in ['bin', 'obj', 'Generated', '.git', 'node_modules']] + + for file in files: + if file.endswith('.cs'): + cs_files.append(os.path.join(root, file)) + + print(f"找到 {len(cs_files)} 个 C# 文件") + + updated_files = 0 + total_replacements = 0 + failed_files = [] + + for file_path in cs_files: + try: + replacements = update_file(file_path) + except RuntimeError as e: + failed_files.append((file_path, str(e))) + continue + + if replacements > 0: + updated_files += 1 + total_replacements += replacements + print(f"更新: {os.path.basename(file_path)} ({replacements} 处替换)") + + print(f"\n完成!更新了 {updated_files} 个文件,共 {total_replacements} 处替换") + if failed_files: + print(f"失败文件数: {len(failed_files)}", file=sys.stderr) + for file_path, error in failed_files: + print(f"- {file_path}: {error}", file=sys.stderr) + return 1 + + return 0 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/third-party-licenses/GodotLogger/LICENSE b/third-party-licenses/GodotLogger/LICENSE new file mode 100644 index 00000000..2d55aadd --- /dev/null +++ b/third-party-licenses/GodotLogger/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 gamedo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.