Archived
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user