Skip to content

Commit b39d25e

Browse files
signalblurclaude
andcommitted
fix: ignore braces inside strings and comments when matching blocks
The terraform, protobuf, graphql, and shell parsers locate a block's extent by counting `{` and `}` as raw characters. A brace inside a string literal or a comment decrements the depth early, so the block is truncated: the reported `lineRange` stops short, and anything derived from the body is sliced off with it. `extractMessageFields` returns an empty array for a protobuf message whose first field carries an option with a brace in its value, and `extractFields` loses GraphQL fields that follow a default value containing one. `graphql-parser.ts` had a second problem in `extractDefinitions`: it used `indexOf("}")` with no depth counting at all, so the first brace in the file ended every definition regardless of nesting. Adds `brace-matcher.ts`, a state machine that tracks quote and line-comment context, with a per-language `BraceSyntax` describing which quote and comment forms to honor. `findClosingBrace` returns an index for the parsers that slice by offset; `countBracesPerLine` returns per-line deltas for the shell parser, which walks lines. Unbalanced input still returns `content.length` and warns under the calling parser's name, as the four private implementations did. In `extractDefinitions` the brace scan now runs only when the definition's header line opens a brace, so a bodyless `scalar` or `union` no longer matches a later definition's closing brace. Adds 11 regression tests. Each was confirmed to fail before the fix: string and comment cases per parser, an escaped-quote case, a backslash-continued line case, and the bodyless-scalar case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent fe8c5bc commit b39d25e

7 files changed

Lines changed: 307 additions & 47 deletions

File tree

understand-anything-plugin/packages/core/src/__tests__/parsers.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,3 +627,129 @@ describe("registerAllParsers", () => {
627627
expect(registry.getSupportedLanguages()).toContain("shell");
628628
});
629629
});
630+
631+
describe("brace matching ignores strings and comments", () => {
632+
it("terraform: a closing brace in a string does not end the block", () => {
633+
const content = `resource "aws_ssm_parameter" "example" {
634+
name = "/app/config"
635+
value = "suffix-}-marker"
636+
type = "String"
637+
}`;
638+
const result = new TerraformParser().analyzeFile("main.tf", content);
639+
expect(result.resources![0]).toMatchObject({ name: "aws_ssm_parameter.example" });
640+
expect(result.resources![0].lineRange).toEqual([1, 5]);
641+
});
642+
643+
it("terraform: a closing brace in a comment does not end the block", () => {
644+
const content = `variable "example" {
645+
# a brace in a comment: }
646+
type = string
647+
default = "x"
648+
}`;
649+
const result = new TerraformParser().analyzeFile("main.tf", content);
650+
const def = result.definitions!.find(d => d.name === "example");
651+
expect(def!.lineRange).toEqual([1, 5]);
652+
});
653+
654+
it("protobuf: a closing brace in a comment keeps all fields", () => {
655+
const content = `message Outer {
656+
// a closing brace in a comment: }
657+
string note = 1;
658+
int32 after_comment = 2;
659+
}`;
660+
const result = new ProtobufParser().analyzeFile("a.proto", content);
661+
const msg = result.definitions!.find(d => d.name === "Outer");
662+
expect(msg!.lineRange).toEqual([1, 5]);
663+
expect(msg!.fields).toEqual(["note", "after_comment"]);
664+
});
665+
666+
it("protobuf: a closing brace in a field option keeps later fields", () => {
667+
const content = `message Outer {
668+
string tmpl = 1 [(validate.rules).string.pattern = "^[a-z]}$"];
669+
int32 after_literal = 2;
670+
}`;
671+
const result = new ProtobufParser().analyzeFile("a.proto", content);
672+
const msg = result.definitions!.find(d => d.name === "Outer");
673+
expect(msg!.fields).toEqual(["tmpl", "after_literal"]);
674+
});
675+
676+
it("graphql: a closing brace in a default value keeps all fields", () => {
677+
const content = `input Config {
678+
key: String = "a}b"
679+
count: Int
680+
}`;
681+
const result = new GraphQLParser().analyzeFile("schema.graphql", content);
682+
const def = result.definitions!.find(d => d.name === "Config");
683+
expect(def!.lineRange).toEqual([1, 4]);
684+
expect(def!.fields).toEqual(["key", "count"]);
685+
});
686+
687+
it("graphql: a nested brace does not truncate the line range", () => {
688+
const content = `type Wrapper {
689+
nested: Config @constraint(pattern: "{2,4}")
690+
tail: Int
691+
}`;
692+
const result = new GraphQLParser().analyzeFile("schema.graphql", content);
693+
const def = result.definitions!.find(d => d.name === "Wrapper");
694+
expect(def!.lineRange).toEqual([1, 4]);
695+
});
696+
697+
it("graphql: a scalar without a body stays on its own line", () => {
698+
const content = `scalar DateTime
699+
700+
type User {
701+
id: ID!
702+
}`;
703+
const result = new GraphQLParser().analyzeFile("schema.graphql", content);
704+
const scalar = result.definitions!.find(d => d.name === "DateTime");
705+
expect(scalar!.lineRange).toEqual([1, 1]);
706+
});
707+
708+
it("shell: a closing brace in a string does not end the function", () => {
709+
const content = `#!/usr/bin/env bash
710+
render() {
711+
echo "closing brace: }"
712+
echo "still inside the function"
713+
}`;
714+
const result = new ShellParser().analyzeFile("x.sh", content);
715+
const fn = result.functions!.find(f => f.name === "render");
716+
expect(fn!.lineRange).toEqual([2, 5]);
717+
});
718+
719+
it("shell: a closing brace in a comment does not end the function", () => {
720+
const content = `setup() {
721+
# trailing brace in a comment: }
722+
echo hi
723+
}`;
724+
const result = new ShellParser().analyzeFile("x.sh", content);
725+
const fn = result.functions!.find(f => f.name === "setup");
726+
expect(fn!.lineRange).toEqual([1, 4]);
727+
});
728+
729+
it("shell: escaped quotes do not swallow the rest of the file", () => {
730+
const content = `first() {
731+
echo "an escaped quote \\" and a brace }"
732+
}
733+
734+
second() {
735+
echo hi
736+
}`;
737+
const result = new ShellParser().analyzeFile("x.sh", content);
738+
expect(result.functions!.find(f => f.name === "first")!.lineRange).toEqual([1, 3]);
739+
expect(result.functions!.find(f => f.name === "second")!.lineRange).toEqual([5, 7]);
740+
});
741+
742+
it("shell: a backslash-continued string keeps later line numbers aligned", () => {
743+
const content = `first() {
744+
echo "continued \\
745+
line with a brace }"
746+
}
747+
748+
second() {
749+
echo hi
750+
}`;
751+
const result = new ShellParser().analyzeFile("x.sh", content);
752+
expect(result.functions!.find(f => f.name === "first")!.lineRange).toEqual([1, 4]);
753+
expect(result.functions!.find(f => f.name === "second")!.lineRange).toEqual([6, 8]);
754+
});
755+
});
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* Brace matching that skips string literals and line comments.
3+
*
4+
* Parsers in this directory locate a block's extent by counting `{` and `}`.
5+
* Counting raw characters lets a brace inside a string or comment decrement the
6+
* depth early, truncating the block: the reported `lineRange` stops short, and
7+
* body-derived data (protobuf fields, GraphQL fields) is sliced off with it.
8+
*
9+
* Does not handle block comments, heredocs, or backtick/dollar interpolation.
10+
*/
11+
12+
/** String and comment forms for one language. */
13+
export interface BraceSyntax {
14+
quotes: string[];
15+
lineComments: string[];
16+
backslashEscapes: boolean;
17+
}
18+
19+
/** HCL: double and single quotes, `#` and `//` comments. */
20+
export const TERRAFORM_SYNTAX: BraceSyntax = {
21+
quotes: ['"', "'"],
22+
lineComments: ["#", "//"],
23+
backslashEscapes: true,
24+
};
25+
26+
/** Protobuf: double and single quotes, `//` comments. */
27+
export const PROTOBUF_SYNTAX: BraceSyntax = {
28+
quotes: ['"', "'"],
29+
lineComments: ["//"],
30+
backslashEscapes: true,
31+
};
32+
33+
/** GraphQL: double quotes, `#` comments. */
34+
export const GRAPHQL_SYNTAX: BraceSyntax = {
35+
quotes: ['"'],
36+
lineComments: ["#"],
37+
backslashEscapes: true,
38+
};
39+
40+
/** Shell: double and single quotes, `#` comments. */
41+
export const SHELL_SYNTAX: BraceSyntax = {
42+
quotes: ['"', "'"],
43+
lineComments: ["#"],
44+
backslashEscapes: true,
45+
};
46+
47+
/**
48+
* Finds the index of the `}` closing the first `{` in `content`, ignoring braces
49+
* inside string literals and line comments.
50+
*
51+
* Returns `content.length` when the braces are unbalanced, matching the behavior
52+
* of the per-parser implementations this replaces. When `parserName` is given, an
53+
* unbalanced run warns under that name, as those implementations did.
54+
*/
55+
export function findClosingBrace(content: string, syntax: BraceSyntax, parserName?: string): number {
56+
let depth = 0;
57+
let quote: string | null = null;
58+
let inLineComment = false;
59+
60+
for (let i = 0; i < content.length; i++) {
61+
const ch = content[i];
62+
63+
if (inLineComment) {
64+
if (ch === "\n") inLineComment = false;
65+
continue;
66+
}
67+
68+
if (quote !== null) {
69+
if (syntax.backslashEscapes && ch === "\\") {
70+
i++;
71+
} else if (ch === quote) {
72+
quote = null;
73+
}
74+
continue;
75+
}
76+
77+
if (syntax.quotes.includes(ch)) {
78+
quote = ch;
79+
continue;
80+
}
81+
82+
if (startsLineComment(content, i, syntax.lineComments)) {
83+
inLineComment = true;
84+
continue;
85+
}
86+
87+
if (ch === "{") {
88+
depth++;
89+
} else if (ch === "}") {
90+
depth--;
91+
if (depth === 0) return i;
92+
}
93+
}
94+
95+
if (depth !== 0 && parserName) {
96+
console.warn(`[${parserName}] Unbalanced braces detected (depth=${depth}), results may be incomplete`);
97+
}
98+
return content.length;
99+
}
100+
101+
/**
102+
* Counts `{` and `}` per line, ignoring those inside string literals and line
103+
* comments. Line-oriented parsers need per-line deltas rather than a single index.
104+
*/
105+
export function countBracesPerLine(
106+
content: string,
107+
syntax: BraceSyntax,
108+
): Array<{ open: number; close: number }> {
109+
const perLine: Array<{ open: number; close: number }> = [{ open: 0, close: 0 }];
110+
let quote: string | null = null;
111+
let inLineComment = false;
112+
113+
for (let i = 0; i < content.length; i++) {
114+
const ch = content[i];
115+
116+
if (ch === "\n") {
117+
inLineComment = false;
118+
perLine.push({ open: 0, close: 0 });
119+
continue;
120+
}
121+
122+
if (inLineComment) continue;
123+
124+
if (quote !== null) {
125+
if (syntax.backslashEscapes && ch === "\\") {
126+
// An escaped newline is consumed here, so account for the line it ends —
127+
// otherwise `perLine` desynchronizes from the caller's line array.
128+
if (content[i + 1] === "\n") perLine.push({ open: 0, close: 0 });
129+
i++;
130+
} else if (ch === quote) {
131+
quote = null;
132+
}
133+
continue;
134+
}
135+
136+
if (syntax.quotes.includes(ch)) {
137+
quote = ch;
138+
continue;
139+
}
140+
141+
if (startsLineComment(content, i, syntax.lineComments)) {
142+
inLineComment = true;
143+
continue;
144+
}
145+
146+
const current = perLine[perLine.length - 1];
147+
if (ch === "{") current.open++;
148+
else if (ch === "}") current.close++;
149+
}
150+
151+
return perLine;
152+
}
153+
154+
function startsLineComment(content: string, index: number, prefixes: string[]): boolean {
155+
for (const prefix of prefixes) {
156+
if (content.startsWith(prefix, index)) return true;
157+
}
158+
return false;
159+
}

understand-anything-plugin/packages/core/src/plugins/parsers/graphql-parser.ts

Lines changed: 14 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo, EndpointInfo } from "../../types.js";
2+
import { findClosingBrace, GRAPHQL_SYNTAX } from "./brace-matcher.js";
23

34
/**
45
* Parses GraphQL schema files to extract type, input, enum, interface, union, and scalar definitions.
@@ -37,9 +38,15 @@ export class GraphQLParser implements AnalyzerPlugin {
3738
// Extract fields (for type/input/interface/enum)
3839
const fields = this.extractFields(content, match.index);
3940

40-
// Find closing brace
41+
// Find closing brace. Bodyless definitions (scalar, union) have no brace
42+
// on their header line, so only scan when the header actually opens one —
43+
// otherwise the scan would run on into the next definition's block.
4144
const afterMatch = content.slice(match.index);
42-
const closeBrace = afterMatch.indexOf("}");
45+
const headerEnd = afterMatch.indexOf("\n");
46+
const header = headerEnd === -1 ? afterMatch : afterMatch.slice(0, headerEnd);
47+
const closeBrace = header.includes("{")
48+
? findClosingBrace(afterMatch, GRAPHQL_SYNTAX, "graphql-parser")
49+
: -1;
4350
const endLine = closeBrace !== -1
4451
? content.slice(0, match.index + closeBrace + 1).split("\n").length
4552
: startLine;
@@ -66,15 +73,10 @@ export class GraphQLParser implements AnalyzerPlugin {
6673
const startIdx = match.index + match[0].length;
6774

6875
// Find closing brace
69-
let depth = 1;
70-
let i = startIdx;
71-
while (i < content.length && depth > 0) {
72-
if (content[i] === "{") depth++;
73-
if (content[i] === "}") depth--;
74-
i++;
75-
}
76+
const braceIdx = match.index + match[0].lastIndexOf("{");
77+
const closeBrace = braceIdx + findClosingBrace(content.slice(braceIdx), GRAPHQL_SYNTAX);
7678

77-
const blockContent = content.slice(startIdx, i - 1);
79+
const blockContent = content.slice(startIdx, closeBrace);
7880
const blockLines = blockContent.split("\n");
7981
const blockStartLine = content.slice(0, startIdx).split("\n").length;
8082

@@ -100,15 +102,8 @@ export class GraphQLParser implements AnalyzerPlugin {
100102
const openBrace = afterType.indexOf("{");
101103
if (openBrace === -1) return fields;
102104

103-
let depth = 1;
104-
let i = openBrace + 1;
105-
while (i < afterType.length && depth > 0) {
106-
if (afterType[i] === "{") depth++;
107-
if (afterType[i] === "}") depth--;
108-
i++;
109-
}
110-
111-
const body = afterType.slice(openBrace + 1, i - 1);
105+
const closeBrace = findClosingBrace(afterType, GRAPHQL_SYNTAX);
106+
const body = afterType.slice(openBrace + 1, closeBrace);
112107
const lines = body.split("\n");
113108
for (const line of lines) {
114109
const fieldMatch = line.trim().match(/^(\w+)/);

understand-anything-plugin/packages/core/src/plugins/parsers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export { ProtobufParser } from "./protobuf-parser.js";
1010
export { TerraformParser } from "./terraform-parser.js";
1111
export { MakefileParser } from "./makefile-parser.js";
1212
export { ShellParser } from "./shell-parser.js";
13+
export { findClosingBrace, countBracesPerLine } from "./brace-matcher.js";
1314

1415
import type { PluginRegistry } from "../registry.js";
1516
import { MarkdownParser } from "./markdown-parser.js";

understand-anything-plugin/packages/core/src/plugins/parsers/protobuf-parser.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AnalyzerPlugin, StructuralAnalysis, DefinitionInfo, EndpointInfo } from "../../types.js";
2+
import { findClosingBrace, PROTOBUF_SYNTAX } from "./brace-matcher.js";
23

34
/**
45
* Parses Protocol Buffer (.proto) files to extract message, enum, and service definitions.
@@ -125,17 +126,6 @@ export class ProtobufParser implements AnalyzerPlugin {
125126
}
126127

127128
private findClosingBrace(content: string): number {
128-
let depth = 0;
129-
for (let i = 0; i < content.length; i++) {
130-
if (content[i] === "{") depth++;
131-
if (content[i] === "}") {
132-
depth--;
133-
if (depth === 0) return i;
134-
}
135-
}
136-
if (depth !== 0) {
137-
console.warn(`[protobuf-parser] Unbalanced braces detected (depth=${depth}), results may be incomplete`);
138-
}
139-
return content.length;
129+
return findClosingBrace(content, PROTOBUF_SYNTAX, "protobuf-parser");
140130
}
141131
}

0 commit comments

Comments
 (0)