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 . ///