Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Darp.Utils.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
<Project Path="test/Darp.Utils.Dialog.FluentAvalonia.Tests/Darp.Utils.Dialog.FluentAvalonia.Tests.csproj" />
<Project Path="test/Darp.Utils.Dialog.Tests/Darp.Utils.Dialog.Tests.csproj" />
<Project Path="test/Darp.Utils.Messaging.Generator.Verify/Darp.Utils.Messaging.Generator.Verify.csproj" />
<Project Path="test/Darp.Utils.ResxSourceGenerator.Integration/Darp.Utils.ResxSourceGenerator.Integration.csproj" />
<Project Path="test/Darp.Utils.ResxSourceGenerator.Tests/Darp.Utils.ResxSourceGenerator.Tests.csproj" />
<Project Path="test/Darp.Utils.SimpleArgumentParser.Tests/Darp.Utils.SimpleArgumentParser.Tests.csproj" />
<Project Path="test/Darp.Utils.TestRail.Tests/Darp.Utils.TestRail.Tests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
### New Rules

| Rule ID | Category | Severity | Notes |

Check warning on line 3 in src/Darp.Utils.ResxSourceGenerator/AnalyzerReleases.Unshipped.md

View workflow job for this annotation

GitHub Actions / build-and-test

Analyzer release file 'AnalyzerReleases.Unshipped.md' has a missing or invalid release header '| Rule ID | Category | Severity | Notes |' (https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md)
|-------------|---------------|----------|--------------------------------------|
| DarpResX001 | Globalization | Warning | Empty resource file |
| DarpResX002 | Globalization | Warning | Invalid Key in resource file |
| DarpResX003 | Globalization | Warning | Missing Value in resource file |
| DarpResX004 | Globalization | Warning | Duplicate Key in resource file |
| DarpResX005 | Globalization | Warning | Missing translation for specific Key |
| DarpResX006 | Globalization | Warning | Mixed format argument styles |
160 changes: 135 additions & 25 deletions src/Darp.Utils.ResxSourceGenerator/BuildHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ namespace Darp.Utils.ResxSourceGenerator;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using Microsoft.CodeAnalysis;
Expand Down Expand Up @@ -65,6 +64,16 @@ internal static class BuildHelper
isEnabledByDefault: true
);

private static readonly DiagnosticDescriptor MixedFormatArgumentsWarning = new(
id: "DarpResX006",
title: "Mixed format argument styles",
messageFormat: "Entry with key '{0}' mixes named and numbered format items and will not get a format method",
category: "Globalization",
defaultSeverity: DiagnosticSeverity.Warning,
helpLinkUri: HelpLinkUri,
isEnabledByDefault: true
);

public static bool TryGenerateSource(
ResourceCollection resourceCollection,
in List<Diagnostic> diagnostics,
Expand Down Expand Up @@ -97,6 +106,7 @@ out var namespaceEnd
}

string? getStringMethod = null;
string? formatHelperMethods = null;
if (resourceInformation.Settings.EmitFormatMethods)
{
getStringMethod += $$$$"""
Expand All @@ -106,11 +116,115 @@ out var namespaceEnd
{{{{memberIndent}}}} if (formatterNames == null) return value;
{{{{memberIndent}}}} for (var i = 0; i < formatterNames.Length; i++)
{{{{memberIndent}}}} {
{{{{memberIndent}}}} value = value.Replace($"{{{formatterNames[i]}}}", $"{{{i}}}");
{{{{memberIndent}}}} value = ReplaceNamedFormatItem(value, formatterNames[i], i);
{{{{memberIndent}}}} }
{{{{memberIndent}}}} return value;
{{{{memberIndent}}}}}

""";
formatHelperMethods += $$$$"""
{{{{memberIndent}}}}private static string ReplaceNamedFormatItem(string value, string formatterName, int index)
{{{{memberIndent}}}}{
{{{{memberIndent}}}} global::System.Text.StringBuilder? builder = null;
{{{{memberIndent}}}} var appendFrom = 0;
{{{{memberIndent}}}}
{{{{memberIndent}}}} for (var i = 0; i < value.Length; i++)
{{{{memberIndent}}}} {
{{{{memberIndent}}}} if (value[i] != '{')
{{{{memberIndent}}}} continue;
{{{{memberIndent}}}} if (i + 1 < value.Length && value[i + 1] == '{')
{{{{memberIndent}}}} {
{{{{memberIndent}}}} i++;
{{{{memberIndent}}}} continue;
{{{{memberIndent}}}} }
{{{{memberIndent}}}}
{{{{memberIndent}}}} var nameStart = i + 1;
{{{{memberIndent}}}} if (!IsMatchAt(value, nameStart, formatterName))
{{{{memberIndent}}}} continue;
{{{{memberIndent}}}}
{{{{memberIndent}}}} var suffixStart = nameStart + formatterName.Length;
{{{{memberIndent}}}} if (suffixStart >= value.Length)
{{{{memberIndent}}}} continue;
{{{{memberIndent}}}}
{{{{memberIndent}}}} var suffixEnd = suffixStart;
{{{{memberIndent}}}} while (suffixEnd < value.Length && value[suffixEnd] != '}')
{{{{memberIndent}}}} {
{{{{memberIndent}}}} if (value[suffixEnd] == '{')
{{{{memberIndent}}}} {
{{{{memberIndent}}}} suffixEnd = -1;
{{{{memberIndent}}}} break;
{{{{memberIndent}}}} }
{{{{memberIndent}}}}
{{{{memberIndent}}}} suffixEnd++;
{{{{memberIndent}}}} }
{{{{memberIndent}}}}
{{{{memberIndent}}}} if (suffixEnd < 0 || suffixEnd >= value.Length)
{{{{memberIndent}}}} continue;
{{{{memberIndent}}}} if (!IsValidFormatSuffix(value, suffixStart, suffixEnd))
{{{{memberIndent}}}} continue;
{{{{memberIndent}}}}
{{{{memberIndent}}}} builder ??= new global::System.Text.StringBuilder(value.Length);
{{{{memberIndent}}}} builder.Append(value, appendFrom, i - appendFrom);
{{{{memberIndent}}}} builder.Append('{').Append(index);
{{{{memberIndent}}}} builder.Append(value, suffixStart, suffixEnd - suffixStart);
{{{{memberIndent}}}} builder.Append('}');
{{{{memberIndent}}}} appendFrom = suffixEnd + 1;
{{{{memberIndent}}}} i = suffixEnd;
{{{{memberIndent}}}} }
{{{{memberIndent}}}}
{{{{memberIndent}}}} if (builder == null)
{{{{memberIndent}}}} return value;
{{{{memberIndent}}}}
{{{{memberIndent}}}} builder.Append(value, appendFrom, value.Length - appendFrom);
{{{{memberIndent}}}} return builder.ToString();
{{{{memberIndent}}}}}

{{{{memberIndent}}}}private static bool IsMatchAt(string value, int start, string formatterName)
{{{{memberIndent}}}}{
{{{{memberIndent}}}} if (start + formatterName.Length > value.Length)
{{{{memberIndent}}}} return false;
{{{{memberIndent}}}}
{{{{memberIndent}}}} for (var i = 0; i < formatterName.Length; i++)
{{{{memberIndent}}}} {
{{{{memberIndent}}}} if (value[start + i] != formatterName[i])
{{{{memberIndent}}}} return false;
{{{{memberIndent}}}} }
{{{{memberIndent}}}}
{{{{memberIndent}}}} return true;
{{{{memberIndent}}}}}

{{{{memberIndent}}}}private static bool IsValidFormatSuffix(string value, int start, int end)
{{{{memberIndent}}}}{
{{{{memberIndent}}}} if (start == end)
{{{{memberIndent}}}} return true;
{{{{memberIndent}}}} if (value[start] == ':')
{{{{memberIndent}}}} return true;
{{{{memberIndent}}}} if (value[start] != ',')
{{{{memberIndent}}}} return false;
{{{{memberIndent}}}}
{{{{memberIndent}}}} var i = start + 1;
{{{{memberIndent}}}} while (i < end && char.IsWhiteSpace(value[i]))
{{{{memberIndent}}}} {
{{{{memberIndent}}}} i++;
{{{{memberIndent}}}} }
{{{{memberIndent}}}} if (i < end && value[i] == '-')
{{{{memberIndent}}}} {
{{{{memberIndent}}}} i++;
{{{{memberIndent}}}} }
{{{{memberIndent}}}}
{{{{memberIndent}}}} var digitStart = i;
{{{{memberIndent}}}} while (i < end && char.IsDigit(value[i]))
{{{{memberIndent}}}} {
{{{{memberIndent}}}} i++;
{{{{memberIndent}}}} }
{{{{memberIndent}}}}
{{{{memberIndent}}}} if (i == digitStart)
{{{{memberIndent}}}} return false;
{{{{memberIndent}}}} if (i == end)
{{{{memberIndent}}}} return true;
{{{{memberIndent}}}} return value[i] == ':';
{{{{memberIndent}}}}}

""";
}
var defaultClass = $$"""
Expand Down Expand Up @@ -159,7 +273,7 @@ out var namespaceEnd
{{memberIndent}}{
{{keysMembers}}
{{memberIndent}}}
{{classIndent}}}
{{formatHelperMethods}}{{classIndent}}}
""";
var debugInformation = resourceCollection.GenerateDebugInformation();
var result = $"""
Expand Down Expand Up @@ -277,6 +391,18 @@ CancellationToken cancellationToken
var resourceString = new ResourceString(propertyIdentifier, value);
if (resourceString.HasArguments)
{
if (resourceString.HasMixedArguments)
{
diagnostics.Add(
Diagnostic.Create(
descriptor: MixedFormatArgumentsWarning,
location: Location.Create(resourceInformation.ResourceFile.Path, default, default),
messageArgs: [name]
)
);
continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not skip key generation for mixed formats

When a resource value mixes numbered and named items (for example {0} {name}) with EmitFormatMethods enabled, this continue exits the entire resource loop after the property has already been emitted but before the corresponding Keys constant is appended. The warning says only the format method should be omitted, but the generated property still references Keys.@..., so this case turns into a compilation error instead of a usable string property plus diagnostic.

Useful? React with 👍 / 👎.

}

Comment on lines +394 to +405

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Don’t continue after reporting mixed format arguments.

This skips key generation for the resource after the property has already been emitted, so the generated property can reference a missing Keys.* constant. Only skip RenderFormatMethod.

Proposed fix
                     if (resourceString.HasMixedArguments)
                     {
                         diagnostics.Add(
                             Diagnostic.Create(
                                 descriptor: MixedFormatArgumentsWarning,
                                 location: Location.Create(resourceInformation.ResourceFile.Path, default, default),
                                 messageArgs: [name]
                             )
                         );
-                        continue;
                     }
-
-                    RenderFormatMethod(memberIndent, membersBuilder, resourceString);
+                    else
+                    {
+                        RenderFormatMethod(memberIndent, membersBuilder, resourceString);
+                    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (resourceString.HasMixedArguments)
{
diagnostics.Add(
Diagnostic.Create(
descriptor: MixedFormatArgumentsWarning,
location: Location.Create(resourceInformation.ResourceFile.Path, default, default),
messageArgs: [name]
)
);
continue;
}
if (resourceString.HasMixedArguments)
{
diagnostics.Add(
Diagnostic.Create(
descriptor: MixedFormatArgumentsWarning,
location: Location.Create(resourceInformation.ResourceFile.Path, default, default),
messageArgs: [name]
)
);
}
else
{
RenderFormatMethod(memberIndent, membersBuilder, resourceString);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Darp.Utils.ResxSourceGenerator/BuildHelper.cs` around lines 394 - 405,
The mixed format arguments branch in BuildHelper should not stop the rest of the
resource generation flow after emitting MixedFormatArgumentsWarning. Update the
logic around resourceString.HasMixedArguments so it only bypasses
RenderFormatMethod, while still allowing the Keys.* constant and property
generation to proceed for that resource. Keep the behavior localized to the
mixed-argument handling in the resource generation path.

RenderFormatMethod(memberIndent, membersBuilder, resourceString);
}
}
Expand Down Expand Up @@ -574,41 +700,25 @@ public static string GetIdentifierFromResourceName(string name)

private readonly struct ResourceString
{
private static readonly Regex NamedParameterMatcher = new(
@"\{([a-z]\w*)\}",
RegexOptions.IgnoreCase | RegexOptions.Compiled
);
private static readonly Regex NumberParameterMatcher = new(@"\{(\d+)\}", RegexOptions.Compiled);
private readonly IReadOnlyList<string> _arguments;

public ResourceString(string identifier, string value)
{
Identifier = identifier;
Value = value;

MatchCollection match = NamedParameterMatcher.Matches(value);
UsingNamedArgs = match.Count > 0;

if (!UsingNamedArgs)
{
match = NumberParameterMatcher.Matches(value);
}

IEnumerable<string> arguments = match.Cast<Match>().Select(m => m.Groups[1].Value).Distinct();
if (!UsingNamedArgs)
{
arguments = arguments.OrderBy(Convert.ToInt32);
}

_arguments = arguments.ToList();
_arguments = ResourceFormatHelper.GetArguments(value, out var usingNamedArgs, out var hasMixedArguments);
UsingNamedArgs = usingNamedArgs;
HasMixedArguments = hasMixedArguments;
}

public string Identifier { get; }
public string Value { get; }

public bool UsingNamedArgs { get; }

public bool HasArguments => _arguments.Count > 0;
public bool HasArguments => _arguments.Count > 0 || HasMixedArguments;

public bool HasMixedArguments { get; }

public string GetArgumentNames() => string.Join(", ", _arguments.Select(a => "\"" + a + "\""));

Expand Down
157 changes: 157 additions & 0 deletions src/Darp.Utils.ResxSourceGenerator/ResourceFormatHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
namespace Darp.Utils.ResxSourceGenerator;

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;

internal static class ResourceFormatHelper
{
public static IReadOnlyList<string> GetArguments(string value, out bool usingNamedArgs)
{
return GetArguments(value, out usingNamedArgs, out _);
}

public static IReadOnlyList<string> GetArguments(string value, out bool usingNamedArgs, out bool hasMixedArguments)
{
var namedArguments = new List<string>();
var numberedArguments = new List<string>();

for (var i = 0; i < value.Length; i++)
{
if (value[i] != '{')
continue;
if (i + 1 < value.Length && value[i + 1] == '{')
{
i++;
continue;
}

if (!TryReadFormatItem(value, i, out var argument, out var isNamed, out var end))
continue;

List<string> arguments = isNamed ? namedArguments : numberedArguments;
if (!arguments.Contains(argument))
{
arguments.Add(argument);
}

i = end;
}

hasMixedArguments = namedArguments.Count > 0 && numberedArguments.Count > 0;
if (hasMixedArguments)
{
usingNamedArgs = false;
return [];
}

usingNamedArgs = namedArguments.Count > 0;
if (usingNamedArgs)
{
return namedArguments;
}

if (numberedArguments.Count == 0)
{
return numberedArguments;
}

var maxArgumentIndex = numberedArguments.Select(x => Convert.ToInt32(x, CultureInfo.InvariantCulture)).Max();
return Enumerable.Range(0, maxArgumentIndex + 1).Select(x => x.ToString(CultureInfo.InvariantCulture)).ToList();
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard oversized numeric format indexes before building the contiguous range.

Convert.ToInt32 can throw for {999999999999}, and a valid-but-huge index like {100000000} can force an enormous generated parameter list. Add int.TryParse plus a sane upper bound/skip diagnostic before Enumerable.Range.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/Darp.Utils.ResxSourceGenerator/ResourceFormatHelper.cs` around lines 60 -
61, The numbered-argument handling in ResourceFormatHelper should guard
oversized format indexes before creating the contiguous list. Update the logic
that parses numberedArguments and builds the Enumerable.Range result to use
int.TryParse instead of Convert.ToInt32, reject or diagnose indexes above a sane
maximum, and skip generating the range when the parsed value is too large. Keep
the fix localized to the method that computes maxArgumentIndex and the
subsequent Enumerable.Range call.

}

private static bool TryReadFormatItem(
string value,
int openBrace,
out string argument,
out bool isNamed,
out int closeBrace
)
{
argument = "";
isNamed = false;
closeBrace = -1;

var argumentStart = openBrace + 1;
if (argumentStart >= value.Length)
return false;

var argumentEnd = argumentStart;
if (char.IsDigit(value[argumentEnd]))
{
while (argumentEnd < value.Length && char.IsDigit(value[argumentEnd]))
{
argumentEnd++;
}
}
else if (IsIdentifierStart(value[argumentEnd]))
{
isNamed = true;
while (argumentEnd < value.Length && IsIdentifierPart(value[argumentEnd]))
{
argumentEnd++;
}
}
else
{
return false;
}

closeBrace = argumentEnd;
while (closeBrace < value.Length && value[closeBrace] != '}')
{
if (value[closeBrace] == '{')
{
closeBrace = -1;
break;
}

closeBrace++;
}

if (closeBrace < 0 || closeBrace >= value.Length)
return false;
if (!IsValidFormatSuffix(value, argumentEnd, closeBrace))
return false;

argument = value.Substring(argumentStart, argumentEnd - argumentStart);
return true;
}

private static bool IsValidFormatSuffix(string value, int start, int end)
{
if (start == end)
return true;
if (value[start] == ':')
return true;
if (value[start] != ',')
return false;

var i = start + 1;
while (i < end && char.IsWhiteSpace(value[i]))
{
i++;
}
if (i < end && value[i] == '-')
{
i++;
}

var digitStart = i;
while (i < end && char.IsDigit(value[i]))
{
i++;
}

if (i == digitStart)
return false;
if (i == end)
return true;
return value[i] == ':';
}

private static bool IsIdentifierStart(char c) => c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';

private static bool IsIdentifierPart(char c) => IsIdentifierStart(c) || c is (>= '0' and <= '9') or '_';
Comment on lines +154 to +156

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve non-ASCII identifier parts

For named placeholders that contain a valid non-ASCII C# identifier character after the first ASCII letter, such as {año} or {name_ä}, this ASCII-only check stops parsing at the first non-ASCII character and rejects the item as an invalid suffix, so no Format... method is generated. The previous matcher accepted these via \w*, and the project already has CharExtensions.IsIdentifierPartCharacter() for C#-compatible Unicode identifiers, so localized parameter names that used to work silently lose their format helper.

Useful? React with 👍 / 👎.

}
Loading
Loading