diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6d6c964..54529b6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -28,10 +28,10 @@ - Converts to JSON and generates C# code files - Target framework: .NET 10.0 -- **LanguageTagsTests** (`LanguageTagsTests/LanguageTagsTests.csproj`) - - xUnit test suite with comprehensive coverage - - Uses AwesomeAssertions for test assertions - - Target framework: .NET 10.0 +- **LanguageTagsTests** (`LanguageTagsTests/LanguageTagsTests.csproj`) + - xUnit test suite with comprehensive coverage + - Uses AwesomeAssertions for test assertions + - Target framework: .NET 10.0 ### Key Directories @@ -47,6 +47,20 @@ - `build-release-task.yml`, `build-library-task.yml`: Build tasks - `get-version-task.yml`, `build-datebadge-task.yml`: Version and badge generation +### Project Configuration + +- **Directory.Build.props**: Common MSBuild properties shared across all projects + (`TargetFramework`, `Nullable`, `ImplicitUsings`, `AnalysisLevel`, `AnalysisMode`, + `EnableNETAnalyzers`, `ArtifactsPath`, `IsPackable`, `ManagePackageVersionsCentrally`) + live here at the solution root. Only add a property to a `.csproj` when it is + specific to that project or requires an explicit override of the shared default. + +- **Directory.Packages.props**: All NuGet package versions are centralised here via + `PackageVersion` items. Individual `.csproj` files use `PackageReference Include="..."` + with no `Version` attribute. Asset metadata (`PrivateAssets`, `IncludeAssets`) stays + in the `.csproj` `PackageReference` element. Use `VersionOverride` only when a project + genuinely requires a different version from the central default. + ## Core Components ### LanguageTag Class (LanguageTag.cs) @@ -73,9 +87,9 @@ The main public API for working with language tags: - `PrivateUse`: PrivateUseTag object - `IsValid`: Property to check if tag is valid -**Instance Methods:** -- `Validate()`: Verify structural correctness -- `Normalize()`: Return normalized copy of tag (does not validate) +**Instance Methods:** +- `Validate()`: Verify structural correctness +- `Normalize()`: Return normalized copy of tag (does not validate) - `ToString()`: String representation - `Equals()`: Equality comparison (case-insensitive) - `GetHashCode()`: Hash code for collections @@ -102,7 +116,7 @@ Fluent builder for constructing language tags: - `PrivateUseAdd(string value)`: Add private use tag - `PrivateUseAddRange(IEnumerable values)`: Add multiple private use tags - `Build()`: Return constructed tag (no validation) -- `Normalize()`: Return normalized tag (no validation) +- `Normalize()`: Return normalized tag (no validation) ### LanguageTagParser Class (LanguageTagParser.cs) @@ -136,54 +150,54 @@ Provides language code conversion and matching: Static class for configuring global logging for the entire library: -**Properties:** -- `LoggerFactory`: Gets or sets the global logger factory for creating category loggers - -**Methods:** -- `SetFactory(ILoggerFactory loggerFactory)`: Configure the library to use a logger factory -- `TrySetFactory(ILoggerFactory loggerFactory)`: Set factory only if none is configured - -**Logger Resolution Priority:** -1. `LoggerFactory` property (when not `NullLoggerFactory`) -2. `NullLogger.Instance` (default fallback) +**Properties:** +- `LoggerFactory`: Gets or sets the global logger factory for creating category loggers + +**Methods:** +- `SetFactory(ILoggerFactory loggerFactory)`: Configure the library to use a logger factory +- `TrySetFactory(ILoggerFactory loggerFactory)`: Set factory only if none is configured + +**Logger Resolution Priority:** +1. `LoggerFactory` property (when not `NullLoggerFactory`) +2. `NullLogger.Instance` (default fallback) **Important Notes:** - Loggers are created and cached at time of use by each class instance -- Changes to `LoggerFactory` after a logger is created do not affect existing cached loggers -- Only new logger requests use updated configuration +- Changes to `LoggerFactory` after a logger is created do not affect existing cached loggers +- Only new logger requests use updated configuration ### Data Models -#### Iso6392Data.cs -- ISO 639-2 language codes (3-letter bibliographic/terminologic codes) -- **Public Methods:** - - `Create()`: Load embedded data - - `FromDataAsync(string fileName)`: Load from file - - `FromJsonAsync(string fileName)`: Load from JSON - - `Find(string? languageTag, bool includeDescription)`: Find record by tag -- **Internal Methods:** `SaveJsonAsync(string fileName)`, `SaveCodeAsync(string fileName)` +#### Iso6392Data.cs +- ISO 639-2 language codes (3-letter bibliographic/terminologic codes) +- **Public Methods:** + - `Create()`: Load embedded data + - `FromDataAsync(string fileName)`: Load from file + - `FromJsonAsync(string fileName)`: Load from JSON + - `Find(string? languageTag, bool includeDescription)`: Find record by tag +- **Internal Methods:** `SaveJsonAsync(string fileName)`, `SaveCodeAsync(string fileName)` - **Record Properties:** `Part2B`, `Part2T`, `Part1`, `RefName` -#### Iso6393Data.cs -- ISO 639-3 language codes (comprehensive language codes) -- **Public Methods:** - - `Create()`: Load embedded data - - `FromDataAsync(string fileName)`: Load from file - - `FromJsonAsync(string fileName)`: Load from JSON - - `Find(string? languageTag, bool includeDescription)`: Find record by tag -- **Internal Methods:** `SaveJsonAsync(string fileName)`, `SaveCodeAsync(string fileName)` +#### Iso6393Data.cs +- ISO 639-3 language codes (comprehensive language codes) +- **Public Methods:** + - `Create()`: Load embedded data + - `FromDataAsync(string fileName)`: Load from file + - `FromJsonAsync(string fileName)`: Load from JSON + - `Find(string? languageTag, bool includeDescription)`: Find record by tag +- **Internal Methods:** `SaveJsonAsync(string fileName)`, `SaveCodeAsync(string fileName)` - **Record Properties:** `Id`, `Part2B`, `Part2T`, `Part1`, `Scope`, `LanguageType`, `RefName`, `Comment` -#### Rfc5646Data.cs -- RFC 5646 / BCP 47 language subtag registry -- **Public Methods:** - - `Create()`: Load embedded data - - `FromDataAsync(string fileName)`: Load from file - - `FromJsonAsync(string fileName)`: Load from JSON - - `Find(string? languageTag, bool includeDescription)`: Find record by tag -- **Properties:** `FileDate`, `RecordList` -- **Internal Methods:** `SaveJsonAsync(string fileName)`, `SaveCodeAsync(string fileName)` -- **Record Properties:** `Type`, `Tag`, `SubTag`, `Description` (ImmutableArray), `Added`, `SuppressScript`, `Scope`, `MacroLanguage`, `Deprecated`, `Comments` (ImmutableArray), `Prefix` (ImmutableArray), `PreferredValue`, `TagValue` +#### Rfc5646Data.cs +- RFC 5646 / BCP 47 language subtag registry +- **Public Methods:** + - `Create()`: Load embedded data + - `FromDataAsync(string fileName)`: Load from file + - `FromJsonAsync(string fileName)`: Load from JSON + - `Find(string? languageTag, bool includeDescription)`: Find record by tag +- **Properties:** `FileDate`, `RecordList` +- **Internal Methods:** `SaveJsonAsync(string fileName)`, `SaveCodeAsync(string fileName)` +- **Record Properties:** `Type`, `Tag`, `SubTag`, `Description` (ImmutableArray), `Added`, `SuppressScript`, `Scope`, `MacroLanguage`, `Deprecated`, `Comments` (ImmutableArray), `Prefix` (ImmutableArray), `PreferredValue`, `TagValue` - **Enums:** - `RecordType`: None, Language, ExtLanguage, Script, Variant, Grandfathered, Region, Redundant - `RecordScope`: None, MacroLanguage, Collection, Special, PrivateUse @@ -288,14 +302,14 @@ LanguageTag tag = LanguageTag.ParseOrDefault(input); // Falls back to "und" ## Recent API Changes -### Changed (Breaking) +### Changed (Breaking) - `LanguageTagParser` is now internal (use `LanguageTag.Parse()` instead) - Properties changed from `IList` to `ImmutableArray`: - `VariantList` → `Variants` - `ExtensionList` → `Extensions` - `TagList` → `Tags` -- Data file APIs are async-only and use static creators: `FromDataAsync()`/`FromJsonAsync()` -- Logging configuration now uses `ILoggerFactory` only; `ILogger` support was removed from `LogOptions` +- Data file APIs are async-only and use static creators: `FromDataAsync()`/`FromJsonAsync()` +- Logging configuration now uses `ILoggerFactory` only; `ILogger` support was removed from `LogOptions` - Tag construction requires use of factory methods or builder (constructors are internal) ### Added (Non-Breaking) @@ -304,7 +318,7 @@ LanguageTag tag = LanguageTag.ParseOrDefault(input); // Falls back to "und" - `LanguageTag.IsValid`: Property for validation - `LanguageTag.FromLanguage()`, `FromLanguageRegion()`, `FromLanguageScriptRegion()`: Factory methods - `IEquatable` implementation with operators -- `LogOptions` static class for global logging configuration with `ILoggerFactory` +- `LogOptions` static class for global logging configuration with `ILoggerFactory` - `ExtensionTag` and `PrivateUseTag` are now sealed records with `Normalize()` and case-insensitive `Equals()` methods - Comprehensive XML documentation for all public APIs diff --git a/.github/workflows/build-library-task.yml b/.github/workflows/build-library-task.yml index cb0b41b..6dd0575 100644 --- a/.github/workflows/build-library-task.yml +++ b/.github/workflows/build-library-task.yml @@ -45,7 +45,8 @@ jobs: - name: Build library project step run: | dotnet build ${{ env.PROJECT_FILE }} \ - --output ${{ runner.temp }}/publish \ + -property:OutputPath=${{ runner.temp }}/publish/ \ + -property:PackageOutputPath=${{ runner.temp }}/publish/ \ --configuration ${{ env.IS_MAIN_BRANCH == 'true' && 'Release' || 'Debug' }} \ -property:Version=${{ needs.get-version.outputs.AssemblyVersion }} \ -property:FileVersion=${{ needs.get-version.outputs.AssemblyFileVersion }} \ diff --git a/.github/workflows/run-periodic-codegen-pull-request.yml b/.github/workflows/run-periodic-codegen-pull-request.yml index cb16fe8..b13b85c 100644 --- a/.github/workflows/run-periodic-codegen-pull-request.yml +++ b/.github/workflows/run-periodic-codegen-pull-request.yml @@ -7,7 +7,8 @@ on: - cron: '0 2 * * MON' concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # Workflow always checks out and targets main/codegen + group: codegen-main cancel-in-progress: true jobs: diff --git a/.vscode/launch.json b/.vscode/launch.json index 58bc348..5dbeaba 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,12 +6,12 @@ "type": "coreclr", "request": "launch", "preLaunchTask": ".Net Build", - "program": "${workspaceFolder}/LanguageTagsCreate/bin/Debug/net10.0/LanguageTagsCreate.dll", + "program": "${workspaceFolder}/.artifacts/bin/LanguageTagsCreate/debug/LanguageTagsCreate.dll", "args": [ "--codepath", "${workspaceFolder}", ], - "cwd": "${workspaceFolder}/LanguageTagsCreate/bin/Debug/net10.0", + "cwd": "${workspaceFolder}/.artifacts/bin/LanguageTagsCreate/debug", "console": "internalConsole", "stopAtEntry": false } diff --git a/AGENTS.md b/AGENTS.md index 4d1feaa..4853d42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,19 +28,28 @@ For comprehensive coding standards and detailed conventions, refer to [`.github/ - xUnit test suite with comprehensive test coverage - Uses AwesomeAssertions for test assertions +### Project Configuration + +- Common MSBuild properties (`TargetFramework`, `Nullable`, `ImplicitUsings`, `AnalysisLevel`, etc.) + live in `Directory.Build.props` at the solution root. Do not duplicate these in individual `.csproj` + files — only add a property to a `.csproj` when it is project-specific or overrides the shared default. +- All NuGet package versions are centralised in `Directory.Packages.props`. `PackageReference` elements + in `.csproj` files must not include a `Version` attribute. Asset metadata (`PrivateAssets`, + `IncludeAssets`) stays in the `.csproj` `PackageReference` element. + ### Key Components -**Public API Classes:** - -- `LanguageTag`: Main class for working with language tags (parse, build, normalize, validate) -- `LanguageTagBuilder`: Fluent builder for constructing language tags -- `LanguageLookup`: Language code conversion and matching (IETF ↔ ISO) -- `Iso6392Data`: ISO 639-2 language code data (`Create()`, `FromDataAsync()`, `FromJsonAsync()`) -- `Iso6393Data`: ISO 639-3 language code data (`Create()`, `FromDataAsync()`, `FromJsonAsync()`) -- `Rfc5646Data`: RFC 5646 / BCP 47 language subtag registry data (`Create()`, `FromDataAsync()`, `FromJsonAsync()`) -- `ExtensionTag`: Represents extension subtags (sealed record) -- `PrivateUseTag`: Represents private use subtags (sealed record) -- `LogOptions`: Static class for configuring library-wide logging via `ILoggerFactory` +**Public API Classes:** + +- `LanguageTag`: Main class for working with language tags (parse, build, normalize, validate) +- `LanguageTagBuilder`: Fluent builder for constructing language tags +- `LanguageLookup`: Language code conversion and matching (IETF ↔ ISO) +- `Iso6392Data`: ISO 639-2 language code data (`Create()`, `FromDataAsync()`, `FromJsonAsync()`) +- `Iso6393Data`: ISO 639-3 language code data (`Create()`, `FromDataAsync()`, `FromJsonAsync()`) +- `Rfc5646Data`: RFC 5646 / BCP 47 language subtag registry data (`Create()`, `FromDataAsync()`, `FromJsonAsync()`) +- `ExtensionTag`: Represents extension subtags (sealed record) +- `PrivateUseTag`: Represents private use subtags (sealed record) +- `LogOptions`: Static class for configuring library-wide logging via `ILoggerFactory` **Internal Classes:** diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..da26fe3 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + latest-all + All + true + true + $(MSBuildThisFileDirectory).artifacts + false + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..3fddd67 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/LanguageData/rfc5646 b/LanguageData/rfc5646 index 82618f9..b81d64d 100644 --- a/LanguageData/rfc5646 +++ b/LanguageData/rfc5646 @@ -1,4 +1,4 @@ -File-Date: 2025-08-25 +File-Date: 2026-04-09 %% Type: language Subtag: aa @@ -769,9 +769,9 @@ Added: 2005-10-16 %% Type: language Subtag: ny -Description: Nyanja -Description: Chewa Description: Chichewa +Description: Chewa +Description: Nyanja Added: 2005-10-16 Suppress-Script: Latn %% @@ -803,6 +803,9 @@ Scope: macrolanguage %% Type: language Subtag: os +Description: Iron Ossetic +Description: Iron +Description: Iron Ossetian Description: Ossetian Description: Ossetic Added: 2005-10-16 @@ -5669,7 +5672,7 @@ Added: 2009-07-29 %% Type: language Subtag: boe -Description: Mundabli +Description: Mundabli-Mufu Added: 2009-07-29 %% Type: language @@ -10515,6 +10518,11 @@ Description: Djimini Senoufo Added: 2009-07-29 %% Type: language +Subtag: dyl +Description: Bhutanese Sign Language +Added: 2026-04-09 +%% +Type: language Subtag: dym Description: Yanda Dom Dogon Added: 2009-07-29 @@ -20008,6 +20016,11 @@ Description: Lefa Added: 2009-07-29 %% Type: language +Subtag: lfb +Description: Buu (Cameroon) +Added: 2026-04-09 +%% +Type: language Subtag: lfn Description: Lingua Franca Nova Added: 2009-07-29 @@ -21104,7 +21117,7 @@ Macrolanguage: zh %% Type: language Subtag: lui -Description: Luiseno +Description: Luiseño Added: 2005-10-16 %% Type: language @@ -27531,6 +27544,11 @@ Description: Walungge Added: 2009-07-29 %% Type: language +Subtag: olb +Description: Oli-Bodiman +Added: 2026-04-09 +%% +Type: language Subtag: old Description: Mochi Added: 2009-07-29 @@ -27899,6 +27917,13 @@ Description: Oscan Added: 2009-07-29 %% Type: language +Subtag: osd +Description: Digor Ossetic +Description: Digor +Description: Digor Ossetian +Added: 2026-04-09 +%% +Type: language Subtag: osi Description: Osing Added: 2009-07-29 @@ -31532,6 +31557,11 @@ Description: Sicel Added: 2009-07-29 %% Type: language +Subtag: scz +Description: Shaetlan +Added: 2026-04-09 +%% +Type: language Subtag: sda Description: Toraja-Sa'dan Added: 2009-07-29 @@ -41431,6 +41461,8 @@ Type: language Subtag: yol Description: Yola Added: 2009-07-29 +Deprecated: 2026-04-09 +Preferred-Value: enm %% Type: language Subtag: yom @@ -43491,6 +43523,13 @@ Prefix: ms Macrolanguage: ms %% Type: extlang +Subtag: dyl +Description: Bhutanese Sign Language +Added: 2026-04-09 +Preferred-Value: dyl +Prefix: sgn +%% +Type: extlang Subtag: ecs Description: Ecuadorian Sign Language Added: 2009-07-29 @@ -48438,6 +48477,13 @@ Description: Simplified form Added: 2015-12-29 %% Type: variant +Subtag: slepe +Description: Sorbian dialect of Schleife +Added: 2026-04-09 +Prefix: dsb +Comments: Spoken in the Free State of Saxony in Germany +%% +Type: variant Subtag: solba Description: The Stolvizza dialect of Resian Description: The Solbica dialect of Resian @@ -48463,6 +48509,13 @@ Prefix: es Comments: A variety of contact dialects of English and Spanish %% Type: variant +Subtag: stadi +Description: The "Stadin slangi" dialect of Finnish +Added: 2026-04-09 +Prefix: fi +Comments: "Stadi" means the city of Helsinki in the dialect. +%% +Type: variant Subtag: surmiran Description: Surmiran idiom of Romansh Added: 2010-06-29 @@ -48494,6 +48547,16 @@ Added: 2021-07-17 Prefix: da %% Type: variant +Subtag: taglish +Description: Tagalog-English code-switching +Added: 2026-04-09 +Prefix: en +Prefix: tl +Prefix: fil +Comments: This subtag represents Taglish, wherever it is different from + straight Tagalog or straight English. +%% +Type: variant Subtag: tailo Description: Taiwanese Hokkien Romanization System for Hokkien languages diff --git a/LanguageData/rfc5646.json b/LanguageData/rfc5646.json index 93b6117..9851dd9 100644 --- a/LanguageData/rfc5646.json +++ b/LanguageData/rfc5646.json @@ -1,5 +1,5 @@ { - "FileDate": "2025-08-25", + "FileDate": "2026-04-09", "RecordList": [ { "Type": "Language", @@ -1501,9 +1501,9 @@ "Type": "Language", "SubTag": "ny", "Description": [ - "Nyanja", + "Chichewa", "Chewa", - "Chichewa" + "Nyanja" ], "Added": "2005-10-16", "SuppressScript": "Latn", @@ -1562,6 +1562,9 @@ "Type": "Language", "SubTag": "os", "Description": [ + "Iron Ossetic", + "Iron", + "Iron Ossetian", "Ossetian", "Ossetic" ], @@ -12003,7 +12006,7 @@ "Type": "Language", "SubTag": "boe", "Description": [ - "Mundabli" + "Mundabli-Mufu" ], "Added": "2009-07-29", "Scope": "None", @@ -22442,6 +22445,17 @@ "Comments": [], "Prefix": [] }, + { + "Type": "Language", + "SubTag": "dyl", + "Description": [ + "Bhutanese Sign Language" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [], + "Prefix": [] + }, { "Type": "Language", "SubTag": "dym", @@ -42926,6 +42940,17 @@ "Comments": [], "Prefix": [] }, + { + "Type": "Language", + "SubTag": "lfb", + "Description": [ + "Buu (Cameroon)" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [], + "Prefix": [] + }, { "Type": "Language", "SubTag": "lfn", @@ -45292,7 +45317,7 @@ "Type": "Language", "SubTag": "lui", "Description": [ - "Luiseno" + "Luise\u00F1o" ], "Added": "2005-10-16", "Scope": "None", @@ -59227,6 +59252,17 @@ "Comments": [], "Prefix": [] }, + { + "Type": "Language", + "SubTag": "olb", + "Description": [ + "Oli-Bodiman" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [], + "Prefix": [] + }, { "Type": "Language", "SubTag": "old", @@ -60025,6 +60061,19 @@ "Comments": [], "Prefix": [] }, + { + "Type": "Language", + "SubTag": "osd", + "Description": [ + "Digor Ossetic", + "Digor", + "Digor Ossetian" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [], + "Prefix": [] + }, { "Type": "Language", "SubTag": "osi", @@ -67818,6 +67867,17 @@ "Comments": [], "Prefix": [] }, + { + "Type": "Language", + "SubTag": "scz", + "Description": [ + "Shaetlan" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [], + "Prefix": [] + }, { "Type": "Language", "SubTag": "sda", @@ -89280,8 +89340,10 @@ ], "Added": "2009-07-29", "Scope": "None", + "Deprecated": "2026-04-09", "Comments": [], - "Prefix": [] + "Prefix": [], + "PreferredValue": "enm" }, { "Type": "Language", @@ -93506,6 +93568,20 @@ ], "PreferredValue": "dup" }, + { + "Type": "ExtLanguage", + "SubTag": "dyl", + "Description": [ + "Bhutanese Sign Language" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [], + "Prefix": [ + "sgn" + ], + "PreferredValue": "dyl" + }, { "Type": "ExtLanguage", "SubTag": "ecs", @@ -103684,6 +103760,21 @@ "Comments": [], "Prefix": [] }, + { + "Type": "Variant", + "SubTag": "slepe", + "Description": [ + "Sorbian dialect of Schleife" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [ + "Spoken in the Free State of Saxony in Germany" + ], + "Prefix": [ + "dsb" + ] + }, { "Type": "Variant", "SubTag": "solba", @@ -103731,6 +103822,21 @@ "es" ] }, + { + "Type": "Variant", + "SubTag": "stadi", + "Description": [ + "The \u0022Stadin slangi\u0022 dialect of Finnish" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [ + "\u0022Stadi\u0022 means the city of Helsinki in the dialect." + ], + "Prefix": [ + "fi" + ] + }, { "Type": "Variant", "SubTag": "surmiran", @@ -103790,6 +103896,23 @@ "da" ] }, + { + "Type": "Variant", + "SubTag": "taglish", + "Description": [ + "Tagalog-English code-switching" + ], + "Added": "2026-04-09", + "Scope": "None", + "Comments": [ + "This subtag represents Taglish, wherever it is different from straight Tagalog or straight English." + ], + "Prefix": [ + "en", + "tl", + "fil" + ] + }, { "Type": "Variant", "SubTag": "tailo", diff --git a/LanguageTags/GlobalUsings.cs b/LanguageTags/GlobalUsings.cs index 9b521ce..9ee56d1 100644 --- a/LanguageTags/GlobalUsings.cs +++ b/LanguageTags/GlobalUsings.cs @@ -1,11 +1,6 @@ -global using System; -global using System.Collections.Generic; global using System.Collections.Immutable; global using System.Globalization; -global using System.IO; -global using System.Linq; global using System.Text; global using System.Text.Json; -global using System.Threading.Tasks; global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Logging.Abstractions; diff --git a/LanguageTags/LanguageTags.csproj b/LanguageTags/LanguageTags.csproj index 8c90b0b..59ee5d6 100644 --- a/LanguageTags/LanguageTags.csproj +++ b/LanguageTags/LanguageTags.csproj @@ -1,11 +1,9 @@ - latest-all LanguageTags 1.0.0.0 Pieter Viljoen Pieter Viljoen - true Pieter Viljoen C# .NET library for ISO 639-2, ISO 639-3, RFC 5646 / BCP 47 language tags true @@ -15,8 +13,8 @@ true 1.0.0-pre true + true en - enable true ptr727.LanguageTags MIT @@ -27,16 +25,11 @@ true ptr727.LanguageTags snupkg - net10.0 1.0.0.0 - - + + diff --git a/LanguageTags/LogOptions.cs b/LanguageTags/LogOptions.cs index 55c0c66..e87a4d5 100644 --- a/LanguageTags/LogOptions.cs +++ b/LanguageTags/LogOptions.cs @@ -1,5 +1,3 @@ -using System.Threading; - namespace ptr727.LanguageTags; /// diff --git a/LanguageTags/Rfc5646DataGen.cs b/LanguageTags/Rfc5646DataGen.cs index 813e964..45b0f78 100644 --- a/LanguageTags/Rfc5646DataGen.cs +++ b/LanguageTags/Rfc5646DataGen.cs @@ -13,7 +13,7 @@ public sealed partial class Rfc5646Data public static Rfc5646Data Create() => new() { - FileDate = new DateOnly(2025, 8, 25), + FileDate = new DateOnly(2026, 4, 9), RecordList = [ new() @@ -1909,7 +1909,7 @@ public static Rfc5646Data Create() => Deprecated = null, PreferredValue = null, Tag = null, - Description = [@"Nyanja", @"Chewa", @"Chichewa"], + Description = [@"Chichewa", @"Chewa", @"Nyanja"], Comments = [], Prefix = [], }, @@ -1984,7 +1984,14 @@ public static Rfc5646Data Create() => Deprecated = null, PreferredValue = null, Tag = null, - Description = [@"Ossetian", @"Ossetic"], + Description = + [ + @"Iron Ossetic", + @"Iron", + @"Iron Ossetian", + @"Ossetian", + @"Ossetic", + ], Comments = [], Prefix = [], }, @@ -15983,7 +15990,7 @@ public static Rfc5646Data Create() => Deprecated = null, PreferredValue = null, Tag = null, - Description = [@"Mundabli"], + Description = [@"Mundabli-Mufu"], Comments = [], Prefix = [], }, @@ -30003,6 +30010,21 @@ public static Rfc5646Data Create() => Prefix = [], }, new() + { + Type = Rfc5646Record.RecordType.Language, + SubTag = "dyl", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = null, + Tag = null, + Description = [@"Bhutanese Sign Language"], + Comments = [], + Prefix = [], + }, + new() { Type = Rfc5646Record.RecordType.Language, SubTag = "dym", @@ -57533,6 +57555,21 @@ public static Rfc5646Data Create() => Prefix = [], }, new() + { + Type = Rfc5646Record.RecordType.Language, + SubTag = "lfb", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = null, + Tag = null, + Description = [@"Buu (Cameroon)"], + Comments = [], + Prefix = [], + }, + new() { Type = Rfc5646Record.RecordType.Language, SubTag = "lfn", @@ -60713,7 +60750,7 @@ public static Rfc5646Data Create() => Deprecated = null, PreferredValue = null, Tag = null, - Description = [@"Luiseno"], + Description = [@"Luiseño"], Comments = [], Prefix = [], }, @@ -79487,6 +79524,21 @@ public static Rfc5646Data Create() => Prefix = [], }, new() + { + Type = Rfc5646Record.RecordType.Language, + SubTag = "olb", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = null, + Tag = null, + Description = [@"Oli-Bodiman"], + Comments = [], + Prefix = [], + }, + new() { Type = Rfc5646Record.RecordType.Language, SubTag = "old", @@ -80567,6 +80619,21 @@ public static Rfc5646Data Create() => Prefix = [], }, new() + { + Type = Rfc5646Record.RecordType.Language, + SubTag = "osd", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = null, + Tag = null, + Description = [@"Digor Ossetic", @"Digor", @"Digor Ossetian"], + Comments = [], + Prefix = [], + }, + new() { Type = Rfc5646Record.RecordType.Language, SubTag = "osi", @@ -90999,6 +91066,21 @@ public static Rfc5646Data Create() => Prefix = [], }, new() + { + Type = Rfc5646Record.RecordType.Language, + SubTag = "scz", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = null, + Tag = null, + Description = [@"Shaetlan"], + Comments = [], + Prefix = [], + }, + new() { Type = Rfc5646Record.RecordType.Language, SubTag = "sda", @@ -119915,8 +119997,8 @@ public static Rfc5646Data Create() => SuppressScript = null, Scope = Rfc5646Record.RecordScope.None, MacroLanguage = null, - Deprecated = null, - PreferredValue = null, + Deprecated = new DateOnly(2026, 4, 9), + PreferredValue = "enm", Tag = null, Description = [@"Yola"], Comments = [], @@ -125190,6 +125272,21 @@ public static Rfc5646Data Create() => Prefix = [@"ms"], }, new() + { + Type = Rfc5646Record.RecordType.ExtLanguage, + SubTag = "dyl", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = "dyl", + Tag = null, + Description = [@"Bhutanese Sign Language"], + Comments = [], + Prefix = [@"sgn"], + }, + new() { Type = Rfc5646Record.RecordType.ExtLanguage, SubTag = "ecs", @@ -137784,6 +137881,21 @@ public static Rfc5646Data Create() => Prefix = [], }, new() + { + Type = Rfc5646Record.RecordType.Variant, + SubTag = "slepe", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = null, + Tag = null, + Description = [@"Sorbian dialect of Schleife"], + Comments = [@"Spoken in the Free State of Saxony in Germany"], + Prefix = [@"dsb"], + }, + new() { Type = Rfc5646Record.RecordType.Variant, SubTag = "solba", @@ -137839,6 +137951,21 @@ public static Rfc5646Data Create() => Prefix = [@"en", @"es"], }, new() + { + Type = Rfc5646Record.RecordType.Variant, + SubTag = "stadi", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = null, + Tag = null, + Description = [@"The ""Stadin slangi"" dialect of Finnish"], + Comments = [@"""Stadi"" means the city of Helsinki in the dialect."], + Prefix = [@"fi"], + }, + new() { Type = Rfc5646Record.RecordType.Variant, SubTag = "surmiran", @@ -137908,6 +138035,24 @@ public static Rfc5646Data Create() => Prefix = [@"da"], }, new() + { + Type = Rfc5646Record.RecordType.Variant, + SubTag = "taglish", + Added = new DateOnly(2026, 4, 9), + SuppressScript = null, + Scope = Rfc5646Record.RecordScope.None, + MacroLanguage = null, + Deprecated = null, + PreferredValue = null, + Tag = null, + Description = [@"Tagalog-English code-switching"], + Comments = + [ + @"This subtag represents Taglish, wherever it is different from straight Tagalog or straight English.", + ], + Prefix = [@"en", @"tl", @"fil"], + }, + new() { Type = Rfc5646Record.RecordType.Variant, SubTag = "tailo", diff --git a/LanguageTagsCreate/GlobalUsings.cs b/LanguageTagsCreate/GlobalUsings.cs index 4dbb9d0..17a0a02 100644 --- a/LanguageTagsCreate/GlobalUsings.cs +++ b/LanguageTagsCreate/GlobalUsings.cs @@ -1,11 +1,5 @@ -global using System; global using System.Collections.Frozen; global using System.Globalization; -global using System.IO; -global using System.Linq; -global using System.Net.Http; -global using System.Threading; -global using System.Threading.Tasks; global using Microsoft.Extensions.Logging; global using Serilog; global using Serilog.Events; diff --git a/LanguageTagsCreate/HttpClientFactory.cs b/LanguageTagsCreate/HttpClientFactory.cs index f84cb7a..da6063b 100644 --- a/LanguageTagsCreate/HttpClientFactory.cs +++ b/LanguageTagsCreate/HttpClientFactory.cs @@ -1,68 +1,119 @@ using System.Net.Http.Headers; using Microsoft.Extensions.Http.Resilience; using Polly; +using Polly.CircuitBreaker; +using Polly.Retry; namespace ptr727.LanguageTags.Create; internal static class HttpClientFactory { + // Retry + private const int RetryMaxAttempts = 3; + private static readonly TimeSpan s_retryBaseDelay = TimeSpan.FromSeconds(1); + private static readonly TimeSpan s_retryMaxDelay = TimeSpan.FromSeconds(30); + + // Circuit breaker + private const double CircuitBreakerFailureRatio = 0.1; + private const int CircuitBreakerMinimumThroughput = 10; + private static readonly TimeSpan s_circuitBreakerSamplingDuration = TimeSpan.FromSeconds(60); + private static readonly TimeSpan s_circuitBreakerBreakDuration = TimeSpan.FromSeconds(30); + + // Connection pool + private static readonly TimeSpan s_connectionLifetime = TimeSpan.FromMinutes(15); + private static readonly TimeSpan s_connectionIdleTimeout = TimeSpan.FromMinutes(2); + + // HttpClient + private static readonly TimeSpan s_httpClientTimeout = TimeSpan.FromSeconds(120); + private static readonly Lazy s_httpClient = new(CreateHttpClient); - private static readonly Lazy s_resilienceHandler = new( - CreateResilienceHandler - ); + // Returns the shared singleton HttpClient; all callers share the connection pool and circuit breaker state. internal static HttpClient GetHttpClient() => s_httpClient.Value; - internal static ResilienceHandler GetResilienceHandler() => s_resilienceHandler.Value; - private static ResilienceHandler CreateResilienceHandler() => new( new ResiliencePipelineBuilder() .AddRetry( - new Polly.Retry.RetryStrategyOptions + new RetryStrategyOptions { - MaxRetryAttempts = 3, + MaxRetryAttempts = RetryMaxAttempts, BackoffType = DelayBackoffType.Exponential, UseJitter = true, - Delay = TimeSpan.FromSeconds(1), - MaxDelay = TimeSpan.FromSeconds(30), + Delay = s_retryBaseDelay, + MaxDelay = s_retryMaxDelay, ShouldHandle = args => - ValueTask.FromResult( - args.Outcome.Exception != null - || args.Outcome.Result is { IsSuccessStatusCode: false } - ), + ValueTask.FromResult(IsTransientFailure(args.Outcome)), + OnRetry = args => + { + Log.Logger.Warning( + "HTTP retry attempt {Attempt} after {Delay}ms: {Outcome}", + args.AttemptNumber, + args.RetryDelay.TotalMilliseconds, + args.Outcome + ); + return ValueTask.CompletedTask; + }, } ) .AddCircuitBreaker( - new Polly.CircuitBreaker.CircuitBreakerStrategyOptions + new CircuitBreakerStrategyOptions { - FailureRatio = 0.2, - MinimumThroughput = 10, - SamplingDuration = TimeSpan.FromSeconds(60), - BreakDuration = TimeSpan.FromSeconds(30), + FailureRatio = CircuitBreakerFailureRatio, + MinimumThroughput = CircuitBreakerMinimumThroughput, + SamplingDuration = s_circuitBreakerSamplingDuration, + BreakDuration = s_circuitBreakerBreakDuration, ShouldHandle = args => - ValueTask.FromResult( - args.Outcome.Exception != null - || args.Outcome.Result is { IsSuccessStatusCode: false } - ), + ValueTask.FromResult(IsTransientFailure(args.Outcome)), + OnOpened = args => + { + Log.Logger.Warning( + "Circuit breaker opened for {Duration}s: {Outcome}", + args.BreakDuration.TotalSeconds, + args.Outcome + ); + return ValueTask.CompletedTask; + }, + OnClosed = _ => + { + Log.Logger.Information("Circuit breaker closed."); + return ValueTask.CompletedTask; + }, + OnHalfOpened = _ => + { + Log.Logger.Debug("Circuit breaker half-opened."); + return ValueTask.CompletedTask; + }, } ) - .AddTimeout(TimeSpan.FromSeconds(30)) .Build() ) { InnerHandler = new SocketsHttpHandler { - PooledConnectionLifetime = TimeSpan.FromMinutes(15), - PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + PooledConnectionLifetime = s_connectionLifetime, + PooledConnectionIdleTimeout = s_connectionIdleTimeout, + AutomaticDecompression = System.Net.DecompressionMethods.All, }, }; - private static HttpClient CreateHttpClient() + private static bool IsTransientFailure(Outcome outcome) => + outcome.Exception is not null + ? outcome.Exception is not (OperationCanceledException or BrokenCircuitException) + : outcome.Result is not null && (int)outcome.Result.StatusCode is 408 or 429 or >= 500; + + // Creates a new HttpClient instance; each caller gets an independent resilience handler + // and circuit breaker state. Callers should store and reuse the returned instance. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "HttpClient takes ownership of the handler and disposes it when the client is disposed." + )] + internal static HttpClient CreateHttpClient() { - HttpClient httpClient = new(GetResilienceHandler()) { Timeout = TimeSpan.FromSeconds(120) }; + HttpClient httpClient = new(CreateResilienceHandler()) { Timeout = s_httpClientTimeout }; httpClient.DefaultRequestHeaders.UserAgent.Add( - new ProductInfoHeaderValue(AssemblyInfo.AppName, AssemblyInfo.InformationalVersion) + new ProductInfoHeaderValue(AssemblyInfo.AppName, AssemblyInfo.ReleaseVersion) ); return httpClient; } diff --git a/LanguageTagsCreate/LanguageTagsCreate.csproj b/LanguageTagsCreate/LanguageTagsCreate.csproj index 6be3fb3..3900151 100644 --- a/LanguageTagsCreate/LanguageTagsCreate.csproj +++ b/LanguageTagsCreate/LanguageTagsCreate.csproj @@ -1,14 +1,9 @@ - + - latest-all - true 1.0.0-pre - false - enable Exe false ptr727.LanguageTags.Create - net10.0 1.0.0 @@ -20,18 +15,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/LanguageTagsTests/GlobalUsings.cs b/LanguageTagsTests/GlobalUsings.cs index be16484..d85913e 100644 --- a/LanguageTagsTests/GlobalUsings.cs +++ b/LanguageTagsTests/GlobalUsings.cs @@ -1,6 +1,2 @@ -global using System; -global using System.Collections.Generic; -global using System.IO; -global using System.Threading.Tasks; global using AwesomeAssertions; global using Xunit; diff --git a/LanguageTagsTests/LanguageTagsTests.csproj b/LanguageTagsTests/LanguageTagsTests.csproj index cb24d2d..f43478d 100644 --- a/LanguageTagsTests/LanguageTagsTests.csproj +++ b/LanguageTagsTests/LanguageTagsTests.csproj @@ -1,22 +1,17 @@ - latest-all - true - false true - enable ptr727.LanguageTags.Tests - net10.0 - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + +