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
4 changes: 4 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ trim_trailing_whitespace = true

[*.{yml,props,targets,csproj,slnx}]
indent_size = 2

[*.cs]
# Use range operator
dotnet_diagnostic.IDE0057.severity = silent
65 changes: 13 additions & 52 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,35 @@
[![NuGet](https://img.shields.io/nuget/v/Ramstack.LocaleAlignment.svg)](https://nuget.org/packages/Ramstack.LocaleAlignment)
[![MIT](https://img.shields.io/github/license/rameel/ramstack.localealignment)](https://github.com/rameel/ramstack.localealignment/blob/main/LICENSE)

A Roslyn source generator that applies POSIX locale user overrides to .NET `CultureInfo` on Unix-like systems.
A small library for aligning .NET culture settings with POSIX locale user overrides on Unix-like systems.

![](https://github.com/rameel/ramstack.localealignment/blob/main/assets/screenshot.png)
![screenshot](https://raw.githubusercontent.com/rameel/ramstack.localealignment/c3abd3eb89b4b88965dede9f4a6b1eccb1b74b21/assets/screenshot.png)

## Problem
On Unix platforms, .NET determines the process culture using a limited subset of locale-related environment variables,
in the following order:
* `LC_ALL`
* `LC_MESSAGES`
* `LANG`
On Unix-like systems, .NET determines the process culture using only a subset of locale-related environment variables (`LC_ALL`, `LC_MESSAGES`, `LANG`).

Overrides such as `LC_NUMERIC`, `LC_TIME`, and `LC_MONETARY` are **ignored** for `CultureInfo` initialization,
even though they are explicitly defined by POSIX and commonly used by users to fine-tune locale behavior.

As a result, applications may observe:

* Incorrect numeric formatting when `LC_NUMERIC` is overridden
* Incorrect date and time formatting when `LC_TIME` is overridden
* Incorrect currency formatting when `LC_MONETARY` is overridden
Category-specific overrides such as `LC_NUMERIC`, `LC_TIME`, and `LC_MONETARY` **are not applied** during `CultureInfo` initialization.

This behavior is documented and discussed in the .NET runtime repository:

* [https://github.com/dotnet/runtime/issues/110095](https://github.com/dotnet/runtime/issues/110095)

## What this package does
`Ramstack.LocaleAlignment` provides a **source generator** that injects a module initializer into your application.

At startup, it:
* Detects whether the application is running on a non-Windows platform
* Respects .NET globalization invariant mode
* Reads the following environment variables independently:
* `LC_NUMERIC`
* `LC_MONETARY`
* `LC_TIME`
* Applies their effects to `CultureInfo` in a POSIX-consistent manner

The generator creates a derived `CultureInfo` instance that:
* Uses date/time formatting from `LC_TIME`
* Uses numeric formatting from `LC_NUMERIC`
* Uses currency formatting from `LC_MONETARY`
## Project structure
This repository contains two packages:

The resulting culture is applied to:
### Ramstack.LocaleAlignment
A small runtime library that explicitly applies POSIX locale category overrides to .NET `CultureInfo`.

* `CultureInfo.CurrentCulture`
* `CultureInfo.CurrentUICulture`
* `CultureInfo.DefaultThreadCurrentCulture`
* `CultureInfo.DefaultThreadCurrentUICulture`
It is intended to be called manually during application startup and can be used from any .NET language (`C#`, `F#`, `Nemerle`, etc.).

All initialization is performed defensively and never throws.
### Ramstack.LocaleAlignment.Generator
A dependency-free Roslyn source generator that enables automatic locale alignment at application startup.

## Usage
Add the package to your project:
The generator injects the equivalent of a `LocaleAlignment.Apply()` call directly into the compiled output,
without introducing any runtime dependency or requiring any configuration.

```bash
dotnet add package Ramstack.LocaleAlignment
```

No additional configuration or code changes are required.

The generated initializer runs automatically at module load time.

## Platform behavior
* **Windows**: no effect (not required - Windows automatically applies all user locale overrides)
* **Unix / Linux**: locale alignment is applied
* **Invariant globalization mode**: no effect

## Supported versions

| | Version |
|------|----------------|
| .NET | 6, 7, 8, 9, 10 |
Expand Down
1 change: 1 addition & 0 deletions Ramstack.LocaleAlignment.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</Folder>
<Folder Name="/src/">
<Project Path="src/Ramstack.LocaleAlignment/Ramstack.LocaleAlignment.csproj" />
<Project Path="src/Ramstack.LocaleAlignment.Generator/Ramstack.LocaleAlignment.Generator.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Ramstack.LocaleAlignment.Tests/Ramstack.LocaleAlignment.Tests.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,44 +1,50 @@
using System.Text;

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;

namespace Ramstack.LocaleAlignment;
namespace Ramstack.LocaleAlignment.Generator;

[Generator]
file sealed class LocaleAlignmentGenerator : IIncrementalGenerator
public sealed class LocaleAlignmentGenerator : IIncrementalGenerator
{
/// <inheritdoc />
public void Initialize(IncrementalGeneratorInitializationContext context) =>
context.RegisterPostInitializationOutput(GenerateLocaleInitializer);
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var version = context.CompilationProvider.Select((c, _) => c is CSharpCompilation csharp ? (LanguageVersion?)csharp.LanguageVersion : null);
context.RegisterSourceOutput(version, GenerateLocaleInitializer);
}

private static void GenerateLocaleInitializer(IncrementalGeneratorPostInitializationContext context)
private static void GenerateLocaleInitializer(SourceProductionContext context, LanguageVersion? version)
{
const string Source = """
// <auto-generated/>
if (version is null)
return;

var modifier = version == LanguageVersion.CSharp10 ? "internal" : "file";

using System;
using System.Globalization;
using System.Runtime.CompilerServices;
var source = $$"""
// <auto-generated />

#nullable enable

file static class CultureInfoInitializer
[EditorBrowsable(EditorBrowsableState.Never)]
{{modifier}} static class LocaleAlignment
{
[ModuleInitializer]
public static void Initialize()
public static void Apply()
{
try
{
InitializeCore();
ApplyCore();
}
catch
{
// Ignore errors
}
}

private static void InitializeCore()
private static void ApplyCore()
{
if (!OperatingSystem.IsWindows() && !IsGlobalizationInvariant())
{
Expand Down Expand Up @@ -150,28 +156,25 @@ static string ToBcp47(string posix)

if (!modifier.IsEmpty)
{
modifier = modifier switch
{
// Scripts
"arabic" => "Arab",
"cyrillic" => "Cyrl",
"devanagari" => "Deva",
"latin" => "Latn",
// "adlam" => "Adlm",
// "bengali" => "Beng",
// "gurmukhi" => "Guru",
// "olchiki" => "Olck",
// "orya" or "odia" => "Orya",
// "telugu" => "Telu",
// "tifinagh" => "Tfng",
// "vai" => "Vaii",

// Variants
"valencia" => "valencia",

// Skip
_ => ""
};
// Scripts
if (modifier.SequenceEqual("arabic")) modifier = "Arab";
else if (modifier.SequenceEqual("cyrillic")) modifier = "Cyrl";
else if (modifier.SequenceEqual("devanagari")) modifier = "Deva";
else if (modifier.SequenceEqual("latin")) modifier = "Latn";
// else if (modifier.SequenceEqual("adlam")) modifier = "Adlm";
// else if (modifier.SequenceEqual("bengali")) modifier = "Beng";
// else if (modifier.SequenceEqual("gurmukhi")) modifier = "Guru";
// else if (modifier.SequenceEqual("olchiki")) modifier = "Olck";
// else if (modifier.SequenceEqual("orya")) modifier = "odia" => "Orya";
// else if (modifier.SequenceEqual("telugu")) modifier = "Telu";
// else if (modifier.SequenceEqual("tifinagh")) modifier = "Tfng";
// else if (modifier.SequenceEqual("vai")) modifier = "Vaii";

// Variants
else if (modifier.SequenceEqual("valencia")) modifier = "valencia";
// skip
else
modifier = default;
}

var buffer = posix.Length > 32
Expand Down Expand Up @@ -220,7 +223,6 @@ static string ToBcp47(string posix)
buffer[i] = '-';
#endif

// create string
posix = new string(buffer);
return posix;
}
Expand Down Expand Up @@ -257,6 +259,6 @@ static bool IsGlobalizationInvariant()

""";

context.AddSource("CultureInfoInitializer.g.cs", SourceText.From(Source, Encoding.UTF8));
context.AddSource("LocaleAlignment.g.cs", SourceText.From(source, Encoding.UTF8));
}
}
53 changes: 53 additions & 0 deletions src/Ramstack.LocaleAlignment.Generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Ramstack.LocaleAlignment.Generator
[![NuGet](https://img.shields.io/nuget/v/Ramstack.LocaleAlignment.Generator.svg)](https://nuget.org/packages/Ramstack.LocaleAlignment.Generator)
[![MIT](https://img.shields.io/github/license/rameel/ramstack.localealignment)](https://github.com/rameel/ramstack.localealignment/blob/main/LICENSE)

Utility that applies POSIX locale user overrides to .NET `CultureInfo` on Unix-like systems.

![screenshot](https://raw.githubusercontent.com/rameel/ramstack.localealignment/c3abd3eb89b4b88965dede9f4a6b1eccb1b74b21/assets/screenshot.png)

## Problem
On Unix-like systems, .NET determines the process culture using only a subset of locale-related environment variables (`LC_ALL`, `LC_MESSAGES`, `LANG`).

Category-specific overrides such as `LC_NUMERIC`, `LC_TIME`, and `LC_MONETARY` **are not applied** during `CultureInfo` initialization.

This behavior is documented and discussed in the .NET runtime repository:
* [https://github.com/dotnet/runtime/issues/110095](https://github.com/dotnet/runtime/issues/110095)

## What this package does
`Ramstack.LocaleAlignment.Generator` provides a **source generator** that injects a module initializer into your application.

At startup, it:
* Detects whether the application is running on a non-Windows platform
* Applies the following environment variables to `CultureInfo`:
* `LC_NUMERIC`
* `LC_MONETARY`
* `LC_TIME`
* Updates the current and default thread cultures
* Respects .NET globalization invariant mode

Has no effect on Windows platforms.

## Usage
Add the package to your project:

```bash
dotnet add package Ramstack.LocaleAlignment.Generator
```

No additional configuration or code changes are required.

The generated initializer runs automatically at module load time.

## Supported versions

| | Version |
|------|----------------|
| .NET | 6, 7, 8, 9, 10 |

## Contributions
Bug reports and contributions are welcome.

## License
This package is released as open source under the **MIT License**.<br />
See the [LICENSE](https://github.com/rameel/ramstack.localealignment/blob/main/LICENSE) file for more details.
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<OutputType>Library</OutputType>
<Description>Source generator that applies POSIX locale user overrides to .NET CultureInfo on Unix-like systems.</Description>
<Nullable>enable</Nullable>
<ImplicitUsings>true</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Deterministic>true</Deterministic>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<PackageType>Analyzer</PackageType>
<IncludeBuildOutput>false</IncludeBuildOutput>
</PropertyGroup>

<PropertyGroup>
<Authors>Rameel</Authors>
<RepositoryUrl>https://github.com/rameel/ramstack.localealignment</RepositoryUrl>
<PackageProjectUrl>https://github.com/rameel/ramstack.localealignment#readme</PackageProjectUrl>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageTags>ICU Unix Linux FreeBSD CultureInfo Locale POSIX</PackageTags>
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
</PropertyGroup>

<PropertyGroup>
<NoWarn>$(NoWarn);NU5128</NoWarn>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.1" PrivateAssets="all" />
<PackageReference Include="MinVer" Version="7.0.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<Compile Update="LocaleAlignmentGenerator.cs">
<Generator>true</Generator>
</Compile>
</ItemGroup>

<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs/" Visible="false" />
</ItemGroup>

<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>

</Project>
Loading