Skip to content
Open
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
2 changes: 1 addition & 1 deletion Build/SilVersions.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
=============================================================
-->
<PropertyGroup Label="SIL Ecosystem Versions">
<SilLcmVersion>11.0.0-beta0178</SilLcmVersion>
<SilLcmVersion>11.0.0-beta0180</SilLcmVersion>
<SilLibPalasoVersion>18.0.0-beta0030</SilLibPalasoVersion>
<SilLibPalasoL10nsVersion>18.0.0-beta0012</SilLibPalasoL10nsVersion>
<SilChorusVersion>6.0.0-beta0065</SilChorusVersion>
Expand Down
15 changes: 15 additions & 0 deletions Src/Common/Controls/XMLViews/MatchingObjectsBrowser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,21 @@ public void Initialize(LcmCache cache, IVwStylesheet stylesheet, Mediator mediat
ResumeLayout(false);
}

/// <summary>
/// Swap the active search engine (e.g. when toggling substring-match mode). The caller
/// owns the lifetime of both engines; this only re-hooks the SearchCompleted event.
/// </summary>
public void SetSearchEngine(SearchEngine searchEngine)
{
CheckDisposed();
if (ReferenceEquals(m_searchEngine, searchEngine))
return;
if (m_searchEngine != null)
m_searchEngine.SearchCompleted -= m_searchEngine_SearchCompleted;
m_searchEngine = searchEngine;
m_searchEngine.SearchCompleted += m_searchEngine_SearchCompleted;
}

private void m_searchEngine_SearchCompleted(object sender, SearchCompletedEventArgs e)
{
UpdateResults(e.Fields.FirstOrDefault(), e.Results);
Expand Down
57 changes: 54 additions & 3 deletions Src/LexText/LexTextControls/EntryGoDlg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,55 @@ protected override string PersistenceLabel
get { return "EntryGo"; }
}

/// <summary>
/// The default engine: full-text (word-prefix) matching. Cached in the property table.
/// Overridable so a subclass can supply a specialized engine (for example one that
/// excludes a starting entry).
/// </summary>
protected virtual SearchEngine FullTextSearchEngine
{
get
{
return SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSearchEngine",
() => new EntryGoSearchEngine(m_cache, SearchType.FullText));
}
}

/// <summary>
/// The substring (match-anywhere) engine, used once a query is long enough
/// (see <see cref="SubstringSearchPolicy"/>). A subclass can override it to supply a
/// specialized engine that also filters substring results.
/// </summary>
protected virtual SearchEngine SubstringSearchEngine
{
get
{
return SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSubstringSearchEngine",
() => new EntryGoSearchEngine(m_cache, SearchType.Substring));
}
}

/// <summary>
/// True when this query should use substring matching. The decision (minimum query
/// length) lives in <see cref="SubstringSearchPolicy"/>. Kept separate from
/// <see cref="SearchEngineFor"/> so callers can test the mode without instantiating the
/// substring engine.
/// </summary>
private bool UseSubstringFor(string searchKey)
{
return SubstringSearchPolicy.UseSubstring(searchKey);
}

/// <summary>
/// Choose the engine for a given search key: the substring engine only when
/// <see cref="UseSubstringFor"/> is true; otherwise the default full-text engine
/// (which still returns its normal results for short keys).
/// </summary>
private SearchEngine SearchEngineFor(string searchKey)
{
return UseSubstringFor(searchKey) ? SubstringSearchEngine : FullTextSearchEngine;
}

/// <summary>
/// Get/Set the starting entry object. This will not be displayed in the list of
/// matching entries.
Expand Down Expand Up @@ -85,10 +134,8 @@ protected override void InitializeMatchingObjects(LcmCache cache)
var xnWindow = m_propertyTable.GetValue<XmlNode>("WindowConfiguration");
XmlNode configNode = xnWindow.SelectSingleNode("controls/parameters/guicontrol[@id=\"matchingEntries\"]/parameters");

SearchEngine searchEngine = SearchEngine.Get(m_mediator, m_propertyTable, "EntryGoSearchEngine", () => new EntryGoSearchEngine(cache));

m_matchingObjectsBrowser.Initialize(cache, FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable), m_mediator, m_propertyTable, configNode,
searchEngine);
SearchEngineFor(string.Empty));

m_matchingObjectsBrowser.ColumnsChanged += m_matchingObjectsBrowser_ColumnsChanged;

Expand All @@ -97,6 +144,7 @@ protected override void InitializeMatchingObjects(LcmCache cache)
if (selectedWs != null)
m_matchingObjectsBrowser.SearchAsync(GetFields(string.Empty, selectedWs.Handle));
}

#endregion Construction and Destruction

#region Other methods
Expand Down Expand Up @@ -158,6 +206,9 @@ protected override void ResetMatches(string searchKey)
m_oldSearchKey = searchKey;
m_oldSearchWs = wsSelHvo;

// Select the engine for this query: substring once the key is long enough
// (>= MinQueryLength), otherwise full-text so short keys behave like the original.
m_matchingObjectsBrowser.SetSearchEngine(SearchEngineFor(searchKey));
m_matchingObjectsBrowser.SearchAsync(GetFields(searchKey, wsSelHvo));
}

Expand Down
4 changes: 2 additions & 2 deletions Src/LexText/LexTextControls/EntryGoSearchEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ internal class EntryGoSearchEngine : SearchEngine
{
private readonly Virtuals m_virtuals;

public EntryGoSearchEngine(LcmCache cache)
: base(cache, SearchType.FullText)
public EntryGoSearchEngine(LcmCache cache, SearchType searchType = SearchType.FullText)
: base(cache, searchType)
{
m_virtuals = Cache.ServiceLocator.GetInstance<Virtuals>();
}
Expand Down
2 changes: 1 addition & 1 deletion Src/LexText/LexTextControls/LexTextControls.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright (c) 2026 SIL International
// This software is licensed under the LGPL, version 2.1 or later
// (http://www.gnu.org/licenses/lgpl-2.1.html)

using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using SIL.FieldWorks.Common.Controls;
using SIL.FieldWorks.LexText.Controls;
using SIL.LCModel;
using SIL.LCModel.Core.Text;
using SIL.LCModel.DomainServices;

namespace LexTextControlsTests
{
/// <summary>
/// The Merge Entry search engine must exclude the starting entry from its matches in BOTH
/// full-text and substring modes, so an entry can never be offered as a target for merging
/// into itself. The substring case matters because a typical multi-character merge-target
/// query runs under substring matching (see <see cref="SubstringSearchPolicy"/>).
/// </summary>
[TestFixture]
public class MergeEntrySearchEngineTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase
{
private ILexEntry _language;
private ILexEntry _languor;

// The base opens a UOW in TestSetup and calls CreateTestData() inside it, so data is
// created directly here with no UOW wrapper (a nested task would throw).
protected override void CreateTestData()
{
base.CreateTestData();
_language = MakeEntry("language", "speech");
_languor = MakeEntry("languor", "weariness");
}

private ILexEntry MakeEntry(string lexemeForm, string gloss)
{
var components = new LexEntryComponents
{
MorphType = Cache.ServiceLocator.GetInstance<IMoMorphTypeRepository>()
.GetObject(MoMorphTypeTags.kguidMorphStem)
};
components.LexemeFormAlternatives.Add(TsStringUtils.MakeString(lexemeForm, Cache.DefaultVernWs));
components.GlossAlternatives.Add(TsStringUtils.MakeString(gloss, Cache.DefaultAnalWs));
return Cache.ServiceLocator.GetInstance<ILexEntryFactory>().Create(components);
}

private IEnumerable<SearchField> LexemeFormQuery(string query)
{
var tss = TsStringUtils.MakeString(query, Cache.DefaultVernWs);
return new[] { new SearchField(LexEntryTags.kflidLexemeForm, tss) };
}

[Test]
public void FullTextEngine_ExcludesTheStartingEntry()
{
using (var engine = new MergeEntryDlg.MergeEntrySearchEngine(Cache, SearchType.FullText))
{
// "langu" is a word-prefix shared by both entries, so full-text matches both.
var withoutExclusion = engine.Search(LexemeFormQuery("langu")).ToList();
Assert.That(withoutExclusion, Does.Contain(_language.Hvo).And.Contain(_languor.Hvo),
"both entries match the shared prefix before any entry is excluded");

engine.CurrentEntryHvo = _language.Hvo;
var withExclusion = engine.Search(LexemeFormQuery("langu")).ToList();
Assert.That(withExclusion, Does.Not.Contain(_language.Hvo),
"the starting entry is never offered as a target for merging into itself");
Assert.That(withExclusion, Does.Contain(_languor.Hvo),
"the other matching entry still appears");
}
}

[Test]
public void SubstringEngine_ExcludesTheStartingEntry()
{
using (var engine = new MergeEntryDlg.MergeEntrySearchEngine(Cache, SearchType.Substring))
{
// "angu" is an interior substring of both entries and a prefix of neither, so it
// exercises the substring (match-anywhere) path specifically.
var withoutExclusion = engine.Search(LexemeFormQuery("angu")).ToList();
Assert.That(withoutExclusion, Does.Contain(_language.Hvo).And.Contain(_languor.Hvo),
"both entries match the interior substring before any entry is excluded");

engine.CurrentEntryHvo = _language.Hvo;
var withExclusion = engine.Search(LexemeFormQuery("angu")).ToList();
Assert.That(withExclusion, Does.Not.Contain(_language.Hvo),
"substring results also exclude the starting entry (the self-merge guard)");
Assert.That(withExclusion, Does.Contain(_languor.Hvo),
"the other interior-substring match still appears");
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright (c) 2026 SIL International
// This software is licensed under the LGPL, version 2.1 or later
// (http://www.gnu.org/licenses/lgpl-2.1.html)

using NUnit.Framework;
using SIL.FieldWorks.LexText.Controls;

namespace LexTextControlsTests
{
/// <summary>
/// Verifies the query-length gate that decides when Find Lexical Entry uses substring
/// (match-anywhere) matching.
/// </summary>
[TestFixture]
public class SubstringSearchPolicyTests
{
// A base letter followed by a combining acute accent (U+0301): 2 UTF-16 units that
// compose to a single character under FormC normalization.
private static readonly string ComposedAcuteE = "e" + (char)0x0301;

[TestCase("", ExpectedResult = false)]
[TestCase("l", ExpectedResult = false)]
[TestCase("la", ExpectedResult = false)] // below MinQueryLength
[TestCase("lan", ExpectedResult = true)] // exactly MinQueryLength
[TestCase("language", ExpectedResult = true)]
public bool UseSubstring_gatesOnLength(string key)
{
return SubstringSearchPolicy.UseSubstring(key);
}

[Test]
public void UseSubstring_nullKey_isFalse()
{
Assert.That(SubstringSearchPolicy.UseSubstring(null), Is.False);
}

[Test]
public void UseSubstring_countsComposedCharacters_notUtf16Units()
{
// Each ComposedAcuteE is 2 UTF-16 units but 1 character after FormC. Counting raw
// Length would see 4 and 6 (both >= 3) and wrongly enable substring; composed
// characters count as 2 and 3.
string twoComposed = ComposedAcuteE + ComposedAcuteE; // raw Length 4 -> 2
string threeComposed = ComposedAcuteE + ComposedAcuteE + ComposedAcuteE; // raw Length 6 -> 3

Assert.That(SubstringSearchPolicy.UseSubstring(twoComposed), Is.False,
"two composed characters should count as length 2, below the threshold");
Assert.That(SubstringSearchPolicy.UseSubstring(threeComposed), Is.True,
"three composed characters should count as length 3, at the threshold");
}

[Test]
public void MinQueryLength_hasExpectedDefault()
{
Assert.That(SubstringSearchPolicy.MinQueryLength, Is.EqualTo(3));
}
}
}
41 changes: 25 additions & 16 deletions Src/LexText/LexTextControls/MergeEntryDlg.cs
Original file line number Diff line number Diff line change
Expand Up @@ -187,35 +187,44 @@ protected override void SetBottomMessage()
m_fwTextBoxBottomMsg.Tss = tsb.GetString();
}

protected override void InitializeMatchingObjects(LcmCache cache)
/// <summary>
/// The full-text engine, specialized to drop the starting entry so an entry can never be
/// offered as a merge target for itself.
/// </summary>
protected override SearchEngine FullTextSearchEngine
{
var xnWindow = m_propertyTable.GetValue<XmlNode>("WindowConfiguration");
XmlNode configNode = xnWindow.SelectSingleNode("controls/parameters/guicontrol[@id=\"matchingEntries\"]/parameters");

var searchEngine = (MergeEntrySearchEngine)SearchEngine.Get(m_mediator, m_propertyTable, "MergeEntrySearchEngine", () => new MergeEntrySearchEngine(cache));
searchEngine.CurrentEntryHvo = m_startingEntry.Hvo;
get { return MergeSearchEngine("MergeEntrySearchEngine", SearchType.FullText); }
}

m_matchingObjectsBrowser.Initialize(cache, FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable), m_mediator, m_propertyTable, configNode,
searchEngine);
/// <summary>
/// The substring engine, specialized to drop the starting entry.
/// </summary>
protected override SearchEngine SubstringSearchEngine
{
get { return MergeSearchEngine("MergeEntrySubstringSearchEngine", SearchType.Substring); }
}

// start building index
var selectedWs = (CoreWritingSystemDefinition) m_cbWritingSystems.SelectedItem;
if(selectedWs != null)
m_matchingObjectsBrowser.SearchAsync(GetFields(string.Empty, selectedWs.Handle));
private SearchEngine MergeSearchEngine(string cacheKey, SearchType searchType)
{
var searchEngine = (MergeEntrySearchEngine)SearchEngine.Get(m_mediator, m_propertyTable,
cacheKey, () => new MergeEntrySearchEngine(m_cache, searchType));
searchEngine.CurrentEntryHvo = m_startingEntry.Hvo;
return searchEngine;
}

/// <summary>
/// A search engine that excludes the current entry (you can't merge an entry with its self
/// A search engine that excludes the current entry, since you cannot merge an entry
/// with itself.
/// </summary>
private class MergeEntrySearchEngine : EntryGoSearchEngine
internal class MergeEntrySearchEngine : EntryGoSearchEngine
{
public int CurrentEntryHvo { private get; set; }

public MergeEntrySearchEngine(LcmCache cache) : base(cache)
public MergeEntrySearchEngine(LcmCache cache, SearchType searchType) : base(cache, searchType)
{
}

protected override IEnumerable<int> FilterResults(IEnumerable<int> results)
protected override IEnumerable<int> FilterResults(IEnumerable<int> results)
{
return results == null ? null : results.Where(hvo => hvo != CurrentEntryHvo);
}
Expand Down
37 changes: 37 additions & 0 deletions Src/LexText/LexTextControls/SubstringSearchPolicy.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) 2026 SIL International
// This software is licensed under the LGPL, version 2.1 or later
// (http://www.gnu.org/licenses/lgpl-2.1.html)

using System.Text;

namespace SIL.FieldWorks.LexText.Controls
{
/// <summary>
/// UI-independent decision logic for the Find Lexical Entry "match anywhere" (substring)
/// search mode: when substring matching applies, plus its tuning knobs. Kept out of
/// <see cref="EntryGoDlg"/> so it can be unit-tested without driving the dialog.
/// </summary>
internal static class SubstringSearchPolicy
{
/// <summary>
/// Substring matching engages only once the query is at least this many characters;
/// shorter keys fall back to the default full-text engine, so short queries behave like
/// the original search (a 1-2 char substring in a large project would otherwise match
/// most entries).
/// </summary>
public const int MinQueryLength = 3;

/// <summary>
/// True when a query should use substring matching: the key is at least
/// <see cref="MinQueryLength"/> characters. Length is counted after FormC normalization,
/// so a base character plus a combining diacritic counts as one character. Shorter keys
/// fall back to the default full-text search.
/// </summary>
/// <param name="searchKey">The (already trimmed) search key.</param>
public static bool UseSubstring(string searchKey)
{
return !string.IsNullOrEmpty(searchKey)
&& searchKey.Normalize(NormalizationForm.FormC).Length >= MinQueryLength;
}
}
}
Loading