diff --git a/CHANGELOG.md b/CHANGELOG.md index 46460e1..5da7a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ Each release of Locali, newest first. An entry is dated when its release is cut, and reads *Unreleased* until then. The releases before `1.0.0-preview.11` are recorded by their [tags](https://github.com/FormaUI/Locali/tags) alone. +## `1.0.0-preview.12` + +*November 14, 2026* + +- A call to a method whose parameter carries `[LocaleKey]`, as `LocalizableText.For`'s key does, is read + as a lookup: its key is checked as a lookup's is, against the translations of the method's type argument, and a + key passed on from a parameter carrying `[LocaleKey]` is checked where its own method is called. + ## `1.0.0-preview.11` *November 5, 2026* diff --git a/CLAUDE.md b/CLAUDE.md index e300542..253b86a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,7 @@ Detailed standards live in `.claude/skills/` and load on demand. Read the matchi ## Source of truth - Do use [README.md](README.md) for everything about the tool itself: the CLI, every rule and where it stays silent, the source layout, how to add a rule, and how the linter runs on the SDK's compiler. It is the authority; this file only says what the repository around it expects. +- Do follow [FormaUI/Versioning `v1.0.0-preview.1`](https://github.com/FormaUI/Versioning/blob/v1.0.0-preview.1/README.md) for versions, branches, commits, releases and the package; the README's *Versioning* section states what it leaves to Locali. - Do read `.editorconfig` before writing C#; it is the authority on style, and the root file and `tests/.editorconfig` differ deliberately. - Do treat the sibling **Forma** repository (`c:\Users\eyal\Development\Projects\formaui-net`) as the reference for how the runtime behaves; a rule is right when it reports exactly what `Forma.Framework.Localization` will get wrong at run time. - Don't invent scope, status, or style rules when an authoritative file defines them. @@ -85,6 +86,7 @@ dotnet build Forma.Locali.slnx -p:GenerateDocumentationFile=true --no-incrementa ``` - Do require build, tests, and the format check to pass before offering work for review. +- Do run `./build.ps1 -Full`, the full gate, after a change to the package, its project file or `package-files.txt`; it packs the tool and verifies the package as a consumer installs it. - Do run the `CS1591` sweep after adding or changing public API, and ignore the hits under `tests/`, which are test method names. - Do run the format check rather than trusting the build; it catches using-directive ordering and line endings that compilation ignores. - Do finish by running the tool itself over the sibling Forma repository when a change touches what a rule reports: diff --git a/README.md b/README.md index a9328aa..ab9a473 100644 --- a/README.md +++ b/README.md @@ -160,9 +160,15 @@ rather than the severity — and a `diagnostics` array of `path`, `line`, `colum Callsites are found semantically where the compilation binds `IStringLocale`, and syntactically otherwise. The semantic path reads the operation tree the compiler binds, which has one shape for every language it compiles, and asks it only about the nodes a lookup can be written as, an -element access or a call named `Resolve`, so a member holding neither is never bound; the -syntactic path reads C# or Visual Basic syntax, cannot resolve overloads, and so leaves the rules -that count values silent. +element access, a call named `Resolve` or a call to a method marked with a key, so a member +holding none is never bound; the syntactic path reads C# or Visual Basic syntax, cannot resolve +overloads, and so leaves the rules that count values silent. + +A call to a method whose parameter carries `[LocaleKey]`, as `LocalizableText.For`'s +key does, is a lookup on the semantic path: the argument for that parameter is its key, read in +the translations of the method's one type argument, and its values are counted where one `params` +parameter follows the key, as a lookup's are. A key a method passes on from a `[LocaleKey]` +parameter of its own is checked where that method is called. Almost every callsite lives in `.razor`, which reaches Roslyn only through the Razor source generator. The linter runs that generator itself, so nothing has to be emitted to disk and no diff --git a/docs/linting-rules.md b/docs/linting-rules.md index 458c2bd..79dcccd 100644 --- a/docs/linting-rules.md +++ b/docs/linting-rules.md @@ -434,6 +434,19 @@ a constant is folded by the compiler and checked like a literal on the semantic inside a type that implements `IStringLocale` is left alone throughout, since such a type forwards the key its own caller wrote. +A call to a method whose parameter carries `[LocaleKey]` is a lookup too, found on the semantic +path alone: the argument for that parameter is the key, read in the translations of the method's +one type argument, and the values are what one `params` parameter after the key gathers. A key +passed on from a parameter that carries `[LocaleKey]` itself is left alone, since it is checked +where its own method is called: + +```csharp +LocalizableText.For("{Count} results today", 12) // right: one value for its one hole +LocalizableText.For("{Count} results today", 12, 3) // LOC0201: one hole, two values +LocalizableText Title([LocaleKey] string key) + => LocalizableText.For(key); // right: Title's callers write the key +``` + Values pair to a key's distinct holes by position, in the order the holes first appear, and a plural expression's bracketed argument names a value the way a brace hole does: @@ -466,7 +479,8 @@ such hole and whose lookup supplies no such value. **Silent where.** The values arrive as one collection, `object?[]` into `params ReadOnlySpan`, whose count only the run knows; an `int[]` in the same position cannot convert, arrives as one boxed value, and is counted. Also silent on the syntactic path, -where no overload resolved. +where no overload resolved, and for a call through `[LocaleKey]` whose method takes anything but +one `params` parameter after its key, since the method may fill the holes itself. ### LOC0202 — An expression naming no argument is given no value @@ -530,9 +544,10 @@ with a JSON `null`: `"Email": null`. A null entry is *found* — `ResourceNotFou genuinely missing cannot use it, and the warning is the honest answer there. **Silent where.** The type has no translations at all, since it is unlocalized rather than -under-translated; the lookup is through the unbound `IStringLocale`, which names no type; a -parent culture answers, since `es-419` reads `es` for what it does not declare; or the run did -not name the project's files. +under-translated; the lookup is through the unbound `IStringLocale`, which names no type, or is +a call through `[LocaleKey]` to a method without exactly one type argument; a parent culture +answers, since `es-419` reads `es` for what it does not declare; or the run did not name the +project's files. ### LOC0208 — A lookup writes its key as an expression @@ -544,6 +559,9 @@ silent would let a run read as complete when part of it was never looked at. **What fixes it.** Write the key as a literal or a constant, which the compiler folds and every rule reads. +**Silent where.** The key is a parameter that carries `[LocaleKey]` itself, which the method's +own callers write and the rules read there. + ### LOC0209 — A key with holes and no words **Reports.** A key made only of holes and punctuation, `"{count} / {total}"`. @@ -598,4 +616,5 @@ the moment any culture is active. **Silent where.** The lookup is a value beside the format text, `string.Format("Found {0}.", L[key])`; or the result is stored, formatted later or passed through a method, which the rule cannot follow. Without a bound compilation, only the lookup written as the unnamed or -`format`-named first argument is certain. +`format`-named first argument is certain. A call through `[LocaleKey]` is never read as format +text, since what its method returns is its own. diff --git a/src/Forma.Locali.Engine/Sources/LocaleKeyMethodNames.cs b/src/Forma.Locali.Engine/Sources/LocaleKeyMethodNames.cs new file mode 100644 index 0000000..0a01b65 --- /dev/null +++ b/src/Forma.Locali.Engine/Sources/LocaleKeyMethodNames.cs @@ -0,0 +1,116 @@ +namespace Forma.Locali.Sources; + +using System.Runtime.CompilerServices; + +using Microsoft.CodeAnalysis; + +using CSharp = Microsoft.CodeAnalysis.CSharp.Syntax; + +/// Names the methods a C# compilation declares or references whose parameter is marked as a localization key. +internal static class LocaleKeyMethodNames +{ + private const string AttributeName = "LocaleKey"; + private const string AttributeTypeName = "LocaleKeyAttribute"; + + private static readonly IReadOnlySet None = new HashSet(); + + // A table runs its factory outside its lock, so each entry is lazy and the trees of a project, walked at once, + // read it once. + private static readonly ConditionalWeakTable>> ByCompilation = []; + private static readonly ConditionalWeakTable>> ByReference = []; + + /// The names of the methods the compilation can call whose parameter is marked as a localization key. + /// The compilation. + public static IReadOnlySet Get(Compilation compilation) + { + ArgumentNullException.ThrowIfNull(compilation); + + return ByCompilation.GetValue(compilation, key => new(() => Collect(key))).Value; + } + + private static IReadOnlySet Collect(Compilation compilation) + { + // Only an assembly that references the one declaring the attribute can mark a parameter with it. + if (compilation.GetTypeByMetadataName(LocalizerContract.KeyAttributeMetadataName)?.ContainingAssembly.Name is not { } declaring) + { + return None; + } + + var names = new HashSet(StringComparer.Ordinal); + + foreach (var tree in compilation.SyntaxTrees) + { + CollectDeclared(tree, names); + } + + // Projects of one run share their references, so each referenced assembly is read once. + foreach (var reference in compilation.References) + { + if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly && References(assembly, declaring)) + { + names.UnionWith(ByReference.GetValue(reference, _ => new(() => CollectReferenced(assembly))).Value); + } + } + + return names; + } + + private static bool References(IAssemblySymbol assembly, string declaring) + => assembly.Name == declaring + || assembly.Modules.Any(module => module.ReferencedAssemblies.Any(identity => identity.Name == declaring)); + + // The compilation's own methods are found by their syntax, which names the attribute without binding a member. + private static void CollectDeclared(SyntaxTree tree, HashSet names) + { + foreach (var parameter in tree.GetRoot().DescendantNodes().OfType()) + { + if (parameter.AttributeLists.Any(list => list.Attributes.Any(IsKeyAttribute)) && DeclaringMethod(parameter) is { } name) + { + names.Add(name); + } + } + } + + private static bool IsKeyAttribute(CSharp.AttributeSyntax attribute) + => UnqualifiedName(attribute.Name) is AttributeName or AttributeTypeName; + + private static string? UnqualifiedName(CSharp.NameSyntax name) + => name switch + { + CSharp.QualifiedNameSyntax qualified => qualified.Right.Identifier.ValueText, + CSharp.AliasQualifiedNameSyntax aliased => aliased.Name.Identifier.ValueText, + CSharp.SimpleNameSyntax simple => simple.Identifier.ValueText, + _ => null, + }; + + private static string? DeclaringMethod(CSharp.ParameterSyntax parameter) + => parameter.Parent?.Parent switch + { + CSharp.MethodDeclarationSyntax method => method.Identifier.ValueText, + CSharp.LocalFunctionStatementSyntax function => function.Identifier.ValueText, + _ => null, + }; + + private static HashSet CollectReferenced(IAssemblySymbol assembly) + { + var names = new HashSet(StringComparer.Ordinal); + CollectReferenced(assembly.GlobalNamespace, names); + + return names; + } + + private static void CollectReferenced(INamespaceOrTypeSymbol container, HashSet names) + { + foreach (var member in container.GetMembers()) + { + if (member is INamespaceOrTypeSymbol nested) + { + CollectReferenced(nested, names); + } + else if (member is IMethodSymbol method && method.Parameters.Any(LocalizerContract.IsKey)) + { + names.Add(method.Name); + } + } + } +} diff --git a/src/Forma.Locali.Engine/Sources/LocalizerContract.cs b/src/Forma.Locali.Engine/Sources/LocalizerContract.cs index 46fb8c6..1b0b0f8 100644 --- a/src/Forma.Locali.Engine/Sources/LocalizerContract.cs +++ b/src/Forma.Locali.Engine/Sources/LocalizerContract.cs @@ -17,6 +17,40 @@ public static class LocalizerContract /// The name a lookup goes through when it is not an indexer. public const string ResolveMethod = "Resolve"; + /// The metadata name of the attribute marking a parameter whose argument is a localization key. + public const string KeyAttributeMetadataName = "Forma.Framework.Localization.LocaleKeyAttribute"; + + private const string KeyAttributeName = "LocaleKeyAttribute"; + + /// Whether the parameter carries the attribute marking its argument as a localization key. + /// The parameter to judge. + public static bool IsKey(IParameterSymbol parameter) + { + ArgumentNullException.ThrowIfNull(parameter); + + return parameter.GetAttributes().Any(attribute => + attribute.AttributeClass is { Name: KeyAttributeName } type && type.ToDisplayString() == KeyAttributeMetadataName); + } + + /// The ordinal of the method's first parameter whose argument is a localization key, or null when none is. + /// The method a call binds to. + public static int? KeyOrdinal(IMethodSymbol method) + { + ArgumentNullException.ThrowIfNull(method); + + return method.Parameters.FirstOrDefault(IsKey)?.Ordinal; + } + + /// The type whose translations a call's key is read in, the method's one type argument, or null when it has not exactly one. + /// The method a call binds to. + public static INamedTypeSymbol? ResourceOf(IMethodSymbol method) + { + ArgumentNullException.ThrowIfNull(method); + + // The factory reads an open definition, as it does for the generic contract's argument. + return method.TypeArguments is [INamedTypeSymbol resource] ? resource.OriginalDefinition : null; + } + /// The type whose translations the receiver reads, or null when it names none. /// The type the lookup was made through. /// The generic contract as the compilation resolved it. diff --git a/src/Forma.Locali.Engine/Sources/SemanticCallsiteFinder.cs b/src/Forma.Locali.Engine/Sources/SemanticCallsiteFinder.cs index cae1472..9c327a9 100644 --- a/src/Forma.Locali.Engine/Sources/SemanticCallsiteFinder.cs +++ b/src/Forma.Locali.Engine/Sources/SemanticCallsiteFinder.cs @@ -8,7 +8,7 @@ namespace Forma.Locali.Sources; using CSharp = Microsoft.CodeAnalysis.CSharp.Syntax; using VisualBasic = Microsoft.CodeAnalysis.VisualBasic.Syntax; -/// Finds callsites by binding the receiver to the localizer contract. +/// Finds callsites by binding the receiver to the localizer contract, and calls by binding a parameter marked as a localization key. public sealed class SemanticCallsiteFinder : ILocalizerCallsiteFinder { /// @@ -32,15 +32,17 @@ public LocalizerLookups Find(Compilation compilation, SyntaxTree tree) // The operation tree is what the compiler bound, in the same shape for every language it reads, so the // walk reads it rather than the syntax; an indexer and a default property are one operation there. - private static (IOperation? Receiver, ImmutableArray Arguments)? Lookup(IOperation operation, INamedTypeSymbol contract) + private static BoundLookup? Lookup(IOperation operation, INamedTypeSymbol contract, INamedTypeSymbol? generic) => operation switch { IPropertyReferenceOperation { Arguments.Length: > 0 } indexer when LocalizerContract.Implements(indexer.Property.ContainingType, contract) - => (indexer.Instance, indexer.Arguments), + => new(indexer.Arguments, 0, Resource(indexer.Instance, generic), KeyedMethod: null), IInvocationOperation { TargetMethod.Name: LocalizerContract.ResolveMethod } invocation when LocalizerContract.Implements(invocation.TargetMethod.ContainingType, contract) - => (invocation.Instance, invocation.Arguments), + => new(invocation.Arguments, 0, Resource(invocation.Instance, generic), KeyedMethod: null), + IInvocationOperation invocation when LocalizerContract.KeyOrdinal(invocation.TargetMethod) is { } ordinal + => new(invocation.Arguments, ordinal, LocalizerContract.ResourceOf(invocation.TargetMethod), invocation.TargetMethod), _ => null, }; @@ -52,27 +54,30 @@ private static LocalizerLookups Walk(Compilation compilation, SyntaxTree tree, I var unreadable = new List(); var interpolated = new List(); - foreach (var node in Candidates(tree.GetRoot(), compilation.Language)) + foreach (var node in Candidates(compilation, tree)) { // A lookup is read at the node its operation stands on, so a node inside it does not read it again. if (model.GetOperation(node) is not { } operation || operation.Syntax != node - || Lookup(operation, contract) is not var (receiver, arguments)) + || Lookup(operation, contract, generic) is not { } lookup) { continue; } - if (SemanticCallsiteReader.Read(operation, arguments) is { } site) + if (SemanticCallsiteReader.Read(operation, lookup.Arguments, lookup.KeyOrdinal) is { } site) { - lookups.Add(site with { ResourceType = Resource(receiver, generic) }); + var resolved = site with { ResourceType = lookup.Resource }; + + lookups.Add(lookup.KeyedMethod is { } method ? Keyed(resolved, method, lookup.KeyOrdinal) : resolved); } - else if (!LocalizerContract.InsideLocalizer(model.GetEnclosingSymbol(node.SpanStart), contract)) + else if (!LocalizerContract.InsideLocalizer(model.GetEnclosingSymbol(node.SpanStart), contract) + && !SemanticCallsiteReader.ForwardsKey(lookup.Arguments, lookup.KeyOrdinal)) { - if (SemanticCallsiteReader.InterpolatedKey(arguments) is { } key) + if (SemanticCallsiteReader.InterpolatedKey(lookup.Arguments, lookup.KeyOrdinal) is { } key) { interpolated.Add(key); } - else if (SemanticCallsiteReader.UnreadableKey(arguments) is { } location) + else if (SemanticCallsiteReader.UnreadableKey(lookup.Arguments, lookup.KeyOrdinal) is { } location) { unreadable.Add(location); } @@ -82,22 +87,39 @@ private static LocalizerLookups Walk(Compilation compilation, SyntaxTree tree, I return new LocalizerLookups(lookups, unreadable, interpolated); } + // A method marked with a key passes its values on as a lookup does only where one params parameter follows the + // key, and what it returns is its own, so its values are counted there alone and its result is never format text. + private static LocalizerLookup Keyed(LocalizerLookup site, IMethodSymbol method, int keyOrdinal) + => site with + { + Arguments = method.Parameters.Length == keyOrdinal + 2 && method.Parameters[^1].IsParams ? site.Arguments : LocalizerArguments.Unknown, + IsFormatString = false, + }; + // The compiler binds a whole member the moment any node in it is asked about, and a lookup is written as an - // element access or a call named Resolve, so only nodes of those shapes are asked and a member holding neither - // is never bound at all. - private static IEnumerable Candidates(SyntaxNode root, string language) - => language switch + // element access, a call named Resolve or a call to a method marked with a key, so only nodes of those shapes are + // asked and a member holding none is never bound at all. + private static IEnumerable Candidates(Compilation compilation, SyntaxTree tree) + { + var nodes = tree.GetRoot().DescendantNodes(); + + return compilation.Language switch { - LanguageNames.CSharp => root.DescendantNodes().Where(IsCSharpCandidate), - LanguageNames.VisualBasic => root.DescendantNodes().Where(IsVisualBasicCandidate), - _ => root.DescendantNodes(), + LanguageNames.CSharp => CSharpCandidates(nodes, LocaleKeyMethodNames.Get(compilation)), + LanguageNames.VisualBasic => nodes.Where(IsVisualBasicCandidate), + _ => nodes, }; + } - private static bool IsCSharpCandidate(SyntaxNode node) + private static IEnumerable CSharpCandidates(IEnumerable nodes, IReadOnlySet keyed) + => nodes.Where(node => IsCSharpCandidate(node, keyed)); + + private static bool IsCSharpCandidate(SyntaxNode node, IReadOnlySet keyed) => node switch { CSharp.ElementAccessExpressionSyntax or CSharp.ElementBindingExpressionSyntax => true, - CSharp.InvocationExpressionSyntax invocation => Invoked(invocation.Expression) == LocalizerContract.ResolveMethod, + CSharp.InvocationExpressionSyntax invocation => Invoked(invocation.Expression) is { } name + && (name == LocalizerContract.ResolveMethod || keyed.Contains(name)), _ => false, }; @@ -117,4 +139,12 @@ private static bool IsVisualBasicCandidate(SyntaxNode node) // The indexer is declared on the unbound contract, so the type it translates is only on the receiver. private static INamedTypeSymbol? Resource(IOperation? receiver, INamedTypeSymbol? generic) => generic is null || receiver is null ? null : LocalizerContract.ResourceOf(receiver.Type, generic); + + // A lookup as the compiler bound it: its arguments, the ordinal its key binds to, the type its key is read in, + // and the method a call marked with a key binds to. + private readonly record struct BoundLookup( + ImmutableArray Arguments, + int KeyOrdinal, + INamedTypeSymbol? Resource, + IMethodSymbol? KeyedMethod); } diff --git a/src/Forma.Locali.Engine/Sources/SemanticCallsiteReader.cs b/src/Forma.Locali.Engine/Sources/SemanticCallsiteReader.cs index a964450..c08195f 100644 --- a/src/Forma.Locali.Engine/Sources/SemanticCallsiteReader.cs +++ b/src/Forma.Locali.Engine/Sources/SemanticCallsiteReader.cs @@ -14,10 +14,11 @@ internal static class SemanticCallsiteReader /// The lookup its arguments describe, or null when the key is not readable text. /// The lookup operation. /// The arguments the lookup bound. - public static LocalizerLookup? Read(IOperation lookup, ImmutableArray arguments) + /// The ordinal of the parameter the key binds to, whose following parameters take the values. + public static LocalizerLookup? Read(IOperation lookup, ImmutableArray arguments, int keyOrdinal) { // The compiler folds a constant, so a key written as one is as readable as a literal spelled out. - if (Key(arguments)?.Value is not { ConstantValue: { HasValue: true, Value: string text } } value) + if (Key(arguments, keyOrdinal)?.Value is not { ConstantValue: { HasValue: true, Value: string text } } value) { return null; } @@ -25,13 +26,14 @@ internal static class SemanticCallsiteReader // The span covers a reference rather than a quoted literal, so the key starts where the span does. var offset = Unconverted(value) is ILiteralOperation literal ? Offset(literal) : 0; - return new LocalizerLookup(new LocalizationKeyReference(text, Span(value), offset), Supplies(arguments), IsFormatString(lookup)); + return new LocalizerLookup(new LocalizationKeyReference(text, Span(value), offset), Supplies(arguments, keyOrdinal), IsFormatString(lookup)); } /// Where a lookup writes its key as an interpolated string with holes, or null when it does not. /// The arguments the lookup bound. - public static FileLinePositionSpan? InterpolatedKey(ImmutableArray arguments) - => Key(arguments)?.Value is { } value + /// The ordinal of the parameter the key binds to. + public static FileLinePositionSpan? InterpolatedKey(ImmutableArray arguments, int keyOrdinal) + => Key(arguments, keyOrdinal)?.Value is { } value && Unconverted(value) is IInterpolatedStringOperation interpolated && interpolated.Parts.OfType().Any() ? Span(value) @@ -39,12 +41,21 @@ internal static class SemanticCallsiteReader /// Where a lookup writes the key that could not be read, or null when it writes no key at all. /// The arguments the lookup bound. - public static FileLinePositionSpan? UnreadableKey(ImmutableArray arguments) - => Key(arguments) is { } key ? Span(key) : null; + /// The ordinal of the parameter the key binds to. + public static FileLinePositionSpan? UnreadableKey(ImmutableArray arguments, int keyOrdinal) + => Key(arguments, keyOrdinal) is { } key ? Span(key) : null; - // The key is the lookup's first parameter, wherever a named argument writes it. - private static IArgumentOperation? Key(ImmutableArray arguments) - => arguments.FirstOrDefault(argument => argument.Parameter is { Ordinal: 0 }); + /// Whether a lookup's key is a parameter that is itself marked as a localization key, which its own callers write. + /// The arguments the lookup bound. + /// The ordinal of the parameter the key binds to. + public static bool ForwardsKey(ImmutableArray arguments, int keyOrdinal) + => Key(arguments, keyOrdinal)?.Value is { } value + && Unconverted(value) is IParameterReferenceOperation { Parameter: var parameter } + && LocalizerContract.IsKey(parameter); + + // The key binds to one parameter, wherever a named argument writes it. + private static IArgumentOperation? Key(ImmutableArray arguments, int keyOrdinal) + => arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == keyOrdinal); // A value passed alongside the format text is no less an argument to string.Format, so the parameter the // lookup actually bound to is what decides this, not where it sits in the source; a named argument can @@ -64,9 +75,9 @@ private static bool IsFormatString(IOperation lookup) }; } - private static LocalizerArguments Supplies(ImmutableArray arguments) + private static LocalizerArguments Supplies(ImmutableArray arguments, int keyOrdinal) { - var values = arguments.Where(argument => argument.Parameter is { Ordinal: > 0 }).ToArray(); + var values = arguments.Where(argument => argument.Parameter?.Ordinal > keyOrdinal).ToArray(); if (values.Length == 0) { return LocalizerArguments.None; diff --git a/src/Forma.Locali/Forma.Locali.csproj b/src/Forma.Locali/Forma.Locali.csproj index 8ec8cc2..ee0e257 100644 --- a/src/Forma.Locali/Forma.Locali.csproj +++ b/src/Forma.Locali/Forma.Locali.csproj @@ -15,7 +15,7 @@ true locali formaui-net.Locali - 1.0.0-preview.11 + 1.0.0-preview.12 Eyal Alon A linter for Forma.Framework.Localization: reads locale files against the runtime's rules and every IStringLocale callsite against what the runtime will do with it. localization;linter;blazor;forma diff --git a/tests/Forma.Locali.Engine.Tests/Rules/Projects/LocalizationCoverageCheckerTests.cs b/tests/Forma.Locali.Engine.Tests/Rules/Projects/LocalizationCoverageCheckerTests.cs index ac79357..f49158c 100644 --- a/tests/Forma.Locali.Engine.Tests/Rules/Projects/LocalizationCoverageCheckerTests.cs +++ b/tests/Forma.Locali.Engine.Tests/Rules/Projects/LocalizationCoverageCheckerTests.cs @@ -246,6 +246,24 @@ public async Task Should_ignore_a_lookup_whose_receiver_names_no_type() Assert.Empty(diagnostics); } + [Fact] + public async Task Should_report_a_key_text_carried_as_data_reads_that_its_type_argument_leaves_out() + { + // Arrange + using var directory = new TempDirectory(); + var file = directory.Write(HebrewFile, LocalizationFileFakeData.Valid); + + // Act + var diagnostics = await LintAsync(directory, """public LocalizableText Title { get; } = LocalizableText.For("Tomorrow");""", file); + + // Assert + var diagnostic = Assert.Single(diagnostics); + + Assert.Multiple( + () => Assert.Equal(LintingCodes.MissingLocalization, diagnostic.Code), + () => Assert.Contains("'Tomorrow'", diagnostic.Message, StringComparison.Ordinal)); + } + [Fact] public async Task Should_ignore_a_file_carrying_no_recognised_culture() { diff --git a/tests/Forma.Locali.Engine.Tests/Rules/Sources/CallsiteRuleTests.cs b/tests/Forma.Locali.Engine.Tests/Rules/Sources/CallsiteRuleTests.cs index 094318e..66ea19d 100644 --- a/tests/Forma.Locali.Engine.Tests/Rules/Sources/CallsiteRuleTests.cs +++ b/tests/Forma.Locali.Engine.Tests/Rules/Sources/CallsiteRuleTests.cs @@ -164,6 +164,64 @@ public void Should_still_count_a_value_that_is_an_array_of_another_element_type( Assert.Equal([LintingCodes.ValueCountMismatch], diagnostics.Codes()); } + [Fact] + public void Should_report_text_carried_as_data_supplying_fewer_values_than_its_key_names_holes() + { + // Act + var diagnostics = SourceLinterTestFactory.Lint( + """public LocalizableText Read() => LocalizableText.For("Stored {stored} of {total}.", 1);"""); + + // Assert + Assert.Equal([LintingCodes.ValueCountMismatch], diagnostics.Codes()); + } + + [Fact] + public void Should_accept_text_carried_as_data_supplying_one_value_for_each_hole() + { + // Act + var diagnostics = SourceLinterTestFactory.Lint( + """public LocalizableText Read() => LocalizableText.For("Stored {stored} of {total}.", 1, 2);"""); + + // Assert + Assert.Empty(diagnostics); + } + + [Fact] + public void Should_report_text_carried_as_data_writing_something_shaped_like_a_hole_it_cannot_read() + { + // Act + var diagnostics = SourceLinterTestFactory.Lint("""public LocalizableText Read() => LocalizableText.For("Value: {0}", 1);"""); + + // Assert + Assert.Contains(LintingCodes.HoleShapedText, diagnostics.Codes()); + } + + [Fact] + public void Should_leave_a_call_whose_method_takes_no_values_after_its_key_uncounted() + { + // Act — the method may fill the key's holes from values of its own. + var diagnostics = SourceLinterTestFactory.Lint(""" + private static string Count([LocaleKey] string key, int count) => key; + public string Read() => Count("{Count} of {Total} items", 3); + """); + + // Assert + Assert.Empty(diagnostics); + } + + [Fact] + public void Should_leave_a_call_marking_its_key_passed_to_string_format_alone() + { + // Act — what the method returns is its own, so the rule can't read it as a lookup's result. + var diagnostics = SourceLinterTestFactory.Lint(""" + private static string Label([LocaleKey] string key) => key; + public string Show() => string.Format(Label("Stored items."), 1); + """); + + // Assert + Assert.DoesNotContain(LintingCodes.ReformattedLookup, diagnostics.Codes()); + } + [Theory] [InlineData("Value: {0}")] [InlineData("Value: {0,5}")] diff --git a/tests/Forma.Locali.Engine.Tests/Sources/SemanticCallsiteFinderTests.cs b/tests/Forma.Locali.Engine.Tests/Sources/SemanticCallsiteFinderTests.cs index 0f4043d..efe16a1 100644 --- a/tests/Forma.Locali.Engine.Tests/Sources/SemanticCallsiteFinderTests.cs +++ b/tests/Forma.Locali.Engine.Tests/Sources/SemanticCallsiteFinderTests.cs @@ -1,5 +1,7 @@ namespace Forma.Locali.Tests.Sources; +using Microsoft.CodeAnalysis; + public sealed class SemanticCallsiteFinderTests { [Fact] @@ -101,6 +103,129 @@ End Sub Assert.Equal(LocalizerArguments.Positional(1), site.Arguments); } + [Fact] + public void Should_read_a_call_to_a_method_marking_its_key_as_a_lookup_in_its_type_argument() + { + // Act + var site = Assert.Single(Find(""" + private static string Label([LocaleKey] string key, params object?[] values) => key; + public void Show() => _ = Label>("Stored {count}.", 1); + """)); + + // Assert + Assert.Multiple( + () => Assert.Equal("Stored {count}.", site.Key.Text), + () => Assert.Equal(LocalizerArguments.Positional(1), site.Arguments), + () => Assert.Equal("Holder`1", site.ResourceType?.MetadataName)); + } + + [Fact] + public void Should_read_text_carried_as_data_as_a_lookup() + { + // Act + var site = Assert.Single(Find("""public LocalizableText Title { get; } = LocalizableText.For("{Count} results today", 12);""")); + + // Assert + Assert.Multiple( + () => Assert.Equal("{Count} results today", site.Key.Text), + () => Assert.Equal(LocalizerArguments.Positional(1), site.Arguments), + () => Assert.Equal("Widget", site.ResourceType?.Name)); + } + + [Fact] + public void Should_read_a_call_to_a_method_a_referenced_assembly_marks() + { + // Arrange — a host's own method reaches the project that calls it as metadata. + var library = CompilationTestFactory.Referenced(""" + using Forma.Framework.Localization; + + namespace Contoso.Labels; + + public static class Labels + { + public static string Label([LocaleKey] string key) => key; + } + """).WithAssemblyName("Contoso.Labels"); + using var image = new MemoryStream(); + Assert.True(library.Emit(image, cancellationToken: TestContext.Current.CancellationToken).Success); + + var compilation = CompilationTestFactory.Referenced(""" + namespace Scratch; + + public sealed class Widget + { + public void Show() => _ = Contoso.Labels.Labels.Label("Today"); + } + """).AddReferences(MetadataReference.CreateFromImage(image.ToArray())); + + // Act + var site = Assert.Single(compilation.SyntaxTrees.SelectMany(tree => new SemanticCallsiteFinder().Find(compilation, tree).Lookups)); + + // Assert + Assert.Multiple( + () => Assert.Equal("Today", site.Key.Text), + () => Assert.Equal("Widget", site.ResourceType?.Name)); + } + + [Fact] + public void Should_name_no_type_for_a_call_to_a_method_without_one_type_argument() + { + // Act + var site = Assert.Single(Find(""" + private static string Label([LocaleKey] string key) => key; + public void Show() => _ = Label("Today"); + """)); + + // Assert + Assert.Null(site.ResourceType); + } + + [Fact] + public void Should_leave_the_values_of_a_call_uncounted_where_no_params_parameter_follows_its_key() + { + // Act — the method may fill the key's holes from values of its own. + var site = Assert.Single(Find(""" + private static string Count([LocaleKey] string key) => key; + public void Show() => _ = Count("{Count} items"); + """)); + + // Assert + Assert.Equal(LocalizerArguments.Unknown, site.Arguments); + } + + [Fact] + public void Should_read_the_key_a_named_argument_writes_after_another() + { + // Act + var site = Assert.Single(Find(""" + private static string Label(int count, [LocaleKey] string key) => key; + public void Show() => _ = Label(key: "Today", count: 1); + """)); + + // Assert + Assert.Equal("Today", site.Key.Text); + } + + [Fact] + public void Should_read_a_Visual_Basic_call_to_a_method_marking_its_key() + { + // Act + var site = Assert.Single(FindVisualBasic(""" + Private Shared Function Label(Of TSource)( key As String, ParamArray values As Object()) As String + Return key + End Function + Public Sub Show() + Dim text = Label(Of Widget)("Stored {count}.", 1) + End Sub + """)); + + // Assert + Assert.Multiple( + () => Assert.Equal("Stored {count}.", site.Key.Text), + () => Assert.Equal(LocalizerArguments.Positional(1), site.Arguments), + () => Assert.Equal("Widget", site.ResourceType?.Name)); + } + private static IReadOnlyList FindVisualBasic(string body) { var compilation = CompilationTestFactory.ReferencedVisualBasic($""" diff --git a/tests/Forma.Locali.Engine.Tests/Sources/SourceLinterTests.cs b/tests/Forma.Locali.Engine.Tests/Sources/SourceLinterTests.cs index 26ce183..026d573 100644 --- a/tests/Forma.Locali.Engine.Tests/Sources/SourceLinterTests.cs +++ b/tests/Forma.Locali.Engine.Tests/Sources/SourceLinterTests.cs @@ -220,6 +220,55 @@ public sealed class Wrapper Assert.Equal(LintingCodes.KeyNotAnalysed, Assert.Single(diagnostics).Code); } + [Fact] + public void Should_report_a_key_a_call_writes_as_an_expression() + { + // Act + var diagnostics = Lint(""" + private static LocalizableText Label([LocaleKey] string key) => LocalizableText.For(key); + public void Show(object column) => _ = Label(column.ToString()!); + """); + + // Assert + Assert.Equal(LintingCodes.KeyNotAnalysed, Assert.Single(diagnostics).Code); + } + + [Fact] + public void Should_report_an_interpolated_key_a_call_writes() + { + // Act + var diagnostics = Lint("""public void Show(int count) => _ = LocalizableText.For($"Stored {count}.");""", LintingCodes.InterpolatedKey); + + // Assert + Assert.Equal(LintingCodes.InterpolatedKey, Assert.Single(diagnostics).Code); + } + + [Fact] + public void Should_leave_a_key_forwarded_from_a_parameter_marked_as_a_key_alone() + { + // Arrange — the key is written where the method is called, and checked there. + var compilation = Compile(""" + public LocalizedText Show([LocaleKey] string key) => L[key]; + public LocalizableText Carry([LocaleKey] string key) => LocalizableText.For(key); + """); + + // Act + var diagnostics = Reported(compilation); + + // Assert + Assert.Empty(diagnostics); + } + + [Fact] + public void Should_report_a_key_forwarded_from_a_parameter_that_is_not_marked() + { + // Act + var diagnostics = Lint("""public LocalizableText Carry(string key) => LocalizableText.For(key);"""); + + // Assert + Assert.Equal(LintingCodes.KeyNotAnalysed, Assert.Single(diagnostics).Code); + } + private static IReadOnlyList Lint(string body) => Reported(Compile(body));