Skip to content

Commit da7c8cd

Browse files
paulirwinclaude
andcommitted
Address PR review feedback on Collation port, #1343
- Rename CollatedTermAttributeImpl class to CollatedTermAttribute (keep file name) - Add ICU span fast path (no string alloc) to CollatedTermAttribute.FillBytesRef and CollationKeyFilter, gated on new FEATURE_COMPAREINFO_SPAN_SORTKEY; NLS keeps the slow path - Add public CollationUtil.IsICU/IsNLS (statically cached) for runtime backend detection and document it in package.md - package.md: use Farsi "fa" instead of "ar", and demonstrate using on IndexWriter/IndexReader examples - Rename NewCollationAnalyzerTask Implementation.BCL to DotNet (keep jdk/bcl param aliases); update TestPerfTasksLogic to impl:dotnet - CollationTestBase: remove stale icu-net comment, rename FranceResult to FrenchResult, reflow sortData to match upstream - Tests: use "fa" in TestCollationKeyAnalyzer/TestCollationKeyFilter, revert TestCollationKeyFilterFactory.TestNormalization to original decomposed input, and document why upstream testCustomRules was omitted Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 44a5ed8 commit da7c8cd

12 files changed

Lines changed: 192 additions & 81 deletions

File tree

Directory.Build.targets

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@
7777

7878
<DefineConstants>$(DefineConstants);FEATURE_MEMORYMARSHAL_GETARRAYDATAREFERENCE</DefineConstants>
7979
<DefineConstants>$(DefineConstants);FEATURE_READONLYSET</DefineConstants>
80+
<!-- CompareInfo.GetSortKey(ReadOnlySpan<char>, Span<byte>, CompareOptions) and GetSortKeyLength(ReadOnlySpan<char>, CompareOptions) -->
81+
<DefineConstants>$(DefineConstants);FEATURE_COMPAREINFO_SPAN_SORTKEY</DefineConstants>
8082

8183
</PropertyGroup>
8284

src/Lucene.Net.Analysis.Common/Collation/CollationAttributeFactory.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,8 @@ public CollationAttributeFactory(AttributeFactory @delegate, CompareInfo collato
116116

117117
public override Attribute CreateAttributeInstance<T>()
118118
{
119-
return typeof(T).IsAssignableFrom(typeof(CollatedTermAttributeImpl))
120-
? new CollatedTermAttributeImpl(this.collator, this.options)
119+
return typeof(T).IsAssignableFrom(typeof(CollatedTermAttribute))
120+
? new CollatedTermAttribute(this.collator, this.options)
121121
: this.@delegate.CreateAttributeInstance<T>();
122122
}
123123
}

src/Lucene.Net.Analysis.Common/Collation/CollationKeyFilter.cs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,7 @@ public override bool IncrementToken()
9696
{
9797
if (this.m_input.IncrementToken())
9898
{
99-
// LUCENENET: Normalize to NFC for cross-backend consistency. See CollatedTermAttributeImpl.Normalize.
100-
byte[] collationKey = this.collator.GetSortKey(TokenAttributes.CollatedTermAttributeImpl.Normalize(this.termAtt.ToString()), this.options).KeyData;
99+
byte[] collationKey = GetCollationKey();
101100
int encodedLength = IndexableBinaryStringTools.GetEncodedLength(collationKey, 0, collationKey.Length);
102101
this.termAtt.ResizeBuffer(encodedLength);
103102
this.termAtt.Length = encodedLength;
@@ -110,5 +109,24 @@ public override bool IncrementToken()
110109
return false;
111110
}
112111
}
112+
113+
private byte[] GetCollationKey()
114+
{
115+
#if FEATURE_COMPAREINFO_SPAN_SORTKEY
116+
// LUCENENET: On .NET 5+ with ICU, CompareInfo has a ReadOnlySpan<char> overload of GetSortKey
117+
// that lets us generate the sort key without converting the term to a string first. The NLS
118+
// backend does not normalize internally (see CollatedTermAttribute.Normalize), so we still need
119+
// the slow path when the app is configured for NLS.
120+
if (CollationUtil.IsICU)
121+
{
122+
ReadOnlySpan<char> source = this.termAtt.Buffer.AsSpan(0, this.termAtt.Length);
123+
byte[] collationKey = new byte[this.collator.GetSortKeyLength(source, this.options)];
124+
this.collator.GetSortKey(source, collationKey, this.options);
125+
return collationKey;
126+
}
127+
#endif
128+
// LUCENENET: Normalize to NFC for cross-backend consistency. See CollatedTermAttribute.Normalize.
129+
return this.collator.GetSortKey(TokenAttributes.CollatedTermAttribute.Normalize(this.termAtt.ToString()), this.options).KeyData;
130+
}
113131
}
114132
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Lucene version compatibility level 4.8.1
2+
using System.Globalization;
3+
4+
namespace Lucene.Net.Collation
5+
{
6+
/*
7+
* Licensed to the Apache Software Foundation (ASF) under one or more
8+
* contributor license agreements. See the NOTICE file distributed with
9+
* this work for additional information regarding copyright ownership.
10+
* The ASF licenses this file to You under the Apache License, Version 2.0
11+
* (the "License"); you may not use this file except in compliance with
12+
* the License. You may obtain a copy of the License at
13+
*
14+
* http://www.apache.org/licenses/LICENSE-2.0
15+
*
16+
* Unless required by applicable law or agreed to in writing, software
17+
* distributed under the License is distributed on an "AS IS" BASIS,
18+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
19+
* See the License for the specific language governing permissions and
20+
* limitations under the License.
21+
*/
22+
23+
/// <summary>
24+
/// LUCENENET specific helpers for working with the platform collator
25+
/// (<see cref="CompareInfo"/>) used by <see cref="CollationKeyAnalyzer"/> and
26+
/// <see cref="CollationAttributeFactory"/>.
27+
/// </summary>
28+
public static class CollationUtil
29+
{
30+
/// <summary>
31+
/// Indicates whether the active globalization backend is ICU (the default on .NET 5+)
32+
/// rather than the legacy NLS implementation. .NET Framework always uses NLS, while .NET 5+
33+
/// uses ICU by default but can be configured to use NLS (for example via the
34+
/// <c>System.Globalization.UseNls</c> runtime switch or the <c>DOTNET_SYSTEM_GLOBALIZATION_USENLS</c>
35+
/// environment variable). NLS and ICU produce different sort keys and orderings, so this value
36+
/// (along with the runtime version and culture) is part of what must match between index time
37+
/// and query time when searching against stored sort keys.
38+
/// <para/>
39+
/// See <a href="https://learn.microsoft.com/en-us/dotnet/core/extensions/globalization-icu#determine-if-your-app-is-using-icu">Determine if your app is using ICU</a>.
40+
/// </summary>
41+
public static bool IsICU { get; } = DetectICU();
42+
43+
/// <summary>
44+
/// Indicates whether the active globalization backend is the legacy NLS implementation
45+
/// (always the case on .NET Framework, and the default on Windows when the app opts out of ICU)
46+
/// rather than ICU. This is the inverse of <see cref="IsICU"/>.
47+
/// </summary>
48+
public static bool IsNLS => !IsICU;
49+
50+
// LUCENENET: Detects whether globalization is backed by ICU. See the link in the IsICU docs.
51+
private static bool DetectICU()
52+
{
53+
SortVersion sortVersion = CultureInfo.InvariantCulture.CompareInfo.Version;
54+
byte[] bytes = sortVersion.SortId.ToByteArray();
55+
int version = bytes[3] << 24 | bytes[2] << 16 | bytes[1] << 8 | bytes[0];
56+
return version != 0 && version == sortVersion.FullVersion;
57+
}
58+
}
59+
}

src/Lucene.Net.Analysis.Common/Collation/TokenAttributes/CollatedTermAttributeImpl.cs

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Lucene version compatibility level 4.8.1
22
using Lucene.Net.Analysis.TokenAttributes;
33
using Lucene.Net.Util;
4+
using System;
45
using System.Globalization;
56

67
namespace Lucene.Net.Collation.TokenAttributes
@@ -26,25 +27,25 @@ namespace Lucene.Net.Collation.TokenAttributes
2627
/// Extension of <see cref="CharTermAttribute"/> that encodes the term
2728
/// text as a binary Unicode collation key instead of as UTF-8 bytes.
2829
/// </summary>
29-
public class CollatedTermAttributeImpl : CharTermAttribute
30+
public class CollatedTermAttribute : CharTermAttribute
3031
{
3132
private readonly CompareInfo collator;
3233
private readonly CompareOptions options;
3334

3435
/// <summary>
35-
/// Create a new <see cref="CollatedTermAttributeImpl"/> </summary>
36+
/// Create a new <see cref="CollatedTermAttribute"/> </summary>
3637
/// <param name="collator"> Collation key generator </param>
37-
public CollatedTermAttributeImpl(CompareInfo collator)
38+
public CollatedTermAttribute(CompareInfo collator)
3839
: this(collator, CompareOptions.None)
3940
{
4041
}
4142

4243
/// <summary>
43-
/// Create a new <see cref="CollatedTermAttributeImpl"/> </summary>
44+
/// Create a new <see cref="CollatedTermAttribute"/> </summary>
4445
/// <param name="collator"> Collation key generator </param>
4546
/// <param name="options"> Collation options that control the collation strength and
4647
/// Unicode normalization (decomposition) of the generated sort key. </param>
47-
public CollatedTermAttributeImpl(CompareInfo collator, CompareOptions options)
48+
public CollatedTermAttribute(CompareInfo collator, CompareOptions options)
4849
{
4950
// LUCENENET: Unlike java.text.Collator, System.Globalization.CompareInfo is
5051
// immutable and thread-safe, so there is no need to clone it here.
@@ -55,12 +56,32 @@ public CollatedTermAttributeImpl(CompareInfo collator, CompareOptions options)
5556
public override void FillBytesRef()
5657
{
5758
BytesRef bytes = this.BytesRef;
59+
#if FEATURE_COMPAREINFO_SPAN_SORTKEY
60+
// LUCENENET: On .NET 5+ with ICU, CompareInfo has a ReadOnlySpan<char> overload of GetSortKey
61+
// that writes directly into a destination buffer, so we can generate the sort key from the term
62+
// text without the intermediate string allocation. The NLS backend does not normalize internally
63+
// (see Normalize), so we still need the slow path when the app is configured for NLS.
64+
if (CollationUtil.IsICU)
65+
{
66+
ReadOnlySpan<char> source = this.AsSpan();
67+
int keyLength = this.collator.GetSortKeyLength(source, this.options);
68+
bytes.Bytes = new byte[keyLength];
69+
bytes.Offset = 0;
70+
bytes.Length = this.collator.GetSortKey(source, bytes.Bytes, this.options);
71+
return;
72+
}
73+
#endif
5874
byte[] keyData = this.collator.GetSortKey(Normalize(this.ToString()), this.options).KeyData;
5975
bytes.Bytes = keyData;
6076
bytes.Offset = 0;
6177
bytes.Length = keyData.Length;
6278
}
6379

80+
#if FEATURE_COMPAREINFO_SPAN_SORTKEY
81+
// LUCENENET: Returns the term text as a span over the backing buffer, avoiding a string allocation.
82+
private ReadOnlySpan<char> AsSpan() => this.Buffer.AsSpan(0, this.Length);
83+
#endif
84+
6485
// LUCENENET: Normalize the term to Unicode Normalization Form C (NFC) before generating the
6586
// sort key. ICU-backed collators (.NET 5+) normalize internally, but the NLS-backed collator
6687
// (.NET Framework) does not fully handle decomposed combining sequences (e.g. "I" + U+0307 vs

src/Lucene.Net.Analysis.Common/Collation/package.md

Lines changed: 29 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,16 @@ summary: *content
4141
### Farsi Range Queries
4242

4343
```cs
44-
CompareInfo collator = CompareInfo.GetCompareInfo("ar");
44+
CompareInfo collator = CompareInfo.GetCompareInfo("fa");
4545
CollationKeyAnalyzer analyzer = new CollationKeyAnalyzer(LuceneVersion.LUCENE_48, collator);
4646
Store.Directory ramDir = new RAMDirectory();
47-
IndexWriter writer = new IndexWriter(ramDir, new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer));
48-
Document doc = new Document();
49-
doc.Add(new TextField("content", "ساب", Field.Store.YES));
50-
writer.AddDocument(doc);
51-
writer.Dispose();
52-
IndexReader ir = DirectoryReader.Open(ramDir);
47+
using (IndexWriter writer = new IndexWriter(ramDir, new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer)))
48+
{
49+
Document doc = new Document();
50+
doc.Add(new TextField("content", "ساب", Field.Store.YES));
51+
writer.AddDocument(doc);
52+
}
53+
using IndexReader ir = DirectoryReader.Open(ramDir);
5354
IndexSearcher searcher = new IndexSearcher(ir);
5455

5556
QueryParser aqp = new QueryParser(LuceneVersion.LUCENE_48, "content", analyzer);
@@ -58,7 +59,7 @@ aqp.AnalyzeRangeTerms = true;
5859
// Unicode order would include U+0633 in [ U+062F - U+0698 ], but Farsi
5960
// orders the U+0698 character before the U+0633 character, so the single
6061
// indexed Term above should NOT be returned by a TermRangeQuery with a
61-
// Farsi Collator (or an Arabic one for the case when Farsi is not supported).
62+
// Farsi Collator.
6263
ScoreDoc[] result = searcher.Search(aqp.Parse("[ د TO ژ ]"), null, 1000).ScoreDocs;
6364
assertEquals("The index Term should not be included.", 0, result.Length);
6465
```
@@ -68,19 +69,20 @@ assertEquals("The index Term should not be included.", 0, result.Length);
6869
```cs
6970
Analyzer analyzer = new CollationKeyAnalyzer(LuceneVersion.LUCENE_48, CompareInfo.GetCompareInfo("da-DK"));
7071
Store.Directory indexStore = new RAMDirectory();
71-
IndexWriter writer = new IndexWriter(indexStore, new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer));
72-
string[] tracer = new string[] { "A", "B", "C", "D", "E" };
73-
string[] data = new string[] { "HAT", "HUT", "HÅT", "HØT", "HOT" };
74-
string[] sortedTracerOrder = new string[] { "A", "E", "B", "D", "C" };
75-
for (int i = 0; i < data.Length; ++i)
72+
using (IndexWriter writer = new IndexWriter(indexStore, new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer)))
7673
{
77-
Document doc = new Document();
78-
doc.Add(new StoredField("tracer", tracer[i]));
79-
doc.Add(new TextField("contents", data[i], Field.Store.NO));
80-
writer.AddDocument(doc);
74+
string[] tracer = new string[] { "A", "B", "C", "D", "E" };
75+
string[] data = new string[] { "HAT", "HUT", "HÅT", "HØT", "HOT" };
76+
for (int i = 0; i < data.Length; ++i)
77+
{
78+
Document doc = new Document();
79+
doc.Add(new StoredField("tracer", tracer[i]));
80+
doc.Add(new TextField("contents", data[i], Field.Store.NO));
81+
writer.AddDocument(doc);
82+
}
8183
}
82-
writer.Dispose();
83-
IndexReader ir = DirectoryReader.Open(indexStore);
84+
string[] sortedTracerOrder = new string[] { "A", "E", "B", "D", "C" };
85+
using IndexReader ir = DirectoryReader.Open(indexStore);
8486
IndexSearcher searcher = new IndexSearcher(ir);
8587
Sort sort = new Sort();
8688
sort.SetSort(new SortField("contents", SortFieldType.STRING));
@@ -101,12 +103,13 @@ CompareInfo collator = CompareInfo.GetCompareInfo("tr-TR");
101103
Analyzer analyzer = new CollationKeyAnalyzer(LuceneVersion.LUCENE_48, collator,
102104
CompareOptions.IgnoreCase | CompareOptions.IgnoreNonSpace);
103105
Store.Directory ramDir = new RAMDirectory();
104-
IndexWriter writer = new IndexWriter(ramDir, new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer));
105-
Document doc = new Document();
106-
doc.Add(new TextField("contents", "DIGY", Field.Store.NO));
107-
writer.AddDocument(doc);
108-
writer.Dispose();
109-
IndexReader ir = DirectoryReader.Open(ramDir);
106+
using (IndexWriter writer = new IndexWriter(ramDir, new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer)))
107+
{
108+
Document doc = new Document();
109+
doc.Add(new TextField("contents", "DIGY", Field.Store.NO));
110+
writer.AddDocument(doc);
111+
}
112+
using IndexReader ir = DirectoryReader.Open(ramDir);
110113
IndexSearcher searcher = new IndexSearcher(ir);
111114
QueryParser parser = new QueryParser(LuceneVersion.LUCENE_48, "contents", analyzer);
112115
Query query = parser.Parse("dıgy"); // U+0131: dotless i
@@ -118,7 +121,7 @@ assertEquals("The index Term should be included.", 1, result.Length);
118121

119122
__WARNING:__ Make sure you use exactly the same collator (`System.Globalization.CompareInfo` *and* `System.Globalization.CompareOptions`) at index and query time -- `System.Globalization.SortKey`s are only comparable when produced by the same collator. Since the platform collator is not independently versioned, it is unsafe to search against stored `System.Globalization.SortKey`s unless the following are exactly the same (best practice is to store this information with the index and check that they remain the same at query time):
120123

121-
1. The .NET runtime version, and the active globalization backend. .NET Framework uses Windows NLS, while .NET 5+ uses ICU by default; the two produce different sort keys and orderings.
124+
1. The .NET runtime version, and the active globalization backend. .NET Framework uses Windows NLS, while .NET 5+ uses ICU by default; the two produce different sort keys and orderings. You can detect the active backend at runtime via <xref:Lucene.Net.Collation.CollationUtil.IsICU> (or its inverse, <xref:Lucene.Net.Collation.CollationUtil.IsNLS>) and store that value with the index to verify it matches at query time.
122125

123126
2. The language (and country and variant, if specified) of the culture used when obtaining the collator via `System.Globalization.CompareInfo.GetCompareInfo`.
124127

src/Lucene.Net.Benchmark/ByTask/Tasks/NewCollationAnalyzerTask.cs

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public static Type GetAnalyzerType(this NewCollationAnalyzerTask.Implementation
3333
{
3434
switch (impl)
3535
{
36-
case NewCollationAnalyzerTask.Implementation.BCL:
36+
case NewCollationAnalyzerTask.Implementation.DotNet:
3737
return typeof(Lucene.Net.Collation.CollationKeyAnalyzer);
3838

3939
case NewCollationAnalyzerTask.Implementation.ICU:
@@ -46,8 +46,8 @@ public static Type GetCollatorType(this NewCollationAnalyzerTask.Implementation
4646
{
4747
switch (impl)
4848
{
49-
case NewCollationAnalyzerTask.Implementation.BCL:
50-
// LUCENENET: The .NET (BCL) equivalent of the JDK's java.text.Collator is the
49+
case NewCollationAnalyzerTask.Implementation.DotNet:
50+
// LUCENENET: The .NET equivalent of the JDK's java.text.Collator is the
5151
// platform collator, System.Globalization.CompareInfo.
5252
return typeof(CompareInfo);
5353

@@ -69,16 +69,16 @@ public class NewCollationAnalyzerTask : PerfTask
6969
public enum Implementation
7070
{
7171
// LUCENENET: This value is named JDK in upstream Lucene (the collator comes from the
72-
// Java Development Kit's java.text.Collator). It has been renamed to BCL here because the
73-
// equivalent in .NET is the platform collator (System.Globalization.CompareInfo) provided
74-
// by the Base Class Library. The "jdk" parameter value is still accepted as a legacy alias.
72+
// Java Development Kit's java.text.Collator). It has been renamed to DotNet here because the
73+
// equivalent in .NET is the platform collator (System.Globalization.CompareInfo). The "jdk"
74+
// (and legacy "bcl") parameter values are still accepted as aliases.
7575

7676
/// <summary>The .NET platform collator (<see cref="CompareInfo"/>), equivalent to the JDK's <c>java.text.Collator</c>.</summary>
77-
BCL,
77+
DotNet,
7878
ICU
7979
}
8080

81-
private Implementation impl = Implementation.BCL;
81+
private Implementation impl = Implementation.DotNet;
8282

8383
public NewCollationAnalyzerTask(PerfRunData runData)
8484
: base(runData)
@@ -88,7 +88,7 @@ public NewCollationAnalyzerTask(PerfRunData runData)
8888
internal static Analyzer CreateAnalyzer(CultureInfo locale, Implementation impl)
8989
{
9090
// LUCENENET specific - senseless to use reflection here, so we construct the collator
91-
// for the chosen implementation directly. The BCL implementation (named JDK in upstream
91+
// for the chosen implementation directly. The DotNet implementation (named JDK in upstream
9292
// Lucene) maps to the .NET platform collator (System.Globalization.CompareInfo); ICU maps
9393
// to ICU4N's Collator.
9494
object collator = impl == Implementation.ICU
@@ -139,11 +139,12 @@ public override void SetParams(string @params)
139139
{
140140
if (value.Equals("icu", StringComparison.OrdinalIgnoreCase))
141141
impl = Implementation.ICU;
142-
// LUCENENET: "bcl" is the .NET name for what upstream Lucene calls "jdk"; we accept
143-
// "jdk" as a legacy alias so existing Lucene benchmark .alg files keep working.
144-
else if (value.Equals("bcl", StringComparison.OrdinalIgnoreCase)
145-
|| value.Equals("jdk", StringComparison.OrdinalIgnoreCase))
146-
impl = Implementation.BCL;
142+
// LUCENENET: "dotnet" maps to what upstream Lucene calls "jdk"; we accept "jdk" (and
143+
// the legacy "bcl") as aliases so existing Lucene benchmark .alg files keep working.
144+
else if (value.Equals("dotnet", StringComparison.OrdinalIgnoreCase)
145+
|| value.Equals("jdk", StringComparison.OrdinalIgnoreCase)
146+
|| value.Equals("bcl", StringComparison.OrdinalIgnoreCase))
147+
impl = Implementation.DotNet;
147148
else
148149
throw RuntimeException.Create("Unknown parameter " + param);
149150
}

0 commit comments

Comments
 (0)