diff --git a/SourceGeneratorToolkit.Test/MetadataFileTests.cs b/SourceGeneratorToolkit.Test/MetadataFileTests.cs index 4a2d5f4..96b779c 100644 --- a/SourceGeneratorToolkit.Test/MetadataFileTests.cs +++ b/SourceGeneratorToolkit.Test/MetadataFileTests.cs @@ -172,6 +172,56 @@ public void ScopedFindLocationFallsBackWhenTheNeedleIsOnlyBeforeTheAnchor() Assert.AreEqual(file.FindLocation("Alpha"), scoped); } + private const string NeedleFirst = """ + {"units":[ + {"base":"Meter","name":"Foot"}, + {"base":"Meter","name":"Yard"} + ]} + """; + + [TestMethod] + public void ScopedFindLocationPicksTheAnchorsEntryWhenTheNeedleIsWrittenFirst() + { + MetadataFile file = new("units.json", NeedleFirst, SourceText.From(NeedleFirst), "/repo/units.json"); + + Location location = file.FindLocation("Foot", "Meter"); + + Assert.AreEqual(1, location.GetLineSpan().StartLinePosition.Line); + Assert.AreEqual("Meter", NeedleFirst.Substring(location.SourceSpan.Start, location.SourceSpan.Length)); + } + + [TestMethod] + public void ScopedFindLocationPicksTheAnchorsEntryOverAnEarlierOne() + { + const string text = """ + {"units":[ + {"base":"Meter","name":"Inch"}, + {"base":"Meter","name":"Foot"} + ]} + """; + MetadataFile file = new("units.json", text, SourceText.From(text), "/repo/units.json"); + + Location location = file.FindLocation("Foot", "Meter"); + + Assert.AreEqual(2, location.GetLineSpan().StartLinePosition.Line); + } + + [TestMethod] + public void ScopedFindLocationSkipsNestedObjectsAndBracesInStringsBeforeTheAnchor() + { + const string text = """ + {"units":[ + {"base":"Meter","name":"Inch"}, + {"base":"Meter","note":"a } and a {","extra":{"k":"v"},"name":"Foot"} + ]} + """; + MetadataFile file = new("units.json", text, SourceText.From(text), "/repo/units.json"); + + Location location = file.FindLocation("Foot", "Meter"); + + Assert.AreEqual(2, location.GetLineSpan().StartLinePosition.Line); + } + [TestMethod] public void ScopedFindLocationReturnsNoneForAnEmptyNeedle() { diff --git a/SourceGeneratorToolkit/MetadataFile.cs b/SourceGeneratorToolkit/MetadataFile.cs index 1e6aec7..20ceb6b 100644 --- a/SourceGeneratorToolkit/MetadataFile.cs +++ b/SourceGeneratorToolkit/MetadataFile.cs @@ -91,8 +91,9 @@ public Location FindLocation(string needle) } /// - /// Finds at or after the first occurrence of , - /// and returns a location covering it. + /// Finds in the JSON object that holds the first occurrence of + /// , preferring an occurrence after the anchor, and returns a location + /// covering it. /// /// Text that scopes the search — typically the entry the needle belongs to. /// The text to find within that scope. @@ -105,6 +106,13 @@ public Location FindLocation(string needle) /// definition occurs once. It is not enough when the needle is a name that is spelled correctly /// in dozens of places and only wrong in one of them — there the first occurrence is somewhere /// else entirely. Anchoring on the surrounding entry picks the right one. + /// + /// JSON objects have no key order, so the needle can be written before the anchor in the same + /// entry, as in {"base":"Meter","name":"Foot"}. Searching only forward from the anchor + /// would then land in a later entry, or fall back to an earlier one. The search therefore looks + /// after the anchor within its enclosing object, then before it within that object, and only + /// then forward past the object and finally anywhere in the file. + /// /// public Location FindLocation(string anchor, string needle) { @@ -119,10 +127,102 @@ public Location FindLocation(string anchor, string needle) return FindLocation(needle); } - int index = IndexOfToken(needle, scope); + int index = -1; + if (TryGetEnclosingObject(scope, out int open, out int close)) + { + index = IndexOfToken(needle, scope, close); + if (index < 0) + { + index = IndexOfToken(needle, open, scope); + } + } + + if (index < 0) + { + index = IndexOfToken(needle, scope); + } + return index < 0 ? FindLocation(needle) : LocationAt(index, needle.Length); } + /// + /// Finds the JSON object that encloses . + /// + /// A position in the file. + /// The index of the object's opening brace. + /// + /// The index just past the object's closing brace, or the end of the file when it is unclosed. + /// + /// when is inside an object. + /// + /// The file is scanned from the start rather than backwards from the index, because only a + /// forward scan can tell which quotes open a string: braces inside strings, and nested objects + /// written before the index, must not be taken for the enclosing object's braces. + /// + private bool TryGetEnclosingObject(int index, out int open, out int close) + { + List opens = []; + bool inString = false; + bool escaped = false; + int depth = -1; + open = -1; + + for (int i = 0; i < Text.Length; i++) + { + if (i == index) + { + if (opens.Count == 0) + { + break; + } + + open = opens[^1]; + depth = opens.Count; + } + + char c = Text[i]; + if (inString) + { + if (escaped) + { + escaped = false; + } + else if (c == '\\') + { + escaped = true; + } + else if (c == '"') + { + inString = false; + } + + continue; + } + + if (c == '"') + { + inString = true; + } + else if (c == '{') + { + opens.Add(i); + } + else if (c == '}' && opens.Count > 0) + { + if (opens.Count == depth) + { + close = i + 1; + return true; + } + + opens.RemoveAt(opens.Count - 1); + } + } + + close = Text.Length; + return open >= 0; + } + /// /// Finds at or after , preferring an /// occurrence that is a whole JSON string. @@ -136,10 +236,26 @@ public Location FindLocation(string anchor, string needle) /// 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) + private int IndexOfToken(string value, int startIndex) => IndexOfToken(value, startIndex, Text.Length); + + /// + /// Finds between and + /// , preferring an occurrence that is a whole JSON string. + /// + /// The text to find. + /// Where the search starts. + /// Where the search ends; a match must lie wholly before it. + /// The index of the text itself, inside its quotes when it was found quoted, or -1. + private int IndexOfToken(string value, int startIndex, int endIndex) { - int quoted = Text.IndexOf("\"" + value + "\"", startIndex, StringComparison.Ordinal); - return quoted >= 0 ? quoted + 1 : Text.IndexOf(value, startIndex, StringComparison.Ordinal); + int count = endIndex - startIndex; + if (count <= 0) + { + return -1; + } + + int quoted = Text.IndexOf("\"" + value + "\"", startIndex, count, StringComparison.Ordinal); + return quoted >= 0 ? quoted + 1 : Text.IndexOf(value, startIndex, count, StringComparison.Ordinal); } ///