Skip to content

Language Tag Matching

Frank Stüber edited this page Sep 11, 2026 · 2 revisions

Enbrea.Bcp47 implements the three matching schemes defined by RFC 4647:

  • Basic Filtering
  • Extended Filtering
  • Lookup

Matching is case-insensitive and registry-independent.

The central API is Bcp47Matcher. For a single LanguageRange, equivalent extension methods are available directly on the range.

Basic Filtering

Basic Filtering matches a basic language range against the beginning of a language tag on subtag boundaries.

For example, the range de matches:

de
de-DE
de-Latn-DE

The range de-DE matches:

de-DE
de-DE-1996

but not:

de-Latn-DE

Use BasicFilter() on a range:

var range = LanguageRangeParser.ParseBasic("de-DE");

var tags = new[]
{
    LanguageTag.Parse("de-DE"),
    LanguageTag.Parse("de-DE-1996"),
    LanguageTag.Parse("de-Latn-DE"),
    LanguageTag.Parse("en-US")
};

var matches = range.BasicFilter(tags);

The static form is equivalent:

var matches = Bcp47Matcher.BasicFilter(range, tags);

Test a single tag with IsBasicMatch():

var range = LanguageRangeParser.ParseBasic("de-DE");
var tag = LanguageTag.Parse("de-DE-1996");

bool matches = range.IsBasicMatch(tag); // true

The standalone wildcard matches every tag in Basic Filtering:

var range = LanguageRangeParser.ParseBasic("*");
var matches = range.BasicFilter(tags);

Extended Filtering

Extended Filtering accepts both basic and extended ranges and allows wildcard subtags to match across ordinary subtags.

For example:

de-*-DE

can match tags such as:

de-DE
de-Latn-DE
de-Deva-DE
de-DE-1996
de-Latn-DE-1996
de-DE-x-goethe

Use ExtendedFilter():

var range = LanguageRangeParser.Parse("de-*-DE");

var matches = range.ExtendedFilter(tags);

Or use the static matcher:

var matches = Bcp47Matcher.ExtendedFilter(range, tags);

A single tag can be tested with:

bool matches = range.IsExtendedMatch(tag);

Singleton barrier

Extended Filtering does not skip over a singleton subtag. Singletons introduce extension sequences or private use and therefore form a boundary for the matching algorithm.

For example, de-*-DE does not match:

de-x-DE

because x is a singleton and the matcher cannot skip over it to find DE afterward.

Filtering with multiple ranges

The static matcher also accepts a sequence of ranges:

var ranges = new[]
{
    LanguageRangeParser.ParseBasic("de"),
    LanguageRangeParser.ParseBasic("en-GB")
};

var matches = Bcp47Matcher.BasicFilter(ranges, tags);

Extended Filtering supports the same pattern:

var ranges = new[]
{
    LanguageRangeParser.Parse("de-*-DE"),
    LanguageRangeParser.Parse("en-*")
};

var matches = Bcp47Matcher.ExtendedFilter(ranges, tags);

A language tag is returned at most once even if it matches more than one range. Results preserve the input order of tags.

Lookup

Lookup selects one best available language tag from a basic language range.

If an exact match is unavailable, the range is progressively truncated from the right.

For example, given the range:

de-DE-1996

Lookup tries successively:

de-DE-1996
de-DE
de

With these available tags:

var tags = new[]
{
    LanguageTag.Parse("de"),
    LanguageTag.Parse("de-DE"),
    LanguageTag.Parse("en-US")
};

Lookup returns de-DE:

var range = LanguageRangeParser.ParseBasic("de-DE-1996");
var result = range.Lookup(tags);

Console.WriteLine(result); // de-DE

The static form is:

var result = Bcp47Matcher.Lookup(range, tags);

Lookup and extensions

When truncation exposes an extension or private-use singleton, the singleton is removed as well.

For example, the fallback sequence for a range containing private-use subtags does not leave a dangling x singleton.

This behavior is part of the RFC 4647 Lookup algorithm.

Lookup with a priority list

Lookup can process several basic ranges in priority order:

var ranges = new[]
{
    LanguageRangeParser.ParseBasic("de-CH"),
    LanguageRangeParser.ParseBasic("de"),
    LanguageRangeParser.ParseBasic("en")
};

var match = Bcp47Matcher.Lookup(ranges, tags);

The first range that produces a match wins.

The wildcard range * is skipped during Lookup because it does not identify one specific best language tag. If no concrete range matches, Lookup() returns null and leaves default selection to the caller.

Basic versus extended ranges

Basic Filtering and Lookup require a basic language range:

var range = LanguageRangeParser.Parse("de-*-DE");

range.BasicFilter(tags); // throws ArgumentException
range.Lookup(tags);      // throws ArgumentException

Extended Filtering accepts both basic and extended ranges.

See Language Ranges for parsing and identifying range types.

Clone this wiki locally