From fdc9c3347289e090cfec7937350c029b37b88995 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:32:06 +0000 Subject: [PATCH 01/14] Initial plan From 3c879fddd8018569e9eb7e8ab7c21e00f21bb8b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:36:15 +0000 Subject: [PATCH 02/14] Fix LogValuesFormatter and LoggerMessageGenerator to support duplicate placeholders Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../gen/LoggerMessageGenerator.Parser.cs | 5 ++++- .../src/LogValuesFormatter.cs | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs index 6af2a7856ed41e..4a16250c5e6f34 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs @@ -780,7 +780,10 @@ private static bool ExtractTemplates(string? message, Dictionary } templateMap[templateName] = templateName; - templateList.Add(templateName); + if (!templateList.Contains(templateName)) + { + templateList.Add(templateName); + } scanIndex = closeBraceIndex + 1; } diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs index 2fdba83aa3206c..2c6ae7dfa2391d 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs @@ -66,8 +66,14 @@ public LogValuesFormatter(string format) formatDelimiterIndex = formatDelimiterIndex < 0 ? closeBraceIndex : formatDelimiterIndex + openBraceIndex; vsb.Append(format.AsSpan(scanIndex, openBraceIndex - scanIndex + 1)); - vsb.Append(_valueNames.Count.ToString(CultureInfo.InvariantCulture)); - _valueNames.Add(format.Substring(openBraceIndex + 1, formatDelimiterIndex - openBraceIndex - 1)); + string valueName = format.Substring(openBraceIndex + 1, formatDelimiterIndex - openBraceIndex - 1); + int valueIndex = _valueNames.IndexOf(valueName); + if (valueIndex < 0) + { + valueIndex = _valueNames.Count; + _valueNames.Add(valueName); + } + vsb.Append(valueIndex.ToString(CultureInfo.InvariantCulture)); vsb.Append(format.AsSpan(formatDelimiterIndex, closeBraceIndex - formatDelimiterIndex + 1)); scanIndex = closeBraceIndex + 1; From 42e93dbcf571f730eccfbfe4e0a6d544d8f5b6a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:37:59 +0000 Subject: [PATCH 03/14] Add comprehensive tests for duplicate placeholder support Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../LoggerMessageGeneratorParserTests.cs | 26 ++++++ .../tests/Common/FormattedLogValuesTest.cs | 47 +++++++++++ .../tests/Common/LoggerMessageTest.cs | 81 +++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs index 75befc10e7b681..7eda7d4f6159e5 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs @@ -948,6 +948,32 @@ partial class C Assert.Empty(diagnostics); } + [Fact] + public async Task ValidTemplates_WithDuplicatePlaceholders() + { + IReadOnlyList diagnostics = await RunGenerator(@" + partial class C + { + [LoggerMessage(EventId = 1, Level = LogLevel.Debug, Message = ""Hello {Name}. How are you {Name}"")] + static partial void M1(ILogger logger, string name); + + [LoggerMessage(EventId = 2, Level = LogLevel.Debug, Message = ""Hello {Name}. You are {Age} years old. How are you {Name}"")] + static partial void M2(ILogger logger, string name, int age); + + [LoggerMessage(EventId = 3, Level = LogLevel.Debug, Message = ""{Age} {Name} {Age}"")] + static partial void M3(ILogger logger, int age, string name); + + [LoggerMessage(EventId = 4, Level = LogLevel.Debug, Message = ""{Name} {Name} {Name}"")] + static partial void M4(ILogger logger, string name); + + [LoggerMessage(EventId = 5, Level = LogLevel.Debug, Message = ""Age: {Age}, Name: {Name}, Age: {Age}, Name: {Name}"")] + static partial void M5(ILogger logger, int age, string name); + } + "); + + Assert.Empty(diagnostics); + } + [Fact] public async Task Cancellation() { diff --git a/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs b/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs index 4f5b3a8af4746b..b12d702f5b5920 100644 --- a/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs @@ -236,6 +236,53 @@ public void FormatsEnumerableValues(string messageFormat, object[] arguments, st Assert.Equal(expected, logValues.ToString()); } + [Theory] + [InlineData("Hello David. How are you David", "Hello {Name}. How are you {Name}", new object[] { "David" })] + [InlineData("Hello David. You are 100 years old. How are you David", "Hello {Name}. You are {Age} years old. How are you {Name}", new object[] { "David", 100 })] + [InlineData("100 David 100", "{Age} {Name} {Age}", new object[] { 100, "David" })] + [InlineData("David David David", "{Name} {Name} {Name}", new object[] { "David" })] + [InlineData("Age: 100, Name: David, Age: 100, Name: David", "Age: {Age}, Name: {Name}, Age: {Age}, Name: {Name}", new object[] { 100, "David" })] + public void LogValues_WithDuplicatePlaceholders(string expected, string format, object[] args) + { + var logValues = new FormattedLogValues(format, args); + Assert.Equal(expected, logValues.ToString()); + + // Original format is expected to be returned from GetValues. + Assert.Equal(format, logValues.First(v => v.Key == "{OriginalFormat}").Value); + } + + [Fact] + public void LogValues_WithDuplicatePlaceholders_CorrectKeyValuePairs() + { + var format = "Hello {Name}. How are you {Name}"; + var name = "David"; + var logValues = new FormattedLogValues(format, name); + + var state = logValues.ToArray(); + Assert.Equal(new[] + { + new KeyValuePair("Name", name), + new KeyValuePair("{OriginalFormat}", format), + }, state); + } + + [Fact] + public void LogValues_WithMultipleDuplicatePlaceholders_CorrectKeyValuePairs() + { + var format = "Hello {Name}. You are {Age} years old. How are you {Name}"; + var name = "David"; + var age = 100; + var logValues = new FormattedLogValues(format, name, age); + + var state = logValues.ToArray(); + Assert.Equal(new[] + { + new KeyValuePair("Name", name), + new KeyValuePair("Age", age), + new KeyValuePair("{OriginalFormat}", format), + }, state); + } + private class MediaType { public MediaType(string type, string subType) diff --git a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs index a177db3ad35d7e..9e16bd26c70df6 100644 --- a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs @@ -514,5 +514,86 @@ public void LogValues_OutOfRangeAccess_ThrowsIndexOutOfRangeExceptionWithDefault Assert.NotEqual("index", exception.Message); Assert.False(string.IsNullOrEmpty(exception.Message)); } + + [Fact] + public void LoggerMessage_Define_WithDuplicatePlaceholder_SingleParameter() + { + // Arrange + var testSink = new TestSink(); + var testLogger = new TestLogger("testlogger", testSink, enabled: true); + var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "Hello {Name}. How are you {Name}"); + + // Act + action(testLogger, "David", null); + + // Assert + Assert.Single(testSink.Writes); + var writeContext = testSink.Writes.First(); + var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); + + Assert.Equal("Hello David. How are you David", actualLogValues.ToString()); + Assert.Equal("Hello {Name}. How are you {Name}", actualLogValues.First(v => v.Key == "{OriginalFormat}").Value); + } + + [Fact] + public void LoggerMessage_Define_WithDuplicatePlaceholder_MultipleParameters() + { + // Arrange + var testSink = new TestSink(); + var testLogger = new TestLogger("testlogger", testSink, enabled: true); + var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "Hello {Name}. You are {Age} years old. How are you {Name}"); + + // Act + action(testLogger, "David", 100, null); + + // Assert + Assert.Single(testSink.Writes); + var writeContext = testSink.Writes.First(); + var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); + + Assert.Equal("Hello David. You are 100 years old. How are you David", actualLogValues.ToString()); + Assert.Equal("Hello {Name}. You are {Age} years old. How are you {Name}", actualLogValues.First(v => v.Key == "{OriginalFormat}").Value); + } + + [Fact] + public void LoggerMessage_Define_WithAllDuplicatePlaceholders() + { + // Arrange + var testSink = new TestSink(); + var testLogger = new TestLogger("testlogger", testSink, enabled: true); + var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "{Name} {Name} {Name}"); + + // Act + action(testLogger, "David", null); + + // Assert + Assert.Single(testSink.Writes); + var writeContext = testSink.Writes.First(); + var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); + + Assert.Equal("David David David", actualLogValues.ToString()); + Assert.Equal("{Name} {Name} {Name}", actualLogValues.First(v => v.Key == "{OriginalFormat}").Value); + } + + [Fact] + public void LoggerMessage_DefineScope_WithDuplicatePlaceholder() + { + // Arrange + var testSink = new TestSink(); + var testLogger = new TestLogger("testlogger", testSink, enabled: true); + var scopeFunc = LoggerMessage.DefineScope("Hello {Name}. How are you {Name}"); + + // Act + using (scopeFunc(testLogger, "David")) + { + // Assert + Assert.Single(testSink.Scopes); + var scopeContext = testSink.Scopes.First(); + var actualLogValues = Assert.IsAssignableFrom>>(scopeContext.Scope); + + Assert.Equal("Hello David. How are you David", actualLogValues.ToString()); + Assert.Equal("Hello {Name}. How are you {Name}", actualLogValues.First(v => v.Key == "{OriginalFormat}").Value); + } + } } } From 70b9a25a0210a1056bbed6a5f826e2cec5510e8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:01:03 +0000 Subject: [PATCH 04/14] Optimize LogValuesFormatter to use Dictionary for O(1) lookups Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../src/LogValuesFormatter.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs index 2c6ae7dfa2391d..530215ffde2307 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs @@ -33,6 +33,7 @@ public LogValuesFormatter(string format) OriginalFormat = format; + Dictionary? valueNameIndices = null; var vsb = new ValueStringBuilder(stackalloc char[256]); int scanIndex = 0; int endIndex = format.Length; @@ -67,11 +68,15 @@ public LogValuesFormatter(string format) vsb.Append(format.AsSpan(scanIndex, openBraceIndex - scanIndex + 1)); string valueName = format.Substring(openBraceIndex + 1, formatDelimiterIndex - openBraceIndex - 1); - int valueIndex = _valueNames.IndexOf(valueName); - if (valueIndex < 0) + + // Lazily allocate the dictionary only when we have placeholders + valueNameIndices ??= new Dictionary(); + + if (!valueNameIndices.TryGetValue(valueName, out int valueIndex)) { valueIndex = _valueNames.Count; _valueNames.Add(valueName); + valueNameIndices[valueName] = valueIndex; } vsb.Append(valueIndex.ToString(CultureInfo.InvariantCulture)); vsb.Append(format.AsSpan(formatDelimiterIndex, closeBraceIndex - formatDelimiterIndex + 1)); From 1e395f74c871f081c0f65373789fee0b1247154a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:02:52 +0000 Subject: [PATCH 05/14] Optimize LoggerMessageGenerator.Parser to use Dictionary for O(1) lookups Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../gen/LoggerMessageGenerator.Parser.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs index 4a16250c5e6f34..69d0f241ae88f1 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs @@ -779,10 +779,11 @@ private static bool ExtractTemplates(string? message, Dictionary break; } - templateMap[templateName] = templateName; - if (!templateList.Contains(templateName)) + // Only add the template name to the list if it hasn't been seen before + if (!templateMap.ContainsKey(templateName)) { templateList.Add(templateName); + templateMap[templateName] = templateName; } scanIndex = closeBraceIndex + 1; From 84a74e72c6124687bf5734a3e1a1287d431bbc27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 09:10:33 +0000 Subject: [PATCH 06/14] Refine LoggerMessageGenerator.Parser to match LogValuesFormatter pattern Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../gen/LoggerMessageGenerator.Parser.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs index 69d0f241ae88f1..6bf627eea91c34 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/LoggerMessageGenerator.Parser.cs @@ -782,8 +782,8 @@ private static bool ExtractTemplates(string? message, Dictionary // Only add the template name to the list if it hasn't been seen before if (!templateMap.ContainsKey(templateName)) { - templateList.Add(templateName); templateMap[templateName] = templateName; + templateList.Add(templateName); } scanIndex = closeBraceIndex + 1; From 641ceae69b297ea9a0d589686c8d76f12250b4c6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 12:05:11 +0000 Subject: [PATCH 07/14] Optimize placeholder lookup to avoid Dictionary for small templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use linear search (IndexOf) for templates with ≤4 unique placeholders to avoid Dictionary allocation and hashing overhead. Only transition to Dictionary when we have >4 unique placeholders. This optimizes for the common case of 1-4 parameters while maintaining good performance for larger templates. Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com> --- .../src/LogValuesFormatter.cs | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs index 530215ffde2307..52e8eb2fab7596 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs @@ -69,14 +69,36 @@ public LogValuesFormatter(string format) vsb.Append(format.AsSpan(scanIndex, openBraceIndex - scanIndex + 1)); string valueName = format.Substring(openBraceIndex + 1, formatDelimiterIndex - openBraceIndex - 1); - // Lazily allocate the dictionary only when we have placeholders - valueNameIndices ??= new Dictionary(); - - if (!valueNameIndices.TryGetValue(valueName, out int valueIndex)) + int valueIndex; + if (valueNameIndices != null) + { + // Use dictionary for lookup when we have many placeholders + if (!valueNameIndices.TryGetValue(valueName, out valueIndex)) + { + valueIndex = _valueNames.Count; + _valueNames.Add(valueName); + valueNameIndices[valueName] = valueIndex; + } + } + else { - valueIndex = _valueNames.Count; - _valueNames.Add(valueName); - valueNameIndices[valueName] = valueIndex; + // For small number of placeholders, use linear search to avoid dictionary allocation and hashing overhead + valueIndex = _valueNames.IndexOf(valueName); + if (valueIndex < 0) + { + valueIndex = _valueNames.Count; + _valueNames.Add(valueName); + + // Switch to dictionary when we have many unique placeholders + if (_valueNames.Count > 4) + { + valueNameIndices = new Dictionary(_valueNames.Count); + for (int i = 0; i < _valueNames.Count; i++) + { + valueNameIndices[_valueNames[i]] = i; + } + } + } } vsb.Append(valueIndex.ToString(CultureInfo.InvariantCulture)); vsb.Append(format.AsSpan(formatDelimiterIndex, closeBraceIndex - formatDelimiterIndex + 1)); From 8f49e2d33f2aa68a37db00479959580d563eafc1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:05:01 +0000 Subject: [PATCH 08/14] Fix backward compatibility: preserve validation for non-duplicate parameter mismatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track total placeholder count (including duplicates) in LogValuesFormatter and use it in CreateLogValuesFormatter to distinguish between: - Genuine parameter count mismatches (no duplicates) → still throws - Fewer unique names due to duplicates → now allowed - Backward compat: matching arg count with duplicate placeholders → still works Added test for backward compat case: Define("{Name}...{Name}"). Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../src/LogValuesFormatter.cs | 5 +++++ .../src/LoggerMessage.cs | 12 ++++++++++-- .../tests/Common/LoggerMessageTest.cs | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs index 52e8eb2fab7596..891d0fb7ef0856 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs @@ -17,6 +17,7 @@ internal sealed class LogValuesFormatter { private const string NullValue = "(null)"; private readonly List _valueNames = new List(); + private readonly int _placeholderCount; #if NET private readonly CompositeFormat _format; #else @@ -37,6 +38,7 @@ public LogValuesFormatter(string format) var vsb = new ValueStringBuilder(stackalloc char[256]); int scanIndex = 0; int endIndex = format.Length; + int placeholderCount = 0; while (scanIndex < endIndex) { @@ -68,6 +70,7 @@ public LogValuesFormatter(string format) vsb.Append(format.AsSpan(scanIndex, openBraceIndex - scanIndex + 1)); string valueName = format.Substring(openBraceIndex + 1, formatDelimiterIndex - openBraceIndex - 1); + placeholderCount++; int valueIndex; if (valueNameIndices != null) @@ -107,6 +110,7 @@ public LogValuesFormatter(string format) } } + _placeholderCount = placeholderCount; _format = #if NET CompositeFormat.Parse(vsb.ToString()); @@ -117,6 +121,7 @@ public LogValuesFormatter(string format) public string OriginalFormat { get; } public List ValueNames => _valueNames; + public int PlaceholderCount => _placeholderCount; private static int FindBraceIndex(string format, char brace, int startIndex, int endIndex) { diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs index 093d2fb77f220e..6507158d877964 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs @@ -456,8 +456,16 @@ private static LogValuesFormatter CreateLogValuesFormatter(string formatString, int actualCount = logValuesFormatter.ValueNames.Count; if (actualCount != expectedNamedParameterCount) { - throw new ArgumentException( - SR.Format(SR.UnexpectedNumberOfNamedParameters, formatString, expectedNamedParameterCount, actualCount)); + // Allow fewer unique placeholder names than expected when duplicates exist + // (e.g., Define("{Name}...{Name}") has 1 unique but 2 total). + // Also allow backward compat where args match total placeholders + // (e.g., Define("{Name}...{Name}") has 2 total, 2 expected). + bool hasDuplicates = logValuesFormatter.PlaceholderCount > actualCount; + if (!hasDuplicates || actualCount > expectedNamedParameterCount) + { + throw new ArgumentException( + SR.Format(SR.UnexpectedNumberOfNamedParameters, formatString, expectedNamedParameterCount, actualCount)); + } } return logValuesFormatter; diff --git a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs index 9e16bd26c70df6..37bf78e6f63b8d 100644 --- a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs @@ -595,5 +595,20 @@ public void LoggerMessage_DefineScope_WithDuplicatePlaceholder() Assert.Equal("Hello {Name}. How are you {Name}", actualLogValues.First(v => v.Key == "{OriginalFormat}").Value); } } + + [Fact] + public void LoggerMessage_Define_WithDuplicatePlaceholder_BackwardCompat_MatchingArgCount() + { + var testSink = new TestSink(); + var testLogger = new TestLogger("testlogger", testSink, enabled: true); + var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "Hello {Name}. How are you {Name}"); + + action(testLogger, "David", "David", null); + + Assert.Single(testSink.Writes); + var writeContext = testSink.Writes.First(); + var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); + Assert.Equal("Hello David. How are you David", actualLogValues.ToString()); + } } } From 4ad9a09f3687af60e2ee20b3c01a14bd21294795 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 15:40:25 +0000 Subject: [PATCH 09/14] Simplify CreateLogValuesFormatter validation logic Rewrite the nested condition to a single clear expression: - Throw when more unique placeholders than expected args - Throw when fewer unique placeholders AND no duplicates (genuine mismatch) - Allow fewer unique placeholders when duplicates exist (backward compat) Co-authored-by: tarekgh <10833894+tarekgh@users.noreply.github.com> --- .../src/LoggerMessage.cs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs index 6507158d877964..b9eacc0d0334a4 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs @@ -454,18 +454,11 @@ private static LogValuesFormatter CreateLogValuesFormatter(string formatString, var logValuesFormatter = new LogValuesFormatter(formatString); int actualCount = logValuesFormatter.ValueNames.Count; - if (actualCount != expectedNamedParameterCount) - { - // Allow fewer unique placeholder names than expected when duplicates exist - // (e.g., Define("{Name}...{Name}") has 1 unique but 2 total). - // Also allow backward compat where args match total placeholders - // (e.g., Define("{Name}...{Name}") has 2 total, 2 expected). - bool hasDuplicates = logValuesFormatter.PlaceholderCount > actualCount; - if (!hasDuplicates || actualCount > expectedNamedParameterCount) - { - throw new ArgumentException( - SR.Format(SR.UnexpectedNumberOfNamedParameters, formatString, expectedNamedParameterCount, actualCount)); - } + if (actualCount > expectedNamedParameterCount + || (actualCount < expectedNamedParameterCount && logValuesFormatter.PlaceholderCount == actualCount)) + { + throw new ArgumentException( + SR.Format(SR.UnexpectedNumberOfNamedParameters, formatString, expectedNamedParameterCount, actualCount)); } return logValuesFormatter; From 4e8c7c1f278f010b8b03774711f866777787b17f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 16:05:11 +0000 Subject: [PATCH 10/14] Use OrdinalIgnoreCase for placeholder deduplication to match source generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align LogValuesFormatter with the source generator's TemplateMap which uses StringComparer.OrdinalIgnoreCase. Both the Dictionary (for >4 placeholders) and the linear search (for ≤4 placeholders) now use case-insensitive comparison, so {Name} and {name} are treated as the same placeholder. Added a case-insensitive duplicate test case. Co-authored-by: tarekgh <10833894+tarekgh@users.noreply.github.com> --- .../src/LogValuesFormatter.cs | 17 +++++++++++++++-- .../tests/Common/FormattedLogValuesTest.cs | 1 + 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs index 891d0fb7ef0856..ec0b29157da466 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs @@ -86,7 +86,7 @@ public LogValuesFormatter(string format) else { // For small number of placeholders, use linear search to avoid dictionary allocation and hashing overhead - valueIndex = _valueNames.IndexOf(valueName); + valueIndex = FindValueName(valueName); if (valueIndex < 0) { valueIndex = _valueNames.Count; @@ -95,7 +95,7 @@ public LogValuesFormatter(string format) // Switch to dictionary when we have many unique placeholders if (_valueNames.Count > 4) { - valueNameIndices = new Dictionary(_valueNames.Count); + valueNameIndices = new Dictionary(_valueNames.Count, StringComparer.OrdinalIgnoreCase); for (int i = 0; i < _valueNames.Count; i++) { valueNameIndices[_valueNames[i]] = i; @@ -123,6 +123,19 @@ public LogValuesFormatter(string format) public List ValueNames => _valueNames; public int PlaceholderCount => _placeholderCount; + private int FindValueName(string valueName) + { + for (int i = 0; i < _valueNames.Count; i++) + { + if (string.Equals(_valueNames[i], valueName, StringComparison.OrdinalIgnoreCase)) + { + return i; + } + } + + return -1; + } + private static int FindBraceIndex(string format, char brace, int startIndex, int endIndex) { // Example: {{prefix{{{Argument}}}suffix}}. diff --git a/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs b/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs index b12d702f5b5920..fd9589fa229bb6 100644 --- a/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs @@ -242,6 +242,7 @@ public void FormatsEnumerableValues(string messageFormat, object[] arguments, st [InlineData("100 David 100", "{Age} {Name} {Age}", new object[] { 100, "David" })] [InlineData("David David David", "{Name} {Name} {Name}", new object[] { "David" })] [InlineData("Age: 100, Name: David, Age: 100, Name: David", "Age: {Age}, Name: {Name}, Age: {Age}, Name: {Name}", new object[] { 100, "David" })] + [InlineData("Hello David. How are you David", "Hello {Name}. How are you {name}", new object[] { "David" })] public void LogValues_WithDuplicatePlaceholders(string expected, string format, object[] args) { var logValues = new FormattedLogValues(format, args); From 476499310a6de1990047bb29810d3e6ab7399bc2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 18:41:18 +0000 Subject: [PATCH 11/14] Add source generator tests for duplicate template names with different casing Added test cases M6-M8 for case-insensitive duplicate placeholders: - {Name} and {name} (simple case difference) - {Name}, {NAME}, and {name} (all same name, different casing) - {Name}, {age}, and {NAME} (mixed parameters with case variations) Co-authored-by: tarekgh <10833894+tarekgh@users.noreply.github.com> --- .../LoggerMessageGeneratorParserTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs index 7eda7d4f6159e5..768475fad1939d 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/tests/Microsoft.Extensions.Logging.Generators.Tests/LoggerMessageGeneratorParserTests.cs @@ -968,6 +968,15 @@ partial class C [LoggerMessage(EventId = 5, Level = LogLevel.Debug, Message = ""Age: {Age}, Name: {Name}, Age: {Age}, Name: {Name}"")] static partial void M5(ILogger logger, int age, string name); + + [LoggerMessage(EventId = 6, Level = LogLevel.Debug, Message = ""Hello {Name}. How are you {name}"")] + static partial void M6(ILogger logger, string name); + + [LoggerMessage(EventId = 7, Level = LogLevel.Debug, Message = ""{Name} {NAME} {name}"")] + static partial void M7(ILogger logger, string name); + + [LoggerMessage(EventId = 8, Level = LogLevel.Debug, Message = ""Hello {Name}. You are {age} years old. How are you {NAME}"")] + static partial void M8(ILogger logger, string name, int age); } "); From f4afd9f63586dc4f2d31acfd748ca33da6a6f43c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 19:04:07 +0000 Subject: [PATCH 12/14] Add case-insensitive duplicate tests for LoggerMessage.Define and FormattedLogValues LoggerMessageTest: added tests for {Name}/{name} and {Name}/{NAME}/{name} FormattedLogValuesTest: added InlineData for {Name}/{NAME}/{name} and mixed {Name}/{age}/{NAME} Co-authored-by: tarekgh <10833894+tarekgh@users.noreply.github.com> --- .../tests/Common/FormattedLogValuesTest.cs | 2 ++ .../tests/Common/LoggerMessageTest.cs | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs b/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs index fd9589fa229bb6..a237afac9bcddd 100644 --- a/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging/tests/Common/FormattedLogValuesTest.cs @@ -243,6 +243,8 @@ public void FormatsEnumerableValues(string messageFormat, object[] arguments, st [InlineData("David David David", "{Name} {Name} {Name}", new object[] { "David" })] [InlineData("Age: 100, Name: David, Age: 100, Name: David", "Age: {Age}, Name: {Name}, Age: {Age}, Name: {Name}", new object[] { 100, "David" })] [InlineData("Hello David. How are you David", "Hello {Name}. How are you {name}", new object[] { "David" })] + [InlineData("David David David", "{Name} {NAME} {name}", new object[] { "David" })] + [InlineData("Hello David. You are 100 years old. How are you David", "Hello {Name}. You are {age} years old. How are you {NAME}", new object[] { "David", 100 })] public void LogValues_WithDuplicatePlaceholders(string expected, string format, object[] args) { var logValues = new FormattedLogValues(format, args); diff --git a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs index 37bf78e6f63b8d..4f0e3322137992 100644 --- a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs @@ -610,5 +610,35 @@ public void LoggerMessage_Define_WithDuplicatePlaceholder_BackwardCompat_Matchin var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); Assert.Equal("Hello David. How are you David", actualLogValues.ToString()); } + + [Fact] + public void LoggerMessage_Define_WithDuplicatePlaceholder_DifferentCasing() + { + var testSink = new TestSink(); + var testLogger = new TestLogger("testlogger", testSink, enabled: true); + var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "Hello {Name}. How are you {name}"); + + action(testLogger, "David", null); + + Assert.Single(testSink.Writes); + var writeContext = testSink.Writes.First(); + var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); + Assert.Equal("Hello David. How are you David", actualLogValues.ToString()); + } + + [Fact] + public void LoggerMessage_Define_WithAllDuplicatePlaceholders_DifferentCasing() + { + var testSink = new TestSink(); + var testLogger = new TestLogger("testlogger", testSink, enabled: true); + var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "{Name} {NAME} {name}"); + + action(testLogger, "David", null); + + Assert.Single(testSink.Writes); + var writeContext = testSink.Writes.First(); + var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); + Assert.Equal("David David David", actualLogValues.ToString()); + } } } From 531c5d96782a07d116d104fc7eb97be02b307bd9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 21:49:02 +0000 Subject: [PATCH 13/14] Remove Arrange/Act/Assert comments from new tests Per repo convention, test code should not include section comments like "// Arrange", "// Act", "// Assert". Co-authored-by: tarekgh <10833894+tarekgh@users.noreply.github.com> --- .../tests/Common/LoggerMessageTest.cs | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs index 4f0e3322137992..a04004cbb28c3f 100644 --- a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs @@ -518,15 +518,12 @@ public void LogValues_OutOfRangeAccess_ThrowsIndexOutOfRangeExceptionWithDefault [Fact] public void LoggerMessage_Define_WithDuplicatePlaceholder_SingleParameter() { - // Arrange var testSink = new TestSink(); var testLogger = new TestLogger("testlogger", testSink, enabled: true); var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "Hello {Name}. How are you {Name}"); - // Act action(testLogger, "David", null); - // Assert Assert.Single(testSink.Writes); var writeContext = testSink.Writes.First(); var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); @@ -538,15 +535,12 @@ public void LoggerMessage_Define_WithDuplicatePlaceholder_SingleParameter() [Fact] public void LoggerMessage_Define_WithDuplicatePlaceholder_MultipleParameters() { - // Arrange var testSink = new TestSink(); var testLogger = new TestLogger("testlogger", testSink, enabled: true); var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "Hello {Name}. You are {Age} years old. How are you {Name}"); - // Act action(testLogger, "David", 100, null); - // Assert Assert.Single(testSink.Writes); var writeContext = testSink.Writes.First(); var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); @@ -558,15 +552,12 @@ public void LoggerMessage_Define_WithDuplicatePlaceholder_MultipleParameters() [Fact] public void LoggerMessage_Define_WithAllDuplicatePlaceholders() { - // Arrange var testSink = new TestSink(); var testLogger = new TestLogger("testlogger", testSink, enabled: true); var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "{Name} {Name} {Name}"); - // Act action(testLogger, "David", null); - // Assert Assert.Single(testSink.Writes); var writeContext = testSink.Writes.First(); var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); @@ -578,15 +569,12 @@ public void LoggerMessage_Define_WithAllDuplicatePlaceholders() [Fact] public void LoggerMessage_DefineScope_WithDuplicatePlaceholder() { - // Arrange var testSink = new TestSink(); var testLogger = new TestLogger("testlogger", testSink, enabled: true); var scopeFunc = LoggerMessage.DefineScope("Hello {Name}. How are you {Name}"); - // Act using (scopeFunc(testLogger, "David")) { - // Assert Assert.Single(testSink.Scopes); var scopeContext = testSink.Scopes.First(); var actualLogValues = Assert.IsAssignableFrom>>(scopeContext.Scope); From a9b850c04f12ffc4bc36b2be32fa093b104452b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:16:17 +0000 Subject: [PATCH 14/14] Revert to strict equality validation for CreateLogValuesFormatter Using strict equality (actualCount != expectedNamedParameterCount) prevents IndexOutOfRangeException when loggers enumerate LogValues state. With deduplication, ValueNames.Count can be smaller than generic args, and the hardcoded indexers (e.g. ValueNames[1] for LogValues) would crash. Removed unused _placeholderCount/PlaceholderCount. Updated backward compat test to verify ArgumentException is thrown. Co-authored-by: tarekgh <10833894+tarekgh@users.noreply.github.com> --- .../src/LogValuesFormatter.cs | 6 +----- .../src/LoggerMessage.cs | 3 +-- .../tests/Common/LoggerMessageTest.cs | 14 +++----------- 3 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs index ec0b29157da466..6dc6bb8e971238 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LogValuesFormatter.cs @@ -17,7 +17,7 @@ internal sealed class LogValuesFormatter { private const string NullValue = "(null)"; private readonly List _valueNames = new List(); - private readonly int _placeholderCount; + #if NET private readonly CompositeFormat _format; #else @@ -38,7 +38,6 @@ public LogValuesFormatter(string format) var vsb = new ValueStringBuilder(stackalloc char[256]); int scanIndex = 0; int endIndex = format.Length; - int placeholderCount = 0; while (scanIndex < endIndex) { @@ -70,7 +69,6 @@ public LogValuesFormatter(string format) vsb.Append(format.AsSpan(scanIndex, openBraceIndex - scanIndex + 1)); string valueName = format.Substring(openBraceIndex + 1, formatDelimiterIndex - openBraceIndex - 1); - placeholderCount++; int valueIndex; if (valueNameIndices != null) @@ -110,7 +108,6 @@ public LogValuesFormatter(string format) } } - _placeholderCount = placeholderCount; _format = #if NET CompositeFormat.Parse(vsb.ToString()); @@ -121,7 +118,6 @@ public LogValuesFormatter(string format) public string OriginalFormat { get; } public List ValueNames => _valueNames; - public int PlaceholderCount => _placeholderCount; private int FindValueName(string valueName) { diff --git a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs index b9eacc0d0334a4..093d2fb77f220e 100644 --- a/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs +++ b/src/libraries/Microsoft.Extensions.Logging.Abstractions/src/LoggerMessage.cs @@ -454,8 +454,7 @@ private static LogValuesFormatter CreateLogValuesFormatter(string formatString, var logValuesFormatter = new LogValuesFormatter(formatString); int actualCount = logValuesFormatter.ValueNames.Count; - if (actualCount > expectedNamedParameterCount - || (actualCount < expectedNamedParameterCount && logValuesFormatter.PlaceholderCount == actualCount)) + if (actualCount != expectedNamedParameterCount) { throw new ArgumentException( SR.Format(SR.UnexpectedNumberOfNamedParameters, formatString, expectedNamedParameterCount, actualCount)); diff --git a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs index a04004cbb28c3f..b5b6435b10c433 100644 --- a/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs +++ b/src/libraries/Microsoft.Extensions.Logging/tests/Common/LoggerMessageTest.cs @@ -585,18 +585,10 @@ public void LoggerMessage_DefineScope_WithDuplicatePlaceholder() } [Fact] - public void LoggerMessage_Define_WithDuplicatePlaceholder_BackwardCompat_MatchingArgCount() + public void LoggerMessage_Define_WithDuplicatePlaceholder_MatchingArgCount_Throws() { - var testSink = new TestSink(); - var testLogger = new TestLogger("testlogger", testSink, enabled: true); - var action = LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "Hello {Name}. How are you {Name}"); - - action(testLogger, "David", "David", null); - - Assert.Single(testSink.Writes); - var writeContext = testSink.Writes.First(); - var actualLogValues = Assert.IsAssignableFrom>>(writeContext.State); - Assert.Equal("Hello David. How are you David", actualLogValues.ToString()); + Assert.Throws(() => + LoggerMessage.Define(LogLevel.Information, new EventId(0, "LogSomething"), "Hello {Name}. How are you {Name}")); } [Fact]