Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSource>`'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*
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<TSource>`'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
Expand Down
29 changes: 24 additions & 5 deletions docs/linting-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<SectionMenu>("{Count} results today", 12) // right: one value for its one hole
LocalizableText.For<SectionMenu>("{Count} results today", 12, 3) // LOC0201: one hole, two values
LocalizableText Title([LocaleKey] string key)
=> LocalizableText.For<SectionMenu>(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:

Expand Down Expand Up @@ -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<object?>`, 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

Expand Down Expand Up @@ -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

Expand All @@ -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}"`.
Expand Down Expand Up @@ -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.
116 changes: 116 additions & 0 deletions src/Forma.Locali.Engine/Sources/LocaleKeyMethodNames.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
namespace Forma.Locali.Sources;

using System.Runtime.CompilerServices;

using Microsoft.CodeAnalysis;

using CSharp = Microsoft.CodeAnalysis.CSharp.Syntax;

/// <summary>Names the methods a C# compilation declares or references whose parameter is marked as a localization key.</summary>
internal static class LocaleKeyMethodNames
{
private const string AttributeName = "LocaleKey";
private const string AttributeTypeName = "LocaleKeyAttribute";

private static readonly IReadOnlySet<string> None = new HashSet<string>();

// 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<Compilation, Lazy<IReadOnlySet<string>>> ByCompilation = [];
private static readonly ConditionalWeakTable<MetadataReference, Lazy<IReadOnlySet<string>>> ByReference = [];

/// <summary>The names of the methods the compilation can call whose parameter is marked as a localization key.</summary>
/// <param name="compilation">The compilation.</param>
public static IReadOnlySet<string> Get(Compilation compilation)
{
ArgumentNullException.ThrowIfNull(compilation);

return ByCompilation.GetValue(compilation, key => new(() => Collect(key))).Value;
}

private static IReadOnlySet<string> 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<string>(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<string> names)
{
foreach (var parameter in tree.GetRoot().DescendantNodes().OfType<CSharp.ParameterSyntax>())
{
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<string> CollectReferenced(IAssemblySymbol assembly)
{
var names = new HashSet<string>(StringComparer.Ordinal);
CollectReferenced(assembly.GlobalNamespace, names);

return names;
}

private static void CollectReferenced(INamespaceOrTypeSymbol container, HashSet<string> 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);
}
}
}
}
34 changes: 34 additions & 0 deletions src/Forma.Locali.Engine/Sources/LocalizerContract.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,40 @@ public static class LocalizerContract
/// <summary>The name a lookup goes through when it is not an indexer.</summary>
public const string ResolveMethod = "Resolve";

/// <summary>The metadata name of the attribute marking a parameter whose argument is a localization key.</summary>
public const string KeyAttributeMetadataName = "Forma.Framework.Localization.LocaleKeyAttribute";

private const string KeyAttributeName = "LocaleKeyAttribute";

/// <summary>Whether the parameter carries the attribute marking its argument as a localization key.</summary>
/// <param name="parameter">The parameter to judge.</param>
public static bool IsKey(IParameterSymbol parameter)
{
ArgumentNullException.ThrowIfNull(parameter);

return parameter.GetAttributes().Any(attribute =>
attribute.AttributeClass is { Name: KeyAttributeName } type && type.ToDisplayString() == KeyAttributeMetadataName);
}

/// <summary>The ordinal of the method's first parameter whose argument is a localization key, or null when none is.</summary>
/// <param name="method">The method a call binds to.</param>
public static int? KeyOrdinal(IMethodSymbol method)
{
ArgumentNullException.ThrowIfNull(method);

return method.Parameters.FirstOrDefault(IsKey)?.Ordinal;
}

/// <summary>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.</summary>
/// <param name="method">The method a call binds to.</param>
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;
}

/// <summary>The type whose translations the receiver reads, or null when it names none.</summary>
/// <param name="receiver">The type the lookup was made through.</param>
/// <param name="generic">The generic contract as the compilation resolved it.</param>
Expand Down
Loading
Loading