From 39d15df8944fdaa644e3e6872e1a12f563ec665f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:25:28 +0000 Subject: [PATCH] Find a whole JSON string before a substring in FindLocation [patch] FindLocation(needle) and FindLocation(anchor, needle) matched with a plain substring search, so "Meter" landed inside "SquareMeter" and the anchored overload scoped to the wrong entry. Both now look for the quoted string first and fall back to the raw search only when that finds nothing. Fixes #33 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW4i7FzmxvaxCZgeCwe --- .../MetadataFileTests.cs | 41 +++++++++++++++++++ SourceGeneratorToolkit/MetadataFile.cs | 25 +++++++++-- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/SourceGeneratorToolkit.Test/MetadataFileTests.cs b/SourceGeneratorToolkit.Test/MetadataFileTests.cs index e50caa6..4a2d5f4 100644 --- a/SourceGeneratorToolkit.Test/MetadataFileTests.cs +++ b/SourceGeneratorToolkit.Test/MetadataFileTests.cs @@ -111,6 +111,47 @@ public void ScopedFindLocationPicksTheOccurrenceAfterTheAnchor() Assert.AreEqual(2, scoped.GetLineSpan().StartLinePosition.Line); } + private const string CompoundNames = """ + {"units":[ + {"name":"SquareMeter","base":"Second"}, + {"name":"Meter","base":"Second"} + ]} + """; + + [TestMethod] + public void FindLocationSkipsALongerNameThatContainsTheNeedle() + { + MetadataFile file = new("units.json", CompoundNames, SourceText.From(CompoundNames), "/repo/units.json"); + + Location location = file.FindLocation("Meter"); + + Assert.AreEqual(2, location.GetLineSpan().StartLinePosition.Line); + Assert.AreEqual("Meter", CompoundNames.Substring(location.SourceSpan.Start, location.SourceSpan.Length)); + Assert.AreEqual('"', CompoundNames[location.SourceSpan.Start - 1]); + } + + [TestMethod] + public void ScopedFindLocationSkipsALongerNameThatContainsTheAnchor() + { + MetadataFile file = new("units.json", CompoundNames, SourceText.From(CompoundNames), "/repo/units.json"); + + Location location = file.FindLocation("Meter", "Second"); + + Assert.AreEqual(2, location.GetLineSpan().StartLinePosition.Line); + Assert.AreEqual("Second", CompoundNames.Substring(location.SourceSpan.Start, location.SourceSpan.Length)); + } + + [TestMethod] + public void FindLocationStillFindsTextThatIsNotAWholeString() + { + const string text = """{"things":[{"name":"Alpha","size":12345}]}"""; + MetadataFile file = new("things.json", text, SourceText.From(text), "/repo/things.json"); + + Location location = file.FindLocation("2345"); + + Assert.AreEqual("2345", text.Substring(location.SourceSpan.Start, location.SourceSpan.Length)); + } + [TestMethod] public void ScopedFindLocationFallsBackWhenTheAnchorIsAbsent() { diff --git a/SourceGeneratorToolkit/MetadataFile.cs b/SourceGeneratorToolkit/MetadataFile.cs index 02ccee2..b7330aa 100644 --- a/SourceGeneratorToolkit/MetadataFile.cs +++ b/SourceGeneratorToolkit/MetadataFile.cs @@ -86,7 +86,7 @@ public Location FindLocation(string needle) return Location.None; } - int index = Text.IndexOf(needle, StringComparison.Ordinal); + int index = IndexOfToken(needle, 0); return index < 0 ? Location.None : LocationAt(index, needle.Length); } @@ -113,16 +113,35 @@ public Location FindLocation(string anchor, string needle) return Location.None; } - int scope = string.IsNullOrEmpty(anchor) ? 0 : Text.IndexOf(anchor, StringComparison.Ordinal); + int scope = string.IsNullOrEmpty(anchor) ? 0 : IndexOfToken(anchor, 0); if (scope < 0) { return FindLocation(needle); } - int index = Text.IndexOf(needle, scope, StringComparison.Ordinal); + int index = IndexOfToken(needle, scope); return index < 0 ? FindLocation(needle) : LocationAt(index, needle.Length); } + /// + /// Finds at or after , preferring an + /// occurrence that is a whole JSON string. + /// + /// The text to find. + /// Where the search starts. + /// The index of the text itself, inside its quotes when it was found quoted, or -1. + /// + /// A plain substring search lands inside longer names: Meter is found in + /// "SquareMeter", which is a different entry. Looking for "Meter" first matches + /// only the whole string. The raw search remains the fallback so that text which is not a JSON + /// string on its own, such as a number or an already quoted anchor, is still found. + /// + private int IndexOfToken(string value, int startIndex) + { + int quoted = Text.IndexOf("\"" + value + "\"", startIndex, StringComparison.Ordinal); + return quoted >= 0 ? quoted + 1 : Text.IndexOf(value, startIndex, StringComparison.Ordinal); + } + /// /// Deserializes the file into . ///