From 3fd51a7461e053295a53cbfc18f3749f2679dc99 Mon Sep 17 00:00:00 2001 From: Prashant Kumar Rai Date: Thu, 13 Aug 2026 11:08:57 +0530 Subject: [PATCH 1/3] Implement access specifier folding range collection and add unit tests --- .../Providers/foldingRangeProvider.ts | 10 +- .../Providers/foldingRangeUtils.ts | 141 ++++++++++++++++++ .../test/unit/foldingRangeProvider.test.ts | 34 +++++ 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 Extension/src/LanguageServer/Providers/foldingRangeUtils.ts create mode 100644 Extension/test/unit/foldingRangeProvider.test.ts diff --git a/Extension/src/LanguageServer/Providers/foldingRangeProvider.ts b/Extension/src/LanguageServer/Providers/foldingRangeProvider.ts index c7065122a..3d2d00660 100644 --- a/Extension/src/LanguageServer/Providers/foldingRangeProvider.ts +++ b/Extension/src/LanguageServer/Providers/foldingRangeProvider.ts @@ -8,6 +8,7 @@ import { ManualPromise } from '../../Utility/Async/manualPromise'; import { CppFoldingRange, DefaultClient, FoldingRangeKind, GetFoldingRangesParams, GetFoldingRangesRequest, GetFoldingRangesResult } from '../client'; import { RequestCancelled, ServerCancelled } from '../protocolFilter'; import { CppSettings } from '../settings'; +import { collectAccessSpecifierFoldingRanges } from './foldingRangeUtils'; interface FoldingRangeRequestInfo { promise: ManualPromise | undefined; @@ -46,7 +47,14 @@ export class FoldingRangeProvider implements vscode.FoldingRangeProvider { }; this.pendingRequests.set(document.uri.toString(), foldingRangeRequestInfo); - const promise: Promise = this.requestRanges(document.uri.toString(), token); + const promise: Promise = this.requestRanges(document.uri.toString(), token).then((ranges: vscode.FoldingRange[] | undefined) => { + const accessSpecifierRanges: vscode.FoldingRange[] = collectAccessSpecifierFoldingRanges(document.getText()) as vscode.FoldingRange[]; + if (ranges === undefined) { + return accessSpecifierRanges; + } + + return ranges.concat(accessSpecifierRanges); + }); await promise; this.pendingRequests.delete(document.uri.toString()); if (foldingRangeRequestInfo.promise !== undefined) { diff --git a/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts b/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts new file mode 100644 index 000000000..5642ebc72 --- /dev/null +++ b/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts @@ -0,0 +1,141 @@ +export interface FoldingRangeLike { + start: number; + end: number; +} + +const accessSpecifierPattern: RegExp = /^\s*(public|protected|private)\s*:\s*$/; + +function stripLineForFolding(line: string, inBlockComment: boolean): { text: string; inBlockComment: boolean; } { + let result = ''; + let index = 0; + let inString: '"' | '\'' | undefined; + + while (index < line.length) { + const character = line[index]; + const nextCharacter = line[index + 1]; + + if (inBlockComment) { + if (character === '*' && nextCharacter === '/') { + inBlockComment = false; + index += 2; + continue; + } + + index++; + continue; + } + + if (inString !== undefined) { + if (character === '\\') { + index += 2; + continue; + } + + if (character === inString) { + inString = undefined; + } + + index++; + continue; + } + + if (character === '/' && nextCharacter === '/') { + break; + } + + if (character === '/' && nextCharacter === '*') { + inBlockComment = true; + index += 2; + continue; + } + + if (character === '"' || character === '\'') { + inString = character; + index++; + continue; + } + + result += character; + index++; + } + + return { text: result, inBlockComment }; +} + +function countCharacter(line: string, character: string): number { + return (line.match(new RegExp(`\\${character}`, 'g')) ?? []).length; +} + +function addFoldingRange(ranges: FoldingRangeLike[], startLine: number, endLine: number): void { + if (endLine > startLine) { + ranges.push({ start: startLine, end: endLine }); + } +} + +export function collectAccessSpecifierFoldingRanges(text: string): FoldingRangeLike[] { + const ranges: FoldingRangeLike[] = []; + const activeSectionsByDepth: Map = new Map(); + const classBodyDepths: number[] = []; + const lines: string[] = text.split(/\r?\n/); + + let inBlockComment = false; + let braceDepth = 0; + let pendingClassDeclaration = false; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const strippedLine = stripLineForFolding(lines[lineIndex], inBlockComment); + inBlockComment = strippedLine.inBlockComment; + + const lineText = strippedLine.text; + const currentDepth = braceDepth; + const currentClassDepth = classBodyDepths[classBodyDepths.length - 1]; + + if (currentClassDepth === currentDepth && accessSpecifierPattern.test(lineText)) { + const activeSectionStart = activeSectionsByDepth.get(currentDepth); + if (activeSectionStart !== undefined) { + addFoldingRange(ranges, activeSectionStart, lineIndex - 1); + } + + activeSectionsByDepth.set(currentDepth, lineIndex); + } + + if (/^\s*(class|struct|union)\b/.test(lineText)) { + pendingClassDeclaration = true; + } + + const openingBraces = countCharacter(lineText, '{'); + const closingBraces = countCharacter(lineText, '}'); + + if (pendingClassDeclaration) { + if (openingBraces > 0) { + const classBodyDepth = currentDepth + openingBraces - closingBraces; + if (classBodyDepth > currentDepth) { + classBodyDepths.push(classBodyDepth); + } + pendingClassDeclaration = false; + } else if (lineText.includes(';')) { + pendingClassDeclaration = false; + } + } + + braceDepth = currentDepth + openingBraces - closingBraces; + + while (classBodyDepths.length > 0 && classBodyDepths[classBodyDepths.length - 1] > braceDepth) { + const endedClassDepth = classBodyDepths.pop(); + if (endedClassDepth === undefined) { + break; + } + + const activeSectionStart = activeSectionsByDepth.get(endedClassDepth); + if (activeSectionStart !== undefined) { + addFoldingRange(ranges, activeSectionStart, lineIndex - 1); + activeSectionsByDepth.delete(endedClassDepth); + } + } + } + + const lastLine = lines.length - 1; + activeSectionsByDepth.forEach((startLine: number) => addFoldingRange(ranges, startLine, lastLine)); + + return ranges; +} \ No newline at end of file diff --git a/Extension/test/unit/foldingRangeProvider.test.ts b/Extension/test/unit/foldingRangeProvider.test.ts new file mode 100644 index 000000000..bf29f3a8b --- /dev/null +++ b/Extension/test/unit/foldingRangeProvider.test.ts @@ -0,0 +1,34 @@ +/* -------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All Rights Reserved. + * See 'LICENSE' in the project root for license information. + * -------------------------------------------------------------------------------------------- */ + +import { deepStrictEqual } from 'assert'; +import { describe, it } from 'mocha'; +import { collectAccessSpecifierFoldingRanges } from '../../src/LanguageServer/Providers/foldingRangeUtils'; + +function toRangeTuples(text: string): Array<[number, number]> { + return collectAccessSpecifierFoldingRanges(text).map(range => [range.start, range.end]); +} + +describe('Access specifier folding', () => { + it('creates fold ranges for public/protected/private sections', () => { + const source = [ + 'class A', + '{', + 'public:', + ' void foo();', + 'private:', + ' int value;', + 'protected:', + ' void bar();', + '};' + ].join('\n'); + + deepStrictEqual(toRangeTuples(source), [ + [2, 3], + [4, 5], + [6, 7] + ]); + }); +}); \ No newline at end of file From 3f832428299b5a10bcdc5fb813a44114f5204c67 Mon Sep 17 00:00:00 2001 From: Prashant Kumar Rai Date: Thu, 13 Aug 2026 23:15:45 +0530 Subject: [PATCH 2/3] Fix formatting in folding range utility and update test function signature --- Extension/src/LanguageServer/Providers/foldingRangeUtils.ts | 2 +- Extension/test/unit/foldingRangeProvider.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts b/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts index 5642ebc72..bb7da49fb 100644 --- a/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts +++ b/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts @@ -138,4 +138,4 @@ export function collectAccessSpecifierFoldingRanges(text: string): FoldingRangeL activeSectionsByDepth.forEach((startLine: number) => addFoldingRange(ranges, startLine, lastLine)); return ranges; -} \ No newline at end of file +} diff --git a/Extension/test/unit/foldingRangeProvider.test.ts b/Extension/test/unit/foldingRangeProvider.test.ts index bf29f3a8b..3b9374020 100644 --- a/Extension/test/unit/foldingRangeProvider.test.ts +++ b/Extension/test/unit/foldingRangeProvider.test.ts @@ -7,7 +7,7 @@ import { deepStrictEqual } from 'assert'; import { describe, it } from 'mocha'; import { collectAccessSpecifierFoldingRanges } from '../../src/LanguageServer/Providers/foldingRangeUtils'; -function toRangeTuples(text: string): Array<[number, number]> { +function toRangeTuples(text: string): [number, number][] { return collectAccessSpecifierFoldingRanges(text).map(range => [range.start, range.end]); } @@ -31,4 +31,4 @@ describe('Access specifier folding', () => { [6, 7] ]); }); -}); \ No newline at end of file +}); From 693a1f5062453be9e2a57d23c530692aa463a960 Mon Sep 17 00:00:00 2001 From: Prashant Kumar Rai Date: Fri, 14 Aug 2026 00:12:39 +0530 Subject: [PATCH 3/3] Add range limit handling for merging folding ranges and enhance tests --- .../Providers/foldingRangeProvider.ts | 15 ++-- .../Providers/foldingRangeUtils.ts | 73 ++++++++++++++++--- .../test/unit/foldingRangeProvider.test.ts | 67 ++++++++++++++++- 3 files changed, 138 insertions(+), 17 deletions(-) diff --git a/Extension/src/LanguageServer/Providers/foldingRangeProvider.ts b/Extension/src/LanguageServer/Providers/foldingRangeProvider.ts index 3d2d00660..0bab990bc 100644 --- a/Extension/src/LanguageServer/Providers/foldingRangeProvider.ts +++ b/Extension/src/LanguageServer/Providers/foldingRangeProvider.ts @@ -8,12 +8,16 @@ import { ManualPromise } from '../../Utility/Async/manualPromise'; import { CppFoldingRange, DefaultClient, FoldingRangeKind, GetFoldingRangesParams, GetFoldingRangesRequest, GetFoldingRangesResult } from '../client'; import { RequestCancelled, ServerCancelled } from '../protocolFilter'; import { CppSettings } from '../settings'; -import { collectAccessSpecifierFoldingRanges } from './foldingRangeUtils'; +import { collectAccessSpecifierFoldingRanges, mergeFoldingRangesWithLimit } from './foldingRangeUtils'; interface FoldingRangeRequestInfo { promise: ManualPromise | undefined; } +interface FoldingContextWithRangeLimit { + rangeLimit?: number; +} + export class FoldingRangeProvider implements vscode.FoldingRangeProvider { private client: DefaultClient; public onDidChangeFoldingRangesEvent = new vscode.EventEmitter(); @@ -46,14 +50,11 @@ export class FoldingRangeProvider implements vscode.FoldingRangeProvider { promise: undefined }; this.pendingRequests.set(document.uri.toString(), foldingRangeRequestInfo); + const rangeLimit: number | undefined = (context as FoldingContextWithRangeLimit).rangeLimit; const promise: Promise = this.requestRanges(document.uri.toString(), token).then((ranges: vscode.FoldingRange[] | undefined) => { - const accessSpecifierRanges: vscode.FoldingRange[] = collectAccessSpecifierFoldingRanges(document.getText()) as vscode.FoldingRange[]; - if (ranges === undefined) { - return accessSpecifierRanges; - } - - return ranges.concat(accessSpecifierRanges); + const accessSpecifierRanges = collectAccessSpecifierFoldingRanges(document.getText()); + return mergeFoldingRangesWithLimit(ranges, accessSpecifierRanges, rangeLimit) as vscode.FoldingRange[]; }); await promise; this.pendingRequests.delete(document.uri.toString()); diff --git a/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts b/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts index bb7da49fb..477d18c6f 100644 --- a/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts +++ b/Extension/src/LanguageServer/Providers/foldingRangeUtils.ts @@ -3,9 +3,43 @@ export interface FoldingRangeLike { end: number; } +export function mergeFoldingRangesWithLimit(primary: FoldingRangeLike[] | undefined, secondary: FoldingRangeLike[], rangeLimit: number | undefined): FoldingRangeLike[] { + const mergedRanges: FoldingRangeLike[] = (primary ?? []).concat(secondary); + + if (rangeLimit === undefined) { + return mergedRanges; + } + + if (rangeLimit <= 0) { + return []; + } + + // Keep existing server ranges first, then append access-specifier ranges until the limit. + return mergedRanges.slice(0, rangeLimit); +} + const accessSpecifierPattern: RegExp = /^\s*(public|protected|private)\s*:\s*$/; +const classDeclarationStartPattern: RegExp = /^\s*(?:template\s*<.*>\s*)?(class|struct|union)\b/; + +interface FoldingScanState { + inBlockComment: boolean; + rawStringDelimiter?: string; +} + +function tryConsumeRawStringStart(line: string, index: number): { consumed: number; rawStringDelimiter: string; } | undefined { + const remaining = line.slice(index); + const match = /^(?:u8|u|U|L)?R"([^ ()\\\t\r\n]{0,16})\(/.exec(remaining); + if (match === null) { + return undefined; + } -function stripLineForFolding(line: string, inBlockComment: boolean): { text: string; inBlockComment: boolean; } { + return { + consumed: match[0].length, + rawStringDelimiter: match[1] + }; +} + +function stripLineForFolding(line: string, state: FoldingScanState): { text: string; state: FoldingScanState; } { let result = ''; let index = 0; let inString: '"' | '\'' | undefined; @@ -14,9 +48,22 @@ function stripLineForFolding(line: string, inBlockComment: boolean): { text: str const character = line[index]; const nextCharacter = line[index + 1]; - if (inBlockComment) { + if (state.rawStringDelimiter !== undefined) { + const rawStringTerminator = `)${state.rawStringDelimiter}"`; + const rawStringEndIndex = line.indexOf(rawStringTerminator, index); + if (rawStringEndIndex < 0) { + index = line.length; + continue; + } + + state.rawStringDelimiter = undefined; + index = rawStringEndIndex + rawStringTerminator.length; + continue; + } + + if (state.inBlockComment) { if (character === '*' && nextCharacter === '/') { - inBlockComment = false; + state.inBlockComment = false; index += 2; continue; } @@ -44,11 +91,18 @@ function stripLineForFolding(line: string, inBlockComment: boolean): { text: str } if (character === '/' && nextCharacter === '*') { - inBlockComment = true; + state.inBlockComment = true; index += 2; continue; } + const rawStringStart = tryConsumeRawStringStart(line, index); + if (rawStringStart !== undefined) { + state.rawStringDelimiter = rawStringStart.rawStringDelimiter; + index += rawStringStart.consumed; + continue; + } + if (character === '"' || character === '\'') { inString = character; index++; @@ -59,7 +113,7 @@ function stripLineForFolding(line: string, inBlockComment: boolean): { text: str index++; } - return { text: result, inBlockComment }; + return { text: result, state }; } function countCharacter(line: string, character: string): number { @@ -78,13 +132,14 @@ export function collectAccessSpecifierFoldingRanges(text: string): FoldingRangeL const classBodyDepths: number[] = []; const lines: string[] = text.split(/\r?\n/); - let inBlockComment = false; + const scanState: FoldingScanState = { + inBlockComment: false + }; let braceDepth = 0; let pendingClassDeclaration = false; for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { - const strippedLine = stripLineForFolding(lines[lineIndex], inBlockComment); - inBlockComment = strippedLine.inBlockComment; + const strippedLine = stripLineForFolding(lines[lineIndex], scanState); const lineText = strippedLine.text; const currentDepth = braceDepth; @@ -99,7 +154,7 @@ export function collectAccessSpecifierFoldingRanges(text: string): FoldingRangeL activeSectionsByDepth.set(currentDepth, lineIndex); } - if (/^\s*(class|struct|union)\b/.test(lineText)) { + if (classDeclarationStartPattern.test(lineText)) { pendingClassDeclaration = true; } diff --git a/Extension/test/unit/foldingRangeProvider.test.ts b/Extension/test/unit/foldingRangeProvider.test.ts index 3b9374020..9b754482b 100644 --- a/Extension/test/unit/foldingRangeProvider.test.ts +++ b/Extension/test/unit/foldingRangeProvider.test.ts @@ -5,7 +5,7 @@ import { deepStrictEqual } from 'assert'; import { describe, it } from 'mocha'; -import { collectAccessSpecifierFoldingRanges } from '../../src/LanguageServer/Providers/foldingRangeUtils'; +import { collectAccessSpecifierFoldingRanges, mergeFoldingRangesWithLimit } from '../../src/LanguageServer/Providers/foldingRangeUtils'; function toRangeTuples(text: string): [number, number][] { return collectAccessSpecifierFoldingRanges(text).map(range => [range.start, range.end]); @@ -31,4 +31,69 @@ describe('Access specifier folding', () => { [6, 7] ]); }); + + it('respects rangeLimit when merging ranges', () => { + const primary = [ + { start: 0, end: 1 }, + { start: 2, end: 3 } + ]; + const secondary = [ + { start: 4, end: 5 }, + { start: 6, end: 7 } + ]; + + deepStrictEqual(mergeFoldingRangesWithLimit(primary, secondary, 3), [ + { start: 0, end: 1 }, + { start: 2, end: 3 }, + { start: 4, end: 5 } + ]); + }); + + it('returns all merged ranges when rangeLimit is undefined', () => { + const primary = [{ start: 10, end: 20 }]; + const secondary = [{ start: 30, end: 40 }]; + + deepStrictEqual(mergeFoldingRangesWithLimit(primary, secondary, undefined), [ + { start: 10, end: 20 }, + { start: 30, end: 40 } + ]); + }); + + it('ignores access-specifier-like lines and braces inside multiline raw strings', () => { + const source = [ + 'class A', + '{', + 'public:', + ' const char* text = R"raw(', + 'private:', + '}', + ')raw";', + ' void foo();', + 'private:', + ' int value;', + '};' + ].join('\n'); + + deepStrictEqual(toRangeTuples(source), [ + [2, 7], + [8, 9] + ]); + }); + + it('detects class declarations with same-line template prefix', () => { + const source = [ + 'template class A', + '{', + 'public:', + ' void foo();', + 'private:', + ' int value;', + '};' + ].join('\n'); + + deepStrictEqual(toRangeTuples(source), [ + [2, 3], + [4, 5] + ]); + }); });