From bcc43d627fc988c20e07c3c89f6625ab0b96d4d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 06:28:44 +0000 Subject: [PATCH] Point scoped FindLocation at the anchor's own entry when the needle is written first [patch] FindLocation(anchor, needle) only searched forward from the anchor, so a needle written before the anchor in the same JSON object was found in a later entry, or fell back to an earlier one. Search the anchor's enclosing object first, after then before the anchor, skipping strings and nested objects, before the forward and unscoped fallbacks. Fixes #66 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3tezLDV1yfkEQRQji --- .../MetadataFileTests.cs | 50 +++++++ SourceGeneratorToolkit/MetadataFile.cs | 128 +++++++++++++++++- 2 files changed, 172 insertions(+), 6 deletions(-) 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); } ///