From bb0f6c2c7d17c31449a3dd893283c274823d7614 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 4 Apr 2026 00:26:52 -0400 Subject: [PATCH 1/5] Fix CodeLens positioned inside preceding code blocks (#15618) The _findStatementStartLine() method walked backwards from a resource match looking for ';' or '{' but ignored '}'. When a code block like if (...) { ... } preceded a resource call, the algorithm walked past the '}' into the block and stopped at its '{', placing the CodeLens inside the preceding block instead of on the resource line. Fix: add '}' as a statement delimiter in the backward walk so that closing braces correctly bound the start of the current statement. Also fix tsconfig.json to include mocha types for test globals and skipLibCheck to avoid MCP duplicate type definition conflicts. --- .../src/editor/parsers/csharpAppHostParser.ts | 2 +- .../src/editor/parsers/jsTsAppHostParser.ts | 2 +- extension/src/test/parsers.test.ts | 432 ++++++++++++++++++ extension/tsconfig.json | 7 +- 4 files changed, 440 insertions(+), 3 deletions(-) diff --git a/extension/src/editor/parsers/csharpAppHostParser.ts b/extension/src/editor/parsers/csharpAppHostParser.ts index 843371f14b8..895b7c086f3 100644 --- a/extension/src/editor/parsers/csharpAppHostParser.ts +++ b/extension/src/editor/parsers/csharpAppHostParser.ts @@ -58,7 +58,7 @@ class CSharpAppHostParser implements AppHostResourceParser { let i = matchIndex - 1; while (i >= 0) { const ch = text[i]; - if (ch === ';' || ch === '{') { + if (ch === ';' || ch === '{' || ch === '}') { break; } i--; diff --git a/extension/src/editor/parsers/jsTsAppHostParser.ts b/extension/src/editor/parsers/jsTsAppHostParser.ts index 4988682c686..a6ac3772f62 100644 --- a/extension/src/editor/parsers/jsTsAppHostParser.ts +++ b/extension/src/editor/parsers/jsTsAppHostParser.ts @@ -59,7 +59,7 @@ class JsTsAppHostParser implements AppHostResourceParser { let i = matchIndex - 1; while (i >= 0) { const ch = text[i]; - if (ch === ';' || ch === '{') { + if (ch === ';' || ch === '{' || ch === '}') { break; } i--; diff --git a/extension/src/test/parsers.test.ts b/extension/src/test/parsers.test.ts index af7f6935001..a93be1b2ea2 100644 --- a/extension/src/test/parsers.test.ts +++ b/extension/src/test/parsers.test.ts @@ -586,6 +586,222 @@ suite('CSharpAppHostParser', () => { } }); + // --- statementStartLine: preceding code blocks (issue #15618) --- + + test('statementStartLine not affected by preceding if block with braces', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false)', + '{', + ' throw new Exception("This should not work");', + '}', + '', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].name, 'nginx'); + assert.strictEqual(resources[0].statementStartLine, 7, 'statement should start on builder.AddContainer line, not inside the if block'); + }); + + test('statementStartLine not affected by preceding nested braces', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (true)', + '{', + ' if (false)', + ' {', + ' throw new Exception("nested");', + ' }', + '}', + '', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 10, 'statement should start on builder.AddContainer line, not inside nested blocks'); + }); + + test('statementStartLine not affected by preceding block with semicolons inside', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (true)', + '{', + ' Console.WriteLine("hello");', + ' Console.WriteLine("world");', + '}', + '', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start on builder.AddContainer line'); + }); + + test('statementStartLine with comment between block and resource call', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false)', + '{', + ' throw new Exception("fail");', + '}', + '', + '// Add nginx container', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start on builder.AddContainer line, skipping the comment'); + }); + + test('statementStartLine with multi-line fluent chain after a block', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false)', + '{', + ' throw new Exception("fail");', + '}', + '', + 'var nginx = builder', + ' .AddContainer("nginx", "nginx")', + ' .WithEndpoint(80);', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 7, 'statement should start at var nginx = builder, not inside the if block'); + }); + + test('statementStartLine not affected by preceding try/catch block', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'try', + '{', + ' DoSomething();', + '}', + 'catch (Exception ex)', + '{', + ' Console.WriteLine(ex);', + '}', + '', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 11, 'statement should start on builder.AddContainer line, not inside try/catch'); + }); + + test('statementStartLine not affected by preceding single-line block', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false) { throw new Exception("fail"); }', + '', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 4, 'statement should start on builder.AddContainer line, not inside single-line block'); + }); + + test('statementStartLine not affected by preceding empty block', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (true) { }', + '', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 4, 'statement should start on builder.AddContainer line, not inside empty block'); + }); + + test('statementStartLine not affected by preceding for loop', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'for (int i = 0; i < 10; i++)', + '{', + ' Console.WriteLine(i);', + '}', + '', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 7, 'statement should start on builder.AddContainer line, not inside for loop'); + }); + + test('statementStartLine correct for multiple resources after blocks', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false)', + '{', + ' throw new Exception("fail");', + '}', + '', + 'builder.AddContainer("nginx", "nginx");', + '', + 'if (true)', + '{', + ' Console.WriteLine("ok");', + '}', + '', + 'builder.AddRedis("cache");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 2); + assert.strictEqual(resources[0].statementStartLine, 7, 'first resource should start on its own line'); + assert.strictEqual(resources[1].statementStartLine, 14, 'second resource should start on its own line'); + }); + // --- Pipeline step classification --- test('classifies AddStep as pipelineStep', () => { @@ -1066,6 +1282,222 @@ suite('JsTsAppHostParser', () => { } }); + // --- statementStartLine: preceding code blocks (issue #15618) --- + + test('statementStartLine not affected by preceding if block with braces', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false)', + '{', + ' throw new Error("This should not work");', + '}', + '', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].name, 'nginx'); + assert.strictEqual(resources[0].statementStartLine, 7, 'statement should start on builder.addContainer line, not inside the if block'); + }); + + test('statementStartLine not affected by preceding nested braces', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (true)', + '{', + ' if (false)', + ' {', + ' throw new Error("nested");', + ' }', + '}', + '', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 10, 'statement should start on builder.addContainer line, not inside nested blocks'); + }); + + test('statementStartLine not affected by preceding block with semicolons inside', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (true)', + '{', + ' console.log("hello");', + ' console.log("world");', + '}', + '', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start on builder.addContainer line'); + }); + + test('statementStartLine with comment between block and resource call', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false)', + '{', + ' throw new Error("fail");', + '}', + '', + '// Add nginx container', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start on builder.addContainer line, skipping the comment'); + }); + + test('statementStartLine with multi-line fluent chain after a block', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false)', + '{', + ' throw new Error("fail");', + '}', + '', + 'const nginx = builder', + ' .addContainer("nginx", "nginx")', + ' .withEndpoint(80);', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 7, 'statement should start at const nginx = builder, not inside the if block'); + }); + + test('statementStartLine not affected by preceding try/catch block', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'try', + '{', + ' doSomething();', + '}', + 'catch (e)', + '{', + ' console.error(e);', + '}', + '', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 11, 'statement should start on builder.addContainer line, not inside try/catch'); + }); + + test('statementStartLine not affected by preceding single-line block', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false) { throw new Error("fail"); }', + '', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 4, 'statement should start on builder.addContainer line, not inside single-line block'); + }); + + test('statementStartLine not affected by preceding empty block', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (true) { }', + '', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 4, 'statement should start on builder.addContainer line, not inside empty block'); + }); + + test('statementStartLine not affected by preceding for loop', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'for (let i = 0; i < 10; i++)', + '{', + ' console.log(i);', + '}', + '', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 7, 'statement should start on builder.addContainer line, not inside for loop'); + }); + + test('statementStartLine correct for multiple resources after blocks', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false)', + '{', + ' throw new Error("fail");', + '}', + '', + 'builder.addContainer("nginx", "nginx");', + '', + 'if (true)', + '{', + ' console.log("ok");', + '}', + '', + 'builder.addRedis("cache");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 2); + assert.strictEqual(resources[0].statementStartLine, 7, 'first resource should start on its own line'); + assert.strictEqual(resources[1].statementStartLine, 14, 'second resource should start on its own line'); + }); + // --- Pipeline step classification --- test('classifies addStep as pipelineStep', () => { diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 416b71eb5c9..7ef241a842e 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -8,7 +8,12 @@ "sourceMap": true, "rootDir": "src", "strict": true, /* enable all strict type-checking options */ - "outDir": "out" + "outDir": "out", + "skipLibCheck": true, + "types": [ + "mocha", + "node" + ] /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ From a165d02070cca441ae04b819d3cef04dbfc55137 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 4 Apr 2026 00:32:05 -0400 Subject: [PATCH 2/5] Add comment configuration tests for CodeLens positioning --- extension/src/test/parsers.test.ts | 136 +++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/extension/src/test/parsers.test.ts b/extension/src/test/parsers.test.ts index a93be1b2ea2..522c3b722ed 100644 --- a/extension/src/test/parsers.test.ts +++ b/extension/src/test/parsers.test.ts @@ -674,6 +674,74 @@ suite('CSharpAppHostParser', () => { assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start on builder.AddContainer line, skipping the comment'); }); + test('statementStartLine with block comment between block and resource call', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false)', + '{', + ' throw new Exception("fail");', + '}', + '', + '/* Add nginx container */', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start on builder.AddContainer line, skipping block comment'); + }); + + test('statementStartLine with mixed comments between block and resource call', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false)', + '{', + ' throw new Exception("fail");', + '}', + '', + '// Line comment', + '/* Block comment', + ' * continuation', + ' */', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 11, 'statement should start on builder.AddContainer line, skipping all comments'); + }); + + test('statementStartLine with comment between block and fluent chain', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false)', + '{', + ' throw new Exception("fail");', + '}', + '', + '// Add nginx', + 'var nginx = builder', + ' .AddContainer("nginx", "nginx")', + ' .WithEndpoint(80);', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start at var nginx, skipping comment after block'); + }); + test('statementStartLine with multi-line fluent chain after a block', () => { const parser = getCSharpParser(); const doc = createMockDocument( @@ -1370,6 +1438,74 @@ suite('JsTsAppHostParser', () => { assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start on builder.addContainer line, skipping the comment'); }); + test('statementStartLine with block comment between block and resource call', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false)', + '{', + ' throw new Error("fail");', + '}', + '', + '/* Add nginx container */', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start on builder.addContainer line, skipping block comment'); + }); + + test('statementStartLine with mixed comments between block and resource call', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false)', + '{', + ' throw new Error("fail");', + '}', + '', + '// Line comment', + '/* Block comment', + ' * continuation', + ' */', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 11, 'statement should start on builder.addContainer line, skipping all comments'); + }); + + test('statementStartLine with comment between block and fluent chain', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false)', + '{', + ' throw new Error("fail");', + '}', + '', + '// Add nginx', + 'const nginx = builder', + ' .addContainer("nginx", "nginx")', + ' .withEndpoint(80);', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 8, 'statement should start at const nginx, skipping comment after block'); + }); + test('statementStartLine with multi-line fluent chain after a block', () => { const parser = getJsTsParser(); const doc = createMockDocument( From c975ad8f22df912a52fa589a9553da76b07126b6 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Sat, 4 Apr 2026 00:40:09 -0400 Subject: [PATCH 3/5] Address review: update comments, skip } lines in comment loop, add trailing comment test --- .../src/editor/parsers/csharpAppHostParser.ts | 8 ++-- .../src/editor/parsers/jsTsAppHostParser.ts | 8 ++-- extension/src/test/parsers.test.ts | 38 +++++++++++++++++++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/extension/src/editor/parsers/csharpAppHostParser.ts b/extension/src/editor/parsers/csharpAppHostParser.ts index 895b7c086f3..f9af2d82350 100644 --- a/extension/src/editor/parsers/csharpAppHostParser.ts +++ b/extension/src/editor/parsers/csharpAppHostParser.ts @@ -34,7 +34,7 @@ class CSharpAppHostParser implements AppHostResourceParser { const startPos = document.positionAt(matchStart); const endPos = document.positionAt(matchStart + match[0].length); - // Find the start of the full statement (walk back to previous semicolon, '{', or start of file) + // Find the start of the full statement (walk back to previous ';', '{', '}', or start of file) const statementStartLine = this._findStatementStartLine(text, matchStart, document); results.push({ @@ -51,7 +51,7 @@ class CSharpAppHostParser implements AppHostResourceParser { /** * Walk backwards from the match position to find the first line of the statement. - * Stops at the previous ';', '{', or start of file, then returns the first non-comment, + * Stops at the previous ';', '{', '}', or start of file, then returns the first non-comment, * non-blank line after that delimiter. */ private _findStatementStartLine(text: string, matchIndex: number, document: vscode.TextDocument): number { @@ -71,10 +71,10 @@ class CSharpAppHostParser implements AppHostResourceParser { } let line = document.positionAt(start).line; const matchLine = document.positionAt(matchIndex).line; - // Skip lines that are comments (// or /* or * continuation) + // Skip lines that are closing braces or comments (// or /* or * continuation) while (line < matchLine) { const lineText = document.lineAt(line).text.trimStart(); - if (lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { + if (lineText.startsWith('}') || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { line++; } else { break; diff --git a/extension/src/editor/parsers/jsTsAppHostParser.ts b/extension/src/editor/parsers/jsTsAppHostParser.ts index a6ac3772f62..77f6e15989f 100644 --- a/extension/src/editor/parsers/jsTsAppHostParser.ts +++ b/extension/src/editor/parsers/jsTsAppHostParser.ts @@ -35,7 +35,7 @@ class JsTsAppHostParser implements AppHostResourceParser { const startPos = document.positionAt(matchStart); const endPos = document.positionAt(matchStart + match[0].length); - // Find the start of the full statement (walk back to previous ';', '{', or start of file) + // Find the start of the full statement (walk back to previous ';', '{', '}', or start of file) const statementStartLine = this._findStatementStartLine(text, matchStart, document); results.push({ @@ -52,7 +52,7 @@ class JsTsAppHostParser implements AppHostResourceParser { /** * Walk backwards from the match position to find the first line of the statement. - * Stops at the previous ';', '{', or start of file, then returns the first non-comment, + * Stops at the previous ';', '{', '}', or start of file, then returns the first non-comment, * non-blank line after that delimiter. */ private _findStatementStartLine(text: string, matchIndex: number, document: vscode.TextDocument): number { @@ -70,10 +70,10 @@ class JsTsAppHostParser implements AppHostResourceParser { } let line = document.positionAt(start).line; const matchLine = document.positionAt(matchIndex).line; - // Skip lines that are comments (// or /* or * continuation) + // Skip lines that are closing braces or comments (// or /* or * continuation) while (line < matchLine) { const lineText = document.lineAt(line).text.trimStart(); - if (lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { + if (lineText.startsWith('}') || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { line++; } else { break; diff --git a/extension/src/test/parsers.test.ts b/extension/src/test/parsers.test.ts index 522c3b722ed..dc6249d93c9 100644 --- a/extension/src/test/parsers.test.ts +++ b/extension/src/test/parsers.test.ts @@ -870,6 +870,25 @@ suite('CSharpAppHostParser', () => { assert.strictEqual(resources[1].statementStartLine, 14, 'second resource should start on its own line'); }); + test('statementStartLine not affected by closing brace with trailing comment', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (false)', + '{', + ' throw new Exception("fail");', + '} // end if', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 6, 'statement should start on builder.AddContainer line, not on } // end if line'); + }); + // --- Pipeline step classification --- test('classifies AddStep as pipelineStep', () => { @@ -1634,6 +1653,25 @@ suite('JsTsAppHostParser', () => { assert.strictEqual(resources[1].statementStartLine, 14, 'second resource should start on its own line'); }); + test('statementStartLine not affected by closing brace with trailing comment', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (false)', + '{', + ' throw new Error("fail");', + '} // end if', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 6, 'statement should start on builder.addContainer line, not on } // end if line'); + }); + // --- Pipeline step classification --- test('classifies addStep as pipelineStep', () => { From a4e53b8b8b735537c00c599341d6bbce33996bd8 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Wed, 8 Apr 2026 13:35:54 -0400 Subject: [PATCH 4/5] Address review: fix callback chain regression, revert tsconfig, handle } else { - Fix _findStatementStartLine to track brace nesting: when a '}' is encountered during the backward walk, match it with '{' and check if preceded by '=>'. Lambda bodies are skipped; other blocks are treated as statement boundaries. Fixes regression where callback lambdas in fluent chains (e.g. .WithPgAdmin(r => { ... }).AddDatabase()) caused CodeLens to stop at the callback's '}' instead of reaching the top of the chain. - Change } line-skip to only skip lines matching '} [// comment]', not lines like '} else {' or '} catch (e) {'. - Revert tsconfig.json: remove skipLibCheck and types array (compilation succeeds without them; the mocha/MCP type issues are pre-existing and should be addressed separately). - Add tests for callback lambda chains, RunAsContainer callbacks, and } else { between blocks and resource calls. --- .../src/editor/parsers/csharpAppHostParser.ts | 55 +++++++- .../src/editor/parsers/jsTsAppHostParser.ts | 55 +++++++- extension/src/test/parsers.test.ts | 128 ++++++++++++++++++ extension/tsconfig.json | 7 +- 4 files changed, 229 insertions(+), 16 deletions(-) diff --git a/extension/src/editor/parsers/csharpAppHostParser.ts b/extension/src/editor/parsers/csharpAppHostParser.ts index f9af2d82350..e06f84a91f1 100644 --- a/extension/src/editor/parsers/csharpAppHostParser.ts +++ b/extension/src/editor/parsers/csharpAppHostParser.ts @@ -51,14 +51,30 @@ class CSharpAppHostParser implements AppHostResourceParser { /** * Walk backwards from the match position to find the first line of the statement. - * Stops at the previous ';', '{', '}', or start of file, then returns the first non-comment, - * non-blank line after that delimiter. + * Stops at the previous ';', '{', or start of file, then returns the first non-comment, + * non-blank line after that delimiter. When a '}' is encountered, the matched '{...}' + * block is inspected: if preceded by '=>' it is a lambda body within the current fluent + * chain and is skipped; otherwise the '}' is treated as a statement boundary. */ private _findStatementStartLine(text: string, matchIndex: number, document: vscode.TextDocument): number { let i = matchIndex - 1; while (i >= 0) { const ch = text[i]; - if (ch === ';' || ch === '{' || ch === '}') { + if (ch === ';' || ch === '{') { + break; + } + if (ch === '}') { + const openBraceIdx = this._findMatchingOpenBrace(text, i); + if (openBraceIdx < 0) { + // No matching open brace — treat as delimiter + break; + } + if (this._isPrecededByArrow(text, openBraceIdx)) { + // Lambda body in the current fluent chain — skip over it + i = openBraceIdx - 1; + continue; + } + // Separate statement block — treat '}' as delimiter break; } i--; @@ -71,10 +87,10 @@ class CSharpAppHostParser implements AppHostResourceParser { } let line = document.positionAt(start).line; const matchLine = document.positionAt(matchIndex).line; - // Skip lines that are closing braces or comments (// or /* or * continuation) + // Skip lines that are only closing braces (with optional comment) or comments while (line < matchLine) { const lineText = document.lineAt(line).text.trimStart(); - if (lineText.startsWith('}') || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { + if (/^\}\s*(\/\/.*)?$/.test(lineText) || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { line++; } else { break; @@ -82,6 +98,35 @@ class CSharpAppHostParser implements AppHostResourceParser { } return line; } + + /** + * Starting from a '}' at closeBraceIdx, walk backwards to find the matching '{'. + * Returns the index of '{', or -1 if not found. + */ + private _findMatchingOpenBrace(text: string, closeBraceIdx: number): number { + let depth = 1; + let j = closeBraceIdx - 1; + while (j >= 0 && depth > 0) { + if (text[j] === '}') { + depth++; + } else if (text[j] === '{') { + depth--; + } + j--; + } + return depth === 0 ? j + 1 : -1; + } + + /** + * Check whether the '{' at openBraceIdx is preceded (ignoring whitespace) by '=>'. + */ + private _isPrecededByArrow(text: string, openBraceIdx: number): boolean { + let k = openBraceIdx - 1; + while (k >= 0 && /\s/.test(text[k])) { + k--; + } + return k >= 1 && text[k - 1] === '=' && text[k] === '>'; + } } // Self-register on import diff --git a/extension/src/editor/parsers/jsTsAppHostParser.ts b/extension/src/editor/parsers/jsTsAppHostParser.ts index 77f6e15989f..9b9913124b9 100644 --- a/extension/src/editor/parsers/jsTsAppHostParser.ts +++ b/extension/src/editor/parsers/jsTsAppHostParser.ts @@ -52,14 +52,30 @@ class JsTsAppHostParser implements AppHostResourceParser { /** * Walk backwards from the match position to find the first line of the statement. - * Stops at the previous ';', '{', '}', or start of file, then returns the first non-comment, - * non-blank line after that delimiter. + * Stops at the previous ';', '{', or start of file, then returns the first non-comment, + * non-blank line after that delimiter. When a '}' is encountered, the matched '{...}' + * block is inspected: if preceded by '=>' it is a lambda body within the current fluent + * chain and is skipped; otherwise the '}' is treated as a statement boundary. */ private _findStatementStartLine(text: string, matchIndex: number, document: vscode.TextDocument): number { let i = matchIndex - 1; while (i >= 0) { const ch = text[i]; - if (ch === ';' || ch === '{' || ch === '}') { + if (ch === ';' || ch === '{') { + break; + } + if (ch === '}') { + const openBraceIdx = this._findMatchingOpenBrace(text, i); + if (openBraceIdx < 0) { + // No matching open brace — treat as delimiter + break; + } + if (this._isPrecededByArrow(text, openBraceIdx)) { + // Lambda body in the current fluent chain — skip over it + i = openBraceIdx - 1; + continue; + } + // Separate statement block — treat '}' as delimiter break; } i--; @@ -70,10 +86,10 @@ class JsTsAppHostParser implements AppHostResourceParser { } let line = document.positionAt(start).line; const matchLine = document.positionAt(matchIndex).line; - // Skip lines that are closing braces or comments (// or /* or * continuation) + // Skip lines that are only closing braces (with optional comment) or comments while (line < matchLine) { const lineText = document.lineAt(line).text.trimStart(); - if (lineText.startsWith('}') || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { + if (/^\}\s*(\/\/.*)?$/.test(lineText) || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { line++; } else { break; @@ -81,6 +97,35 @@ class JsTsAppHostParser implements AppHostResourceParser { } return line; } + + /** + * Starting from a '}' at closeBraceIdx, walk backwards to find the matching '{'. + * Returns the index of '{', or -1 if not found. + */ + private _findMatchingOpenBrace(text: string, closeBraceIdx: number): number { + let depth = 1; + let j = closeBraceIdx - 1; + while (j >= 0 && depth > 0) { + if (text[j] === '}') { + depth++; + } else if (text[j] === '{') { + depth--; + } + j--; + } + return depth === 0 ? j + 1 : -1; + } + + /** + * Check whether the '{' at openBraceIdx is preceded (ignoring whitespace) by '=>'. + */ + private _isPrecededByArrow(text: string, openBraceIdx: number): boolean { + let k = openBraceIdx - 1; + while (k >= 0 && /\s/.test(text[k])) { + k--; + } + return k >= 1 && text[k - 1] === '=' && text[k] === '>'; + } } // Self-register on import diff --git a/extension/src/test/parsers.test.ts b/extension/src/test/parsers.test.ts index dc6249d93c9..a183a6989e6 100644 --- a/extension/src/test/parsers.test.ts +++ b/extension/src/test/parsers.test.ts @@ -889,6 +889,70 @@ suite('CSharpAppHostParser', () => { assert.strictEqual(resources[0].statementStartLine, 6, 'statement should start on builder.AddContainer line, not on } // end if line'); }); + test('statementStartLine reaches top of fluent chain through callback lambda', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'var catalogDb = builder.AddPostgres("postgres")', + ' .WithPgAdmin(resource => {', + ' resource.SomeConfig();', + ' })', + ' .AddDatabase("catalogdb");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 2); + assert.strictEqual(resources[0].name, 'postgres'); + assert.strictEqual(resources[0].statementStartLine, 2, 'AddPostgres starts at var catalogDb'); + assert.strictEqual(resources[1].name, 'catalogdb'); + assert.strictEqual(resources[1].statementStartLine, 2, 'AddDatabase should also start at var catalogDb, not after callback }'); + }); + + test('statementStartLine reaches top of fluent chain through RunAsContainer callback', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'var db = builder.AddPostgres("postgres")', + ' .RunAsContainer(c => {', + ' c.WithLifetime(ContainerLifetime.Persistent);', + ' })', + ' .AddDatabase("db");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 2); + assert.strictEqual(resources[1].name, 'db'); + assert.strictEqual(resources[1].statementStartLine, 2, 'AddDatabase should start at var db, not after callback }'); + }); + + test('statementStartLine not affected by } else { between block and resource', () => { + const parser = getCSharpParser(); + const doc = createMockDocument( + [ + 'var builder = DistributedApplication.CreateBuilder(args);', + '', + 'if (true)', + '{', + ' DoSomething();', + '} else {', + ' DoSomethingElse();', + '}', + '', + 'builder.AddContainer("nginx", "nginx");', + ].join('\n'), + '/test/AppHost.cs' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 9, 'statement should start on builder.AddContainer line'); + }); + // --- Pipeline step classification --- test('classifies AddStep as pipelineStep', () => { @@ -1672,6 +1736,70 @@ suite('JsTsAppHostParser', () => { assert.strictEqual(resources[0].statementStartLine, 6, 'statement should start on builder.addContainer line, not on } // end if line'); }); + test('statementStartLine reaches top of fluent chain through callback arrow function', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'const catalogDb = builder.addPostgres("postgres")', + ' .withPgAdmin((resource) => {', + ' resource.someConfig();', + ' })', + ' .addDatabase("catalogdb");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 2); + assert.strictEqual(resources[0].name, 'postgres'); + assert.strictEqual(resources[0].statementStartLine, 2, 'addPostgres starts at const catalogDb'); + assert.strictEqual(resources[1].name, 'catalogdb'); + assert.strictEqual(resources[1].statementStartLine, 2, 'addDatabase should also start at const catalogDb, not after callback }'); + }); + + test('statementStartLine reaches top of fluent chain through runAsContainer callback', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'const db = builder.addPostgres("postgres")', + ' .runAsContainer((c) => {', + ' c.withLifetime("persistent");', + ' })', + ' .addDatabase("db");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 2); + assert.strictEqual(resources[1].name, 'db'); + assert.strictEqual(resources[1].statementStartLine, 2, 'addDatabase should start at const db, not after callback }'); + }); + + test('statementStartLine not affected by } else { between block and resource', () => { + const parser = getJsTsParser(); + const doc = createMockDocument( + [ + 'import { createBuilder } from "@aspire/sdk";', + '', + 'if (true)', + '{', + ' doSomething();', + '} else {', + ' doSomethingElse();', + '}', + '', + 'builder.addContainer("nginx", "nginx");', + ].join('\n'), + '/test/apphost.ts' + ); + const resources = parser.parseResources(doc); + assert.strictEqual(resources.length, 1); + assert.strictEqual(resources[0].statementStartLine, 9, 'statement should start on builder.addContainer line'); + }); + // --- Pipeline step classification --- test('classifies addStep as pipelineStep', () => { diff --git a/extension/tsconfig.json b/extension/tsconfig.json index 7ef241a842e..416b71eb5c9 100644 --- a/extension/tsconfig.json +++ b/extension/tsconfig.json @@ -8,12 +8,7 @@ "sourceMap": true, "rootDir": "src", "strict": true, /* enable all strict type-checking options */ - "outDir": "out", - "skipLibCheck": true, - "types": [ - "mocha", - "node" - ] + "outDir": "out" /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ From 1340d25d4b5c92a14fee8deea99ef3e2823a5d14 Mon Sep 17 00:00:00 2001 From: Adam Ratzman Date: Thu, 9 Apr 2026 00:35:10 -0400 Subject: [PATCH 5/5] Extract shared statement-finding logic into parserUtils Move _findStatementStartLine, _findMatchingOpenBrace, and _isPrecededByArrow from both C# and JS/TS parsers into a shared parserUtils.ts module. Both parsers now import from parserUtils instead of duplicating the identical implementations. Addresses review feedback from JamesNK about code duplication. --- .../src/editor/parsers/csharpAppHostParser.ts | 81 +----------------- .../src/editor/parsers/jsTsAppHostParser.ts | 79 +----------------- extension/src/editor/parsers/parserUtils.ts | 83 +++++++++++++++++++ 3 files changed, 87 insertions(+), 156 deletions(-) create mode 100644 extension/src/editor/parsers/parserUtils.ts diff --git a/extension/src/editor/parsers/csharpAppHostParser.ts b/extension/src/editor/parsers/csharpAppHostParser.ts index e06f84a91f1..b123b112c68 100644 --- a/extension/src/editor/parsers/csharpAppHostParser.ts +++ b/extension/src/editor/parsers/csharpAppHostParser.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { AppHostResourceParser, ParsedResource, registerParser } from './AppHostResourceParser'; +import { findStatementStartLine } from './parserUtils'; /** * C# AppHost resource parser. @@ -35,7 +36,7 @@ class CSharpAppHostParser implements AppHostResourceParser { const endPos = document.positionAt(matchStart + match[0].length); // Find the start of the full statement (walk back to previous ';', '{', '}', or start of file) - const statementStartLine = this._findStatementStartLine(text, matchStart, document); + const statementStartLine = findStatementStartLine(text, matchStart, document); results.push({ name: resourceName, @@ -49,84 +50,6 @@ class CSharpAppHostParser implements AppHostResourceParser { return results; } - /** - * Walk backwards from the match position to find the first line of the statement. - * Stops at the previous ';', '{', or start of file, then returns the first non-comment, - * non-blank line after that delimiter. When a '}' is encountered, the matched '{...}' - * block is inspected: if preceded by '=>' it is a lambda body within the current fluent - * chain and is skipped; otherwise the '}' is treated as a statement boundary. - */ - private _findStatementStartLine(text: string, matchIndex: number, document: vscode.TextDocument): number { - let i = matchIndex - 1; - while (i >= 0) { - const ch = text[i]; - if (ch === ';' || ch === '{') { - break; - } - if (ch === '}') { - const openBraceIdx = this._findMatchingOpenBrace(text, i); - if (openBraceIdx < 0) { - // No matching open brace — treat as delimiter - break; - } - if (this._isPrecededByArrow(text, openBraceIdx)) { - // Lambda body in the current fluent chain — skip over it - i = openBraceIdx - 1; - continue; - } - // Separate statement block — treat '}' as delimiter - break; - } - i--; - } - // i is now at the delimiter or -1 (start of file) - // Find the first non-whitespace character after the delimiter - let start = i + 1; - while (start < matchIndex && /\s/.test(text[start])) { - start++; - } - let line = document.positionAt(start).line; - const matchLine = document.positionAt(matchIndex).line; - // Skip lines that are only closing braces (with optional comment) or comments - while (line < matchLine) { - const lineText = document.lineAt(line).text.trimStart(); - if (/^\}\s*(\/\/.*)?$/.test(lineText) || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { - line++; - } else { - break; - } - } - return line; - } - - /** - * Starting from a '}' at closeBraceIdx, walk backwards to find the matching '{'. - * Returns the index of '{', or -1 if not found. - */ - private _findMatchingOpenBrace(text: string, closeBraceIdx: number): number { - let depth = 1; - let j = closeBraceIdx - 1; - while (j >= 0 && depth > 0) { - if (text[j] === '}') { - depth++; - } else if (text[j] === '{') { - depth--; - } - j--; - } - return depth === 0 ? j + 1 : -1; - } - - /** - * Check whether the '{' at openBraceIdx is preceded (ignoring whitespace) by '=>'. - */ - private _isPrecededByArrow(text: string, openBraceIdx: number): boolean { - let k = openBraceIdx - 1; - while (k >= 0 && /\s/.test(text[k])) { - k--; - } - return k >= 1 && text[k - 1] === '=' && text[k] === '>'; - } } // Self-register on import diff --git a/extension/src/editor/parsers/jsTsAppHostParser.ts b/extension/src/editor/parsers/jsTsAppHostParser.ts index 9b9913124b9..81a4f191941 100644 --- a/extension/src/editor/parsers/jsTsAppHostParser.ts +++ b/extension/src/editor/parsers/jsTsAppHostParser.ts @@ -1,5 +1,6 @@ import * as vscode from 'vscode'; import { AppHostResourceParser, ParsedResource, registerParser } from './AppHostResourceParser'; +import { findStatementStartLine } from './parserUtils'; /** * JavaScript / TypeScript AppHost resource parser. @@ -36,7 +37,7 @@ class JsTsAppHostParser implements AppHostResourceParser { const endPos = document.positionAt(matchStart + match[0].length); // Find the start of the full statement (walk back to previous ';', '{', '}', or start of file) - const statementStartLine = this._findStatementStartLine(text, matchStart, document); + const statementStartLine = findStatementStartLine(text, matchStart, document); results.push({ name: resourceName, @@ -50,82 +51,6 @@ class JsTsAppHostParser implements AppHostResourceParser { return results; } - /** - * Walk backwards from the match position to find the first line of the statement. - * Stops at the previous ';', '{', or start of file, then returns the first non-comment, - * non-blank line after that delimiter. When a '}' is encountered, the matched '{...}' - * block is inspected: if preceded by '=>' it is a lambda body within the current fluent - * chain and is skipped; otherwise the '}' is treated as a statement boundary. - */ - private _findStatementStartLine(text: string, matchIndex: number, document: vscode.TextDocument): number { - let i = matchIndex - 1; - while (i >= 0) { - const ch = text[i]; - if (ch === ';' || ch === '{') { - break; - } - if (ch === '}') { - const openBraceIdx = this._findMatchingOpenBrace(text, i); - if (openBraceIdx < 0) { - // No matching open brace — treat as delimiter - break; - } - if (this._isPrecededByArrow(text, openBraceIdx)) { - // Lambda body in the current fluent chain — skip over it - i = openBraceIdx - 1; - continue; - } - // Separate statement block — treat '}' as delimiter - break; - } - i--; - } - let start = i + 1; - while (start < matchIndex && /\s/.test(text[start])) { - start++; - } - let line = document.positionAt(start).line; - const matchLine = document.positionAt(matchIndex).line; - // Skip lines that are only closing braces (with optional comment) or comments - while (line < matchLine) { - const lineText = document.lineAt(line).text.trimStart(); - if (/^\}\s*(\/\/.*)?$/.test(lineText) || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { - line++; - } else { - break; - } - } - return line; - } - - /** - * Starting from a '}' at closeBraceIdx, walk backwards to find the matching '{'. - * Returns the index of '{', or -1 if not found. - */ - private _findMatchingOpenBrace(text: string, closeBraceIdx: number): number { - let depth = 1; - let j = closeBraceIdx - 1; - while (j >= 0 && depth > 0) { - if (text[j] === '}') { - depth++; - } else if (text[j] === '{') { - depth--; - } - j--; - } - return depth === 0 ? j + 1 : -1; - } - - /** - * Check whether the '{' at openBraceIdx is preceded (ignoring whitespace) by '=>'. - */ - private _isPrecededByArrow(text: string, openBraceIdx: number): boolean { - let k = openBraceIdx - 1; - while (k >= 0 && /\s/.test(text[k])) { - k--; - } - return k >= 1 && text[k - 1] === '=' && text[k] === '>'; - } } // Self-register on import diff --git a/extension/src/editor/parsers/parserUtils.ts b/extension/src/editor/parsers/parserUtils.ts new file mode 100644 index 00000000000..0d67c0081f0 --- /dev/null +++ b/extension/src/editor/parsers/parserUtils.ts @@ -0,0 +1,83 @@ +import * as vscode from 'vscode'; + +/** + * Walk backwards from the match position to find the first line of the statement. + * Stops at the previous ';', '{', or start of file, then returns the first non-comment, + * non-blank line after that delimiter. When a '}' is encountered, the matched '{...}' + * block is inspected: if preceded by '=>' it is a lambda body within the current fluent + * chain and is skipped; otherwise the '}' is treated as a statement boundary. + * + * Shared by C# and JS/TS AppHost parsers since the statement-boundary rules are + * identical for C-syntax languages. + */ +export function findStatementStartLine(text: string, matchIndex: number, document: vscode.TextDocument): number { + let i = matchIndex - 1; + while (i >= 0) { + const ch = text[i]; + if (ch === ';' || ch === '{') { + break; + } + if (ch === '}') { + const openBraceIdx = findMatchingOpenBrace(text, i); + if (openBraceIdx < 0) { + // No matching open brace — treat as delimiter + break; + } + if (isPrecededByArrow(text, openBraceIdx)) { + // Lambda body in the current fluent chain — skip over it + i = openBraceIdx - 1; + continue; + } + // Separate statement block — treat '}' as delimiter + break; + } + i--; + } + // i is now at the delimiter or -1 (start of file) + // Find the first non-whitespace character after the delimiter + let start = i + 1; + while (start < matchIndex && /\s/.test(text[start])) { + start++; + } + let line = document.positionAt(start).line; + const matchLine = document.positionAt(matchIndex).line; + // Skip lines that are only closing braces (with optional comment) or comments + while (line < matchLine) { + const lineText = document.lineAt(line).text.trimStart(); + if (/^\}\s*(\/\/.*)?$/.test(lineText) || lineText.startsWith('//') || lineText.startsWith('/*') || lineText.startsWith('*')) { + line++; + } else { + break; + } + } + return line; +} + +/** + * Starting from a '}' at closeBraceIdx, walk backwards to find the matching '{'. + * Returns the index of '{', or -1 if not found. + */ +export function findMatchingOpenBrace(text: string, closeBraceIdx: number): number { + let depth = 1; + let j = closeBraceIdx - 1; + while (j >= 0 && depth > 0) { + if (text[j] === '}') { + depth++; + } else if (text[j] === '{') { + depth--; + } + j--; + } + return depth === 0 ? j + 1 : -1; +} + +/** + * Check whether the '{' at openBraceIdx is preceded (ignoring whitespace) by '=>'. + */ +export function isPrecededByArrow(text: string, openBraceIdx: number): boolean { + let k = openBraceIdx - 1; + while (k >= 0 && /\s/.test(text[k])) { + k--; + } + return k >= 1 && text[k - 1] === '=' && text[k] === '>'; +}