feat(view): JSON 折叠纯函数——括号栈扫描与可见性投影

- scanJsonFolds:括号栈单遍扫描(行尾 {/[ 开块、行首 }/] 收块),end=闭括号行(闭括号随块隐藏;LSP 文档的「闭括号前一行」是 0-based 记法的转述陷阱,注释已记录),单行块不产出,非法结构健壮跳过
- buildVisibleProjection:可见性投影,隐藏语义同 VS Code hidesLine(start < line ≤ end,折叠头可见),嵌套折叠区间覆盖天然正确
- findVisibleIndex:model 行号 → 可视索引反查
- clampLineToVisible:选区端点钳制到折叠头行(Monaco adjustSelections 语义)
- 新增 20 个纯函数单测(扫描规则/嵌套/非法结构/投影边界/钳制)
- 附调研文档 docs/JSON预览器代码折叠调研.md(VS Code/CM6 源码考证 + 交互惯例)
This commit is contained in:
2026-09-09 14:16:51 +08:00
parent 8d6a842055
commit b3ad8a5a8e
3 changed files with 418 additions and 0 deletions
+162
View File
@@ -2,11 +2,15 @@ import { describe, expect, it } from 'vitest';
import {
buildJsonLineSegments,
buildVisibleProjection,
clampLineToVisible,
computeGutterWidth,
computeVisibleRowWindow,
findVisibleIndex,
formatJsonOrRawText,
JSON_VIEW_OVERSCAN_ROWS,
JSON_VIEW_ROW_HEIGHT_PX,
scanJsonFolds,
splitTextIntoLines,
} from './json-text-processor';
@@ -127,3 +131,161 @@ describe('computeGutterWidth', () => {
expect(computeGutterWidth(0)).toBe(1);
});
});
/* ---------- 代码块折叠(实施计划 §3 用例清单) ---------- */
describe('scanJsonFolds 扫描规则', () => {
it('顶层多行对象:1 个 fold,end = 闭括号行,depth = 1', () => {
const lines = ['{', ' "a": 1', ' "b": 2', '}'];
expect(scanJsonFolds(lines)).toEqual([
{ startLine: 1, endLine: 4, depth: 1, kind: 'object', hiddenLineCount: 3 },
]);
});
it('嵌套对象:内外两个 fold,内层 depth = 3,各自 endLine 正确', () => {
const lines = [
'{', // 1
' "outer": {', // 2
' "inner": {', // 3
' "x": 1', // 4
' }', // 5
' }', // 6
'}', // 7
];
expect(scanJsonFolds(lines)).toEqual([
{ startLine: 3, endLine: 5, depth: 3, kind: 'object', hiddenLineCount: 2 },
{ startLine: 2, endLine: 6, depth: 2, kind: 'object', hiddenLineCount: 4 },
{ startLine: 1, endLine: 7, depth: 1, kind: 'object', hiddenLineCount: 6 },
]);
});
it('顶层数组:kind = array', () => {
const lines = ['[', ' 1', ' 2', ']'];
expect(scanJsonFolds(lines)).toEqual([
{ startLine: 1, endLine: 4, depth: 1, kind: 'array', hiddenLineCount: 3 },
]);
});
it('单行对象/数组不产出折叠', () => {
expect(scanJsonFolds(['{"a": 1}'])).toEqual([]);
expect(scanJsonFolds(['[1, 2]'])).toEqual([]);
expect(scanJsonFolds(['{', ' "a": {"x": 1}', '}'])).toEqual([
{ startLine: 1, endLine: 3, depth: 1, kind: 'object', hiddenLineCount: 2 },
]);
});
it('非法结构(无开括号的闭括号)健壮跳过,不抛错', () => {
expect(scanJsonFolds(['}'])).toEqual([]);
expect(scanJsonFolds([' ]'])).toEqual([]);
expect(() => scanJsonFolds(['}', ']', ' }'])).not.toThrow();
});
it('行尾含空白/换行的开括号行正常识别(trimEnd 生效)', () => {
const lines = ['{ ', ' "a": 1', '}', '\n'];
expect(scanJsonFolds(lines)).toEqual([
{ startLine: 1, endLine: 3, depth: 1, kind: 'object', hiddenLineCount: 2 },
]);
});
it('非 JSON 文本不抛错(调用方须先确认是 JSON,此处只保证健壮性)', () => {
expect(() => scanJsonFolds(['function f() {', ' return 1;', '}'])).not.toThrow();
expect(scanJsonFolds([])).toEqual([]);
});
});
describe('buildVisibleProjection 隐藏语义', () => {
const folds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', '}']);
const fold = folds[0]!;
it('空折叠集:恒等投影', () => {
expect(buildVisibleProjection(4, folds, new Set())).toEqual([1, 2, 3, 4]);
});
it('折叠中间块:start 可见、(start, end] 全部消失(含闭括号行)、end+1 起恢复', () => {
const middleFolds = scanJsonFolds(['{', ' "x": {', ' "y": 1', ' }', '}']);
// middleFolds: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
const projection = buildVisibleProjection(5, middleFolds, new Set([2]));
expect(projection).toEqual([1, 2, 5]);
});
it('投影长度 = 总行数 − 隐藏行数', () => {
const projection = buildVisibleProjection(4, [fold], new Set([fold.startLine]));
expect(projection).toHaveLength(4 - fold.hiddenLineCount);
});
it('嵌套两块同时折叠:区间合并隐藏、无重复行号', () => {
const nested = scanJsonFolds([
'{', // 1
' "a": {', // 2
' "b": 1', // 3
' }', // 4
'}', // 5
]);
// nested: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
// 同时折叠外层(start=1)与内层(start=2):外层区间已覆盖全部,投影只剩折叠头
const projection = buildVisibleProjection(5, nested, new Set([1, 2]));
expect(projection).toEqual([1]);
});
it('折叠位含不存在的 startLine:忽略不生效', () => {
expect(buildVisibleProjection(4, folds, new Set([999]))).toEqual([1, 2, 3, 4]);
});
it('零总行数返回空投影', () => {
expect(buildVisibleProjection(0, folds, new Set([1]))).toEqual([]);
});
});
describe('findVisibleIndex 反查', () => {
const folds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', '}']);
// 折叠顶层对象(start=1/end=4,闭括号行随块隐藏):投影只剩折叠头
const projection = buildVisibleProjection(4, folds, new Set([1]));
it('可见行命中正确可视索引', () => {
expect(findVisibleIndex(projection, 1)).toBe(0);
const halfFolds = scanJsonFolds(['{', ' "a": 1', ' "b": 2', ' "c": 3', '}']);
const halfProjection = buildVisibleProjection(5, halfFolds, new Set([1]));
expect(halfProjection).toEqual([1]); // 折叠整个对象后仍只有折叠头可见
expect(findVisibleIndex(halfProjection, 1)).toBe(0);
});
it('隐藏行(含闭括号行)返回 −1', () => {
expect(findVisibleIndex(projection, 2)).toBe(-1);
expect(findVisibleIndex(projection, 3)).toBe(-1);
expect(findVisibleIndex(projection, 4)).toBe(-1);
});
});
describe('clampLineToVisible 选区钳制', () => {
const folds = scanJsonFolds([
'{', // 1
' "outer": {', // 2
' "x": 1', // 3
' }', // 4
'}', // 5
]);
// folds: start=2/end=4(depth=2)与 start=1/end=5(depth=1)
it('区间内隐藏行(含闭括号行)收缩到折叠头行 start', () => {
const collapsed = new Set([1]);
expect(clampLineToVisible(2, folds, collapsed)).toBe(1);
expect(clampLineToVisible(4, folds, collapsed)).toBe(1);
expect(clampLineToVisible(5, folds, collapsed)).toBe(1); // 行 5 是闭括号,在隐藏区
});
it('start 本身、折叠头之后的可见行不收缩', () => {
const collapsed = new Set([2]); // 只折叠内层(2..4)
expect(clampLineToVisible(2, folds, collapsed)).toBe(2);
expect(clampLineToVisible(5, folds, collapsed)).toBe(5); // 外层闭括号在折叠区外
});
it('未折叠的 fold 不参与钳制', () => {
expect(clampLineToVisible(3, folds, new Set([2]))).toBe(2);
expect(clampLineToVisible(3, folds, new Set())).toBe(3);
});
it('嵌套区间同时折叠:命中最外层折叠头', () => {
const collapsed = new Set([1, 2]);
expect(clampLineToVisible(3, folds, collapsed)).toBe(1);
});
});
+128
View File
@@ -112,3 +112,131 @@ export function buildJsonLineSegments(line: string): JsonLineSegment[] {
export function computeGutterWidth(totalRowCount: number): number {
return String(Math.max(totalRowCount, 1)).length;
}
/* ---------- 代码块折叠(调研与实施计划见 docs/JSON预览器代码折叠调研.md / 实施计划.md) ----------
*
* 三层模型(对齐 VS Code/Monaco 与 CodeMirror 6 的共同结构):
* 1. folds:括号栈扫描产出的折叠范围(model 行号对,end = 闭括号前一行)
* 2. collapsedSet:已折叠集合(fold 的 start 行号)
* 3. visibleModel:可见性投影——未被隐藏的 model 行号按序排列;
* 隐藏语义同 VS Code hidesLine:start < line ≤ end(折叠头行永远可见)
*/
/** 单个折叠范围(1-based model 行号;start = 开括号行,end = 闭括号行) */
export interface JsonFoldRange {
readonly startLine: number;
/** 闭括号行(注意:不是闭括号前一行——LSP 的「闭括号前一行」是 0-based 记法,
* 转 1-based 后即闭括号行本身;VS Code 折叠对象时闭括号行随块隐藏,行尾摘要附在折叠头行) */
readonly endLine: number;
/** 嵌套深度:顶层块 = 1(供「折叠全部到第 N 层」扩展位与调试) */
readonly depth: number;
/** 块类型(行尾是 { 还是 [) */
readonly kind: 'object' | 'array';
/** 该折叠隐藏的行数(endLine − startLine,不含折叠头行) */
readonly hiddenLineCount: number;
}
/** 从行数组扫描 JSON 折叠范围(括号栈行级扫描,VS Code jsonFolding 同款规则)。
* 仅当行尾为 { / [(后随空白)时开块、行首(首个非空白)为 } / ] 时收块——
* pretty-print 的 JSON 保证每行至多一个括号,字符串值不会以裸括号结尾,零误判。
* 单行块(闭括号紧随开括号行)不产出折叠。非 JSON 文本可能产出错误范围,调用方须先确认是 JSON。
*/
export function scanJsonFolds(lineList: readonly string[]): JsonFoldRange[] {
const folds: JsonFoldRange[] = [];
// 栈元素:开括号行(1-based)与块类型
const openStack: Array<{ startLine: number; kind: 'object' | 'array' }> = [];
for (let index = 0; index < lineList.length; index += 1) {
const line = lineList[index]!;
const lineNo = index + 1;
const trimmedEnd = line.trimEnd();
if (trimmedEnd.endsWith('{') || trimmedEnd.endsWith('[')) {
openStack.push({
startLine: lineNo,
kind: trimmedEnd.endsWith('{') ? 'object' : 'array',
});
continue;
}
const firstChar = trimmedEnd.trimStart().charAt(0);
if (firstChar === '}' || firstChar === ']') {
const opened = openStack.pop();
if (!opened) continue; // 非法结构(不完整 JSON 等):跳过,保持扫描健壮
// end = 闭括号行(闭括号随块隐藏);单行块不产出(closeLine > start + 1 才有隐藏行)
const closeLine = lineNo;
if (closeLine > opened.startLine + 1) {
folds.push({
startLine: opened.startLine,
endLine: closeLine,
depth: openStack.length + 1,
kind: opened.kind,
hiddenLineCount: closeLine - opened.startLine,
});
}
}
}
return folds;
}
/**
* 可见性投影:从全量 model 行号中剔除被折叠区间隐藏的行(start < line ≤ end)。
* 折叠区间按 startLine 升序判定;嵌套折叠通过区间覆盖判定天然正确——
* 一个行号落在多个已折叠区间内只输出一次。
*/
export function buildVisibleProjection(
totalLineCount: number,
folds: readonly JsonFoldRange[],
collapsedStartLines: ReadonlySet<number>,
): number[] {
if (totalLineCount <= 0) return [];
const identityProjection = (): number[] =>
Array.from({ length: totalLineCount }, (_, index) => index + 1);
if (collapsedStartLines.size === 0) return identityProjection();
const collapsedRanges = folds
.filter((fold) => collapsedStartLines.has(fold.startLine))
.sort((a, b) => a.startLine - b.startLine);
if (collapsedRanges.length === 0) return identityProjection();
const visible: number[] = [];
for (let line = 1; line <= totalLineCount; line += 1) {
let isHidden = false;
for (const range of collapsedRanges) {
if (range.startLine >= line) break; // 区间按 start 升序,后面的不会覆盖当前行
if (line <= range.endLine) {
isHidden = true;
break;
}
}
if (!isHidden) visible.push(line);
}
return visible;
}
/** model 行号 → 可视索引(0-based,投影数组中的位置);行被隐藏或未命中时返回 −1 */
export function findVisibleIndex(visibleModel: readonly number[], modelLine: number): number {
return visibleModel.indexOf(modelLine);
}
/** 选区端点钳制:model 行落在某已折叠区间的隐藏部分(start < line ≤ end)时收缩到折叠头行。
* Monaco HiddenRangeModel.adjustSelections 同款语义——选区端点只收缩、不展开折叠。
*/
export function clampLineToVisible(
modelLine: number,
folds: readonly JsonFoldRange[],
collapsedStartLines: ReadonlySet<number>,
): number {
let clamped = modelLine;
for (const fold of folds) {
if (!collapsedStartLines.has(fold.startLine)) continue;
if (fold.startLine < clamped && clamped <= fold.endLine) {
clamped = fold.startLine;
}
}
return clamped;
}