diff --git a/packages/vipeye-internal/package-lock.json b/packages/vipeye-internal/package-lock.json index 3e4f1697..77482250 100644 --- a/packages/vipeye-internal/package-lock.json +++ b/packages/vipeye-internal/package-lock.json @@ -1,12 +1,12 @@ { "name": "vipeye-internal", - "version": "1.1.411", + "version": "1.1.413", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vipeye-internal", - "version": "1.1.411", + "version": "1.1.413", "license": "MIT", "dependencies": { "@yarnpkg/fslib": "2.10.4", diff --git a/packages/vipeye-internal/src/analyzer/flowChartBuilder.ts b/packages/vipeye-internal/src/analyzer/flowChartBuilder.ts new file mode 100644 index 00000000..c3759a9d --- /dev/null +++ b/packages/vipeye-internal/src/analyzer/flowChartBuilder.ts @@ -0,0 +1,711 @@ +/* + * flowChartBuilder.ts + * + * Viper 扩展:把函数的 AST 转成「语句级控制流图(流程图)」,输出 Mermaid `flowchart TD` 文本, + * 供 Webview(停靠面板)渲染函数跳转图。 + * + * 覆盖的语句结构: + * - if / elif / else(elif 串成连续的菱形判断) + * - for / for-else、while / while-else(含回流边;else 只在「非 break 结束」时进入) + * - match / case(每个 case 一个菱形,支持 guard 与不可反驳模式) + * - try / except / else / finally(try 画成「正常/异常」菱形,异常链逐个 except, + * 未匹配的异常画成「异常向上传播」终止节点,finally 汇聚所有路径) + * - break / continue / return / raise(终止节点;break/continue 绑定最内层循环) + * - with:透明展开(只加一个标题块,内部语句顺序展开) + * - 嵌套 def / class:折叠成单个节点,不展开其内部(避免图暴涨) + * + * 粒度:连续的无分支语句合并为一个基本块节点(矩形);分支条件为菱形。 + * + * 说明:本模块是纯语法层(AST)的图构建,不做类型分析,因此图是「语法跳转图」而非类型收窄图。 + */ + +import { convertOffsetToPosition } from '../common/positionUtils'; +import { TextRange } from '../common/textRange'; +import { TextRangeCollection } from '../common/textRangeCollection'; +import { + ForNode, + FunctionNode, + IfNode, + MatchNode, + ParseNode, + ParseNodeType, + StatementNode, + TryNode, + WhileNode, + WithNode, +} from '../parser/parseNodes'; +import { Token } from '../parser/tokenizerTypes'; + +/** 节点 id → 源码起始行号(1 起),供 Webview 点击节点跳转到源码用。 */ +export interface FlowChartNodeLines { + [nodeId: string]: number; +} + +export interface FlowChartResult { + /** 函数名(用于面板标题)。 */ + functionName: string; + /** 完整的 Mermaid 文本。 */ + mermaidText: string; + /** 节点 id → 源码行号。 */ + nodeLines: FlowChartNodeLines; +} + +export function buildFlowChartForFunction( + functionNode: FunctionNode, + sourceText: string, + lines: TextRangeCollection +): FlowChartResult { + return new FlowChartBuilder(sourceText, lines).build(functionNode); +} + +const enum ChartShape { + /** 入口节点(胶囊)。 */ + Entry, + /** 正常结束节点(胶囊)。 */ + Exit, + /** 基本块(矩形)。 */ + Block, + /** 分支条件(菱形)。 */ + Decision, + /** 终止语句:return / raise / break / continue(圆角矩形)。 */ + Terminator, +} + +interface ChartNode { + id: string; + shape: ChartShape; + /** 节点上逐行显示的文本(未转义)。 */ + lines: string[]; +} + +interface ChartEdge { + from: string; + to: string; + label: string | undefined; +} + +// 循环作用域。break / continue 只绑定最内层循环,故每个循环一个实例并向下传递。 +interface LoopScope { + /** break 语句产生的出口,绕过 else 直接成为「循环之后」的入口。 */ + breakExits: string[]; + /** continue 语句节点,最后统一回连到循环条件。 */ + continueTargets: string[]; +} + +// try-finally 作用域。return/raise/break/continue 必须「穿过」finally 才能继续, +// 故这些出口的边要先连到 finally 入口,再由 finally 入口连出去(见 _routeThroughFinally)。 +interface FinallyScope { + /** 本层 finally 体的入口节点 id。 */ + entryId: string; +} + +/** 单个节点最多展示的源码行数(超出截断,避免个别节点撑爆图)。 */ +const MaxLinesPerNode = 12; +/** 单行最多展示的字符数。 */ +const MaxCharsPerLine = 96; + +class FlowChartBuilder { + private _nodes: ChartNode[] = []; + private _edges: ChartEdge[] = []; + private _nodeMap = new Map(); + private _nodeLines: FlowChartNodeLines = {}; + private _nextNodeId = 1; + + // 「待消费的边标签」:下次从该节点连出的边会带上这个标签。 + // 用于给分支出口(True/False、匹配/不匹配)打标签,而无需让 _buildStatements 感知标签。 + private _pendingEdgeLabels = new Map(); + + // try-finally 作用域栈(外层在前、内层在后)。为空表示当前不在任何 finally 内。 + private _finallyStack: FinallyScope[] = []; + + // 已连接过的边(from|to|label),避免「多条出口穿过同一个 finally」时产生重复边。 + private _edgeKeys = new Set(); + + // 函数结束节点,按需创建(正常落到末尾、或终止语句穿过 finally 之后都汇到它)。 + private _endNodeId: string | undefined; + private _endNodeOffset = 0; + + constructor(private _sourceText: string, private _lines: TextRangeCollection) {} + + build(functionNode: FunctionNode): FlowChartResult { + this._endNodeOffset = functionNode.start; + + const entryId = this._addNode(ChartShape.Entry, [this._getFunctionHeader(functionNode)], functionNode.start); + const bodyExits = this._buildStatements(functionNode.d.suite.d.statements, [entryId], undefined); + + // 只有存在「正常落到函数末尾」的路径时才画结束节点(全 return 的函数就没有)。 + if (bodyExits.length > 0) { + this._link(bodyExits, this._getEndNodeId()); + } + + return { + functionName: functionNode.d.name.d.value, + mermaidText: this._render(), + nodeLines: this._nodeLines, + }; + } + + private _getFunctionHeader(functionNode: FunctionNode): string { + const params = functionNode.d.params + .map((param) => (param.d.name ? this._getNodeText(param.d.name) : '...')) + .join(', '); + return `def ${functionNode.d.name.d.value}(${params})`; + } + + // ===== 图元操作 ===== + + private _addNode(shape: ChartShape, lines: string[], offset: number, mergeWithNodeId?: string): string { + if (mergeWithNodeId !== undefined) { + const existing = this._nodeMap.get(mergeWithNodeId)!; + existing.lines.push(...lines); + return mergeWithNodeId; + } + + const id = `n${this._nextNodeId++}`; + const node: ChartNode = { id, shape, lines: [] }; + this._appendNodeLines(node, lines); + + this._nodes.push(node); + this._nodeMap.set(id, node); + this._nodeLines[id] = convertOffsetToPosition(offset, this._lines).line + 1; + return id; + } + + private _appendNodeLines(node: ChartNode, lines: string[]): void { + node.lines.push(...lines); + + // 超长基本块只保留前若干行,末尾换成省略提示(避免单个节点撑爆整张图)。 + if (node.lines.length > MaxLinesPerNode) { + const hiddenCount = node.lines.length - MaxLinesPerNode; + node.lines = node.lines.slice(0, MaxLinesPerNode); + node.lines.push(`…(还有 ${hiddenCount} 行)`); + } + } + + private _link(fromIds: string[], toId: string, label?: string): void { + for (const fromId of fromIds) { + let edgeLabel = label; + if (edgeLabel === undefined) { + const pending = this._pendingEdgeLabels.get(fromId); + if (pending !== undefined) { + edgeLabel = pending; + this._pendingEdgeLabels.delete(fromId); + } + } + + // 去重:多条出口穿过同一个 finally 时会产生同一条边,重复画出来只会让图变脏。 + const edgeKey = `${fromId}|${toId}|${edgeLabel ?? ''}`; + if (this._edgeKeys.has(edgeKey)) { + continue; + } + this._edgeKeys.add(edgeKey); + + this._edges.push({ from: fromId, to: toId, label: edgeLabel }); + } + } + + /** 函数结束节点的 id(按需创建)。 */ + private _getEndNodeId(): string { + if (!this._endNodeId) { + this._endNodeId = this._addNode(ChartShape.Exit, ['结束'], this._endNodeOffset); + } + return this._endNodeId; + } + + /** + * 让「必须等 finally 执行完才能继续」的出口穿过 finally 链。 + * 例如 `try: return x finally: f()` 的真实顺序是 return x → f() → 函数返回, + * 因此 return 的边要先连到 finally 入口,再由 finally 入口连到它原本的目标。 + * + * @returns 调用方应该继续连出的节点 id:不在 finally 内就是原节点,否则是最外层 finally 入口。 + */ + private _routeThroughFinally(sourceId: string): string { + let currentId = sourceId; + + for (let i = this._finallyStack.length - 1; i >= 0; i--) { + const finallyEntryId = this._finallyStack[i].entryId; + this._link([currentId], finallyEntryId); + currentId = finallyEntryId; + } + + return currentId; + } + + /** 给「下一个从 nodeId 连出的边」预设标签(用于分支出口)。 */ + private _queueEdgeLabel(nodeId: string, label: string): void { + this._pendingEdgeLabels.set(nodeId, label); + } + + private _clearEdgeLabel(nodeId: string): void { + this._pendingEdgeLabels.delete(nodeId); + } + + // ===== 语句序列 ===== + + /** + * 顺序构建一批语句。 + * @param incoming 进入本批语句的「悬空出口」节点 id(尚未连出边) + * @param openBlockId 可选的、允许继续追加语句的当前基本块(如 with 的标题块) + * @returns 本批语句执行完后仍悬空的出口节点 id(终止语句不会产生出口) + */ + private _buildStatements( + statements: StatementNode[], + incoming: string[], + loopScope: LoopScope | undefined, + openBlockId?: string + ): string[] { + let current = incoming; + + for (const statement of statements) { + if (this._isCompoundStatement(statement)) { + openBlockId = undefined; + current = this._buildCompoundStatement(statement, current, loopScope); + continue; + } + + const nodeText = this._getNodeText(statement); + if (this._isTerminatorStatement(statement)) { + openBlockId = undefined; + current = this._buildTerminatorStatement(statement, nodeText, current, loopScope); + continue; + } + + // 简单语句:能并入当前基本块就并入,否则新建块。 + if (openBlockId !== undefined && current.length === 1 && current[0] === openBlockId) { + this._addNode(ChartShape.Block, [nodeText], statement.start, openBlockId); + continue; + } + + const blockId = this._addNode(ChartShape.Block, [nodeText], statement.start); + this._link(current, blockId); + current = [blockId]; + openBlockId = blockId; + } + + return current; + } + + private _isCompoundStatement(statement: StatementNode): boolean { + switch (statement.nodeType) { + case ParseNodeType.If: + case ParseNodeType.While: + case ParseNodeType.For: + case ParseNodeType.Try: + case ParseNodeType.With: + case ParseNodeType.Match: + case ParseNodeType.Function: + case ParseNodeType.Class: + return true; + + default: + return false; + } + } + + /** 是否为终止语句(return / raise / break / continue)。它们位于 StatementList 内部。 */ + private _isTerminatorStatement(statement: StatementNode): boolean { + if (statement.nodeType !== ParseNodeType.StatementList) { + return false; + } + + return statement.d.statements.some((smallStatement) => this._isTerminatorSmallStatement(smallStatement.nodeType)); + } + + private _isTerminatorSmallStatement(nodeType: ParseNodeType): boolean { + return ( + nodeType === ParseNodeType.Return || + nodeType === ParseNodeType.Raise || + nodeType === ParseNodeType.Break || + nodeType === ParseNodeType.Continue + ); + } + + private _buildTerminatorStatement( + statement: StatementNode, + nodeText: string, + incoming: string[], + loopScope: LoopScope | undefined + ): string[] { + const nodeId = this._addNode(ChartShape.Terminator, [nodeText], statement.start); + this._link(incoming, nodeId); + + // 终止语句位于 finally 内时,真实顺序是「终止 → finally → 原去向」, + // 因此出口节点换成最外层 finally 入口(不在 finally 内则仍是自身)。 + const exitId = this._routeThroughFinally(nodeId); + + if (statement.nodeType === ParseNodeType.StatementList) { + for (const smallStatement of statement.d.statements) { + if ( + (smallStatement.nodeType === ParseNodeType.Break || + smallStatement.nodeType === ParseNodeType.Continue) && + loopScope + ) { + // break / continue 的去向由循环收口,这里只登记它的最终出口。 + if (smallStatement.nodeType === ParseNodeType.Break) { + loopScope.breakExits.push(exitId); + } else { + loopScope.continueTargets.push(exitId); + } + continue; + } + + // return / raise 要等 finally 执行完才真正离开函数。 + if ( + exitId !== nodeId && + (smallStatement.nodeType === ParseNodeType.Return || + smallStatement.nodeType === ParseNodeType.Raise) + ) { + this._link([exitId], this._getEndNodeId()); + } + } + } + + // 终止语句之后不再有后继(return/raise 直接结束;break/continue 由循环收口)。 + return []; + } + + private _buildCompoundStatement( + statement: StatementNode, + incoming: string[], + loopScope: LoopScope | undefined + ): string[] { + switch (statement.nodeType) { + case ParseNodeType.If: + return this._buildIf(statement, incoming, loopScope); + + case ParseNodeType.While: + case ParseNodeType.For: + return this._buildLoop(statement, incoming, loopScope); + + case ParseNodeType.Match: + return this._buildMatch(statement, incoming, loopScope); + + case ParseNodeType.Try: + return this._buildTry(statement, incoming, loopScope); + + case ParseNodeType.With: + return this._buildWith(statement, incoming, loopScope); + + case ParseNodeType.Function: + case ParseNodeType.Class: + return this._buildNestedDefinition(statement, incoming); + + default: + return incoming; + } + } + + // ===== if / elif / else ===== + + private _buildIf(node: IfNode, incoming: string[], loopScope: LoopScope | undefined): string[] { + // firstToken 区分首分支(if)与后续分支(elif),标签据此显示。 + const condId = this._addNode( + ChartShape.Decision, + [`${this._getTokenText(node.d.firstToken)} ${this._getNodeText(node.d.testExpr)}`], + node.start + ); + this._link(incoming, condId); + + this._queueEdgeLabel(condId, 'True'); + const thenExits = this._buildStatements(node.d.ifSuite.d.statements, [condId], loopScope); + this._clearEdgeLabel(condId); + + const elseSuite = node.d.elseSuite; + if (!elseSuite) { + // 无 else:条件为假的出口直接悬空,由后继语句(或结束节点)接走。 + this._queueEdgeLabel(condId, 'False'); + return thenExits.concat(condId); + } + + if (elseSuite.nodeType === ParseNodeType.If) { + // elif:串成下一个菱形,保证「False」标签落在连向 elif 条件的边上。 + this._queueEdgeLabel(condId, 'False'); + const elifExits = this._buildIf(elseSuite, [condId], loopScope); + this._clearEdgeLabel(condId); + return thenExits.concat(elifExits); + } + + this._queueEdgeLabel(condId, 'False'); + const elseExits = this._buildStatements(elseSuite.d.statements, [condId], loopScope); + this._clearEdgeLabel(condId); + return thenExits.concat(elseExits); + } + + // ===== for / while(含 else)===== + + private _buildLoop( + node: WhileNode | ForNode, + incoming: string[], + loopScope: LoopScope | undefined + ): string[] { + const isWhile = node.nodeType === ParseNodeType.While; + const conditionText = isWhile + ? `while ${this._getNodeText(node.d.testExpr)}` + : `for ${this._getNodeText(node.d.targetExpr)} in ${this._getNodeText(node.d.iterableExpr)}`; + const trueLabel = isWhile ? 'True' : '还有元素'; + const falseLabel = isWhile ? 'False' : '迭代结束'; + + const condId = this._addNode(ChartShape.Decision, [conditionText], node.start); + this._link(incoming, condId); + + const bodySuite = isWhile ? node.d.whileSuite : node.d.forSuite; + const scope: LoopScope = { breakExits: [], continueTargets: [] }; + + this._queueEdgeLabel(condId, trueLabel); + const bodyExits = this._buildStatements(bodySuite.d.statements, [condId], scope); + this._clearEdgeLabel(condId); + + // 回流:循环体正常结束 + continue 都回到条件判断。 + this._link(bodyExits.concat(scope.continueTargets), condId); + + let normalExits: string[]; + if (node.d.elseSuite) { + // for-else / while-else:只有「非 break 结束」才会进入 else。 + this._queueEdgeLabel(condId, falseLabel); + normalExits = this._buildStatements(node.d.elseSuite.d.statements, [condId], scope); + this._clearEdgeLabel(condId); + } else { + this._queueEdgeLabel(condId, falseLabel); + normalExits = [condId]; + } + + // break 出口绕过 else,直接成为整个循环结构的出口。 + return normalExits.concat(scope.breakExits); + } + + // ===== match / case ===== + + private _buildMatch(node: MatchNode, incoming: string[], loopScope: LoopScope | undefined): string[] { + const matchId = this._addNode(ChartShape.Block, [`match ${this._getNodeText(node.d.expr)}`], node.start); + this._link(incoming, matchId); + + if (node.d.cases.length === 0) { + return [matchId]; + } + + const exits: string[] = []; + let fromId = matchId; + + for (const caseNode of node.d.cases) { + const guardText = caseNode.d.guardExpr ? ` if ${this._getNodeText(caseNode.d.guardExpr)}` : ''; + const condId = this._addNode( + ChartShape.Decision, + [`case ${this._getNodeText(caseNode.d.pattern)}${guardText}`], + caseNode.start + ); + this._queueEdgeLabel(fromId, fromId === matchId ? '开始匹配' : '不匹配'); + this._link([fromId], condId); + + this._queueEdgeLabel(condId, '匹配'); + exits.push(...this._buildStatements(caseNode.d.suite.d.statements, [condId], loopScope)); + this._clearEdgeLabel(condId); + + fromId = condId; + } + + // 最后一个 case 若不是「无条件匹配」,则「都不匹配」的出口悬空给后继语句。 + const lastCase = node.d.cases[node.d.cases.length - 1]; + if (!lastCase.d.isIrrefutable) { + this._queueEdgeLabel(fromId, '都不匹配'); + exits.push(fromId); + } + + return exits; + } + + // ===== try / except / else / finally ===== + + private _buildTry(node: TryNode, incoming: string[], loopScope: LoopScope | undefined): string[] { + const tryId = this._addNode(ChartShape.Decision, ['try'], node.start); + this._link(incoming, tryId); + + // finally 入口必须先建:try 体与各 handler 里的 return/raise/break/continue + // 都要穿过它,_routeThroughFinally 才能拿到入口 id。 + const finallySuite = node.d.finallySuite; + const finallyEntryId = finallySuite + ? this._addNode(ChartShape.Block, ['finally'], finallySuite.start) + : undefined; + if (finallyEntryId) { + this._finallyStack.push({ entryId: finallyEntryId }); + } + + // 正常路径:try 体 →(可选 else)。 + this._queueEdgeLabel(tryId, '正常'); + let normalExits = this._buildStatements(node.d.trySuite.d.statements, [tryId], loopScope); + this._clearEdgeLabel(tryId); + + if (node.d.elseSuite) { + normalExits = this._buildStatements(node.d.elseSuite.d.statements, normalExits, loopScope); + } + + // 异常路径:逐个 except 连成链。 + const handlerExits: string[] = []; + let handlerIncomingId = tryId; + let handlerIncomingIsTry = true; + + for (const exceptNode of node.d.exceptClauses) { + const typeText = exceptNode.d.typeExpr ? this._getNodeText(exceptNode.d.typeExpr) : ''; + const nameText = exceptNode.d.name ? ` as ${exceptNode.d.name.d.value}` : ''; + const condId = this._addNode( + ChartShape.Decision, + [`except ${typeText}${nameText}`.trim()], + exceptNode.start + ); + this._queueEdgeLabel(handlerIncomingId, handlerIncomingIsTry ? '异常' : '未匹配'); + this._link([handlerIncomingId], condId); + + this._queueEdgeLabel(condId, '匹配'); + handlerExits.push( + ...this._buildStatements(exceptNode.d.exceptSuite.d.statements, [condId], loopScope) + ); + this._clearEdgeLabel(condId); + + handlerIncomingId = condId; + handlerIncomingIsTry = false; + } + + // 没有任何 handler 匹配(或无 handler)时异常继续向上传播。 + const lastExcept = node.d.exceptClauses[node.d.exceptClauses.length - 1]; + const propagationSources: string[] = []; + if (!lastExcept) { + this._queueEdgeLabel(tryId, '异常'); + propagationSources.push(tryId); + } else if (lastExcept.d.typeExpr) { + // 裸 except(无类型)能兜住所有异常,故不再有传播路径。 + this._queueEdgeLabel(handlerIncomingId, '未匹配'); + propagationSources.push(handlerIncomingId); + } + + // try 体 / else / handler 都已构建完,最后才轮到 finally 体, + // 故从这里开始不再把出口穿过 finally(否则 finally 自己会被串进去)。 + if (finallyEntryId) { + this._finallyStack.pop(); + } + + // 未匹配的异常也要先执行 finally,再向上传播。 + if (propagationSources.length > 0) { + if (finallyEntryId) { + this._link(propagationSources, finallyEntryId); + } + const propagationId = this._addNode(ChartShape.Terminator, ['raise(异常向上传播)'], node.start); + this._link(finallyEntryId ? [finallyEntryId] : propagationSources, propagationId); + } + + let exits = normalExits.concat(handlerExits); + + // finally:正常路径与各 handler 的出口都汇聚进来,执行完再继续向下。 + if (finallySuite && finallyEntryId) { + this._link(exits, finallyEntryId); + exits = this._buildStatements(finallySuite.d.statements, [finallyEntryId], loopScope, finallyEntryId); + } + + return exits; + } + + // ===== with ===== + + private _buildWith(node: WithNode, incoming: string[], loopScope: LoopScope | undefined): string[] { + const itemText = node.d.withItems + .map((item) => { + const exprText = this._getNodeText(item.d.expr); + return item.d.target ? `${exprText} as ${this._getNodeText(item.d.target)}` : exprText; + }) + .join(', '); + const headerId = this._addNode(ChartShape.Block, [`with ${itemText}`], node.start); + this._link(incoming, headerId); + + // with 是透明容器:内部语句顺序展开,并允许首条简单语句并入标题块。 + return this._buildStatements(node.d.suite.d.statements, [headerId], loopScope, headerId); + } + + // ===== 嵌套 def / class ===== + + private _buildNestedDefinition(statement: StatementNode, incoming: string[]): string[] { + const headerText = this._getNodeText(statement).split('\n')[0]; + const nodeId = this._addNode(ChartShape.Block, [`${headerText.trim()} …(未展开)`], statement.start); + this._link(incoming, nodeId); + + // 嵌套定义本身是声明语句,执行流继续向下。 + return [nodeId]; + } + + // ===== 文本与渲染 ===== + + private _getNodeText(node: ParseNode): string { + const text = this._sourceText.slice(node.start, TextRange.getEnd(node)); + return text.replace(/\s+/g, ' ').trim().slice(0, MaxCharsPerLine); + } + + private _getTokenText(token: Token): string { + return this._sourceText.slice(token.start, token.start + token.length); + } + + private _render(): string { + const output: string[] = ['flowchart TD']; + + for (const node of this._nodes) { + const label = node.lines.map((line) => escapeMermaidLabel(line)).join('
'); + output.push(` ${node.id}${renderNodeShape(node.shape, label)}`); + } + + for (const edge of this._edges) { + const labelText = edge.label ? `|"${escapeMermaidLabel(edge.label)}"| ` : ''; + output.push(` ${edge.from} -->${labelText ? ' ' + labelText : ' '}${edge.to}`); + } + + output.push(''); + output.push(' classDef entry fill:none,stroke:#16a34a,stroke-width:2px'); + output.push(' classDef exitNode fill:none,stroke:#dc2626,stroke-width:2px'); + output.push(' classDef terminator fill:none,stroke:#dc2626,stroke-width:2px'); + output.push(' classDef decision fill:none,stroke:#d97706,stroke-width:2px'); + + for (const node of this._nodes) { + const className = getClassName(node.shape); + output.push(` class ${node.id} ${className}`); + } + + return output.join('\n'); + } +} + +function renderNodeShape(shape: ChartShape, label: string): string { + switch (shape) { + case ChartShape.Decision: + return `{"${label}"}`; + case ChartShape.Terminator: + return `(["${label}"])`; + case ChartShape.Entry: + case ChartShape.Exit: + return `(["${label}"])`; + default: + return `["${label}"]`; + } +} + +function getClassName(shape: ChartShape): string { + switch (shape) { + case ChartShape.Entry: + return 'entry'; + case ChartShape.Exit: + return 'exitNode'; + case ChartShape.Terminator: + return 'terminator'; + case ChartShape.Decision: + return 'decision'; + default: + return 'block'; + } +} + +/** + * 转义 Mermaid 标签。 + * Mermaid 在带引号的标签里用 HTML 实体,且 `#` 是实体前缀,故先转义 `#`。 + */ +function escapeMermaidLabel(text: string): string { + return text + .replace(/#/g, '#35;') + .replace(/&/g, '#38;') + .replace(/"/g, '#quot;') + .replace(//g, '#62;'); +} diff --git a/packages/vipeye-internal/src/commands/commandController.ts b/packages/vipeye-internal/src/commands/commandController.ts index 390ab03d..688ea52d 100644 --- a/packages/vipeye-internal/src/commands/commandController.ts +++ b/packages/vipeye-internal/src/commands/commandController.ts @@ -12,6 +12,7 @@ import { LanguageServerInterface } from '../common/languageServerInterface'; import { Commands } from './commands'; import { CreateTypeStubCommand } from './createTypeStub'; import { DumpFileDebugInfoCommand } from './dumpFileDebugInfoCommand'; +import { FlowChartCommand } from './flowChartCommand'; import { QuickActionCommand } from './quickActionCommand'; import { RestartServerCommand } from './restartServer'; @@ -24,12 +25,14 @@ export class CommandController implements ServerCommand { private _restartServer: RestartServerCommand; private _quickAction: QuickActionCommand; private _dumpFileDebugInfo: DumpFileDebugInfoCommand; + private _flowChart: FlowChartCommand; constructor(ls: LanguageServerInterface) { this._createStub = new CreateTypeStubCommand(ls); this._restartServer = new RestartServerCommand(ls); this._quickAction = new QuickActionCommand(ls); this._dumpFileDebugInfo = new DumpFileDebugInfoCommand(ls); + this._flowChart = new FlowChartCommand(ls); } async execute(cmdParams: ExecuteCommandParams, token: CancellationToken): Promise { @@ -50,6 +53,10 @@ export class CommandController implements ServerCommand { return this._dumpFileDebugInfo.execute(cmdParams, token); } + case Commands.showFunctionFlowChart: { + return this._flowChart.execute(cmdParams, token); + } + default: { return new ResponseError(1, 'Unsupported command'); } diff --git a/packages/vipeye-internal/src/commands/commands.ts b/packages/vipeye-internal/src/commands/commands.ts index e5bd035b..58a5d8a4 100644 --- a/packages/vipeye-internal/src/commands/commands.ts +++ b/packages/vipeye-internal/src/commands/commands.ts @@ -18,4 +18,5 @@ export const enum Commands { dumpTypes = 'viper.dumpTypes', dumpCachedTypes = 'viper.dumpCachedTypes', dumpCodeFlowGraph = 'viper.dumpCodeFlowGraph', + showFunctionFlowChart = 'viper.showFunctionFlowChart', } diff --git a/packages/vipeye-internal/src/commands/flowChartCommand.ts b/packages/vipeye-internal/src/commands/flowChartCommand.ts new file mode 100644 index 00000000..c95e94a7 --- /dev/null +++ b/packages/vipeye-internal/src/commands/flowChartCommand.ts @@ -0,0 +1,100 @@ +/* + * flowChartCommand.ts + * + * Viper 扩展:为「函数跳转图」提供 LSP 服务端命令。 + * + * 客户端(vscode-vipeye)把「文件 uri + 光标 offset」发过来,本命令: + * 1) 按 offset 找到最内层节点; + * 2) 从该节点向上找到最近的函数(方法优先于其所在类,故光标在方法内即取方法); + * 3) 用 AST 构建控制流图(见 analyzer/flowChartBuilder.ts),返回 Mermaid 文本与节点行号映射。 + * + * 光标不在任何函数内时返回 undefined,由客户端提示用户。 + */ + +import { CancellationToken, ExecuteCommandParams } from 'vscode-languageserver'; + +import { buildFlowChartForFunction, FlowChartNodeLines } from '../analyzer/flowChartBuilder'; +import { findNodeByOffset } from '../analyzer/parseTreeUtils'; +import { throwIfCancellationRequested } from '../common/cancellationUtils'; +import { LanguageServerInterface } from '../common/languageServerInterface'; +import { convertOffsetToPosition } from '../common/positionUtils'; +import { Uri } from '../common/uri/uri'; +import { FunctionNode, ParseNode, ParseNodeType } from '../parser/parseNodes'; +import { ServerCommand } from './commandController'; + +/** 服务端返回给客户端的流程图数据(跨进程传输)。 */ +export interface FlowChartResponse { + /** 函数名(面板标题用)。 */ + functionName: string; + /** 完整 Mermaid 文本。 */ + mermaidText: string; + /** 图节点 id → 源码行号(1 起),供点击节点跳转。 */ + nodeLines: FlowChartNodeLines; + /** 函数在文档中的起始 / 结束行号(1 起)。 */ + startLine: number; + endLine: number; +} + +export class FlowChartCommand implements ServerCommand { + constructor(private _ls: LanguageServerInterface) {} + + async execute(params: ExecuteCommandParams, token: CancellationToken): Promise { + throwIfCancellationRequested(token); + + if (!params.arguments || params.arguments.length < 2) { + return undefined; + } + + const fileUri = Uri.parse(params.arguments[0] as string, this._ls.serviceProvider); + const offset = params.arguments[1] as number; + const workspace = await this._ls.getWorkspaceForFile(fileUri); + + return workspace.service.run((_program) => { + const parseResults = workspace.service.getParseResults(workspace.service.fs.realCasePath(fileUri)); + if (!parseResults || offset === undefined) { + return undefined; + } + + const node = findNodeByOffset(parseResults.parserOutput.parseTree, offset); + if (!node) { + return undefined; + } + + const functionNode = findInnermostFunction(node); + if (!functionNode) { + return undefined; + } + + const chart = buildFlowChartForFunction(functionNode, parseResults.text, parseResults.parserOutput.lines); + const lines = parseResults.parserOutput.lines; + + return { + functionName: chart.functionName, + mermaidText: chart.mermaidText, + nodeLines: chart.nodeLines, + startLine: convertOffsetToPosition(functionNode.start, lines).line + 1, + endLine: convertOffsetToPosition(functionNode.start + functionNode.length - 1, lines).line + 1, + }; + }); + } +} + +/** + * 从指定节点向上找最近的函数定义。 + * 注意不能用 parseTreeUtils 的 getEnclosingFunction:它从 parent 起找,节点本身是函数时取不到; + * 且它在类体内直接返回 undefined,而 Viper 的方法都写在类体里,那会导致方法全都取不到。 + * 本函数遇到 Function 即返回,因此光标在方法内时取到的是方法而不是类。 + */ +function findInnermostFunction(node: ParseNode): FunctionNode | undefined { + let curNode: ParseNode | undefined = node; + + while (curNode) { + if (curNode.nodeType === ParseNodeType.Function) { + return curNode; + } + + curNode = curNode.parent; + } + + return undefined; +} diff --git a/packages/vipeye-internal/src/tests/__flowchart__/classify.vp b/packages/vipeye-internal/src/tests/__flowchart__/classify.vp new file mode 100644 index 00000000..82bb7f25 --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/classify.vp @@ -0,0 +1,10 @@ + +def classify(n: int) -> int: + result: int = 0 + if n > 0: + result = 1 + elif n < 0: + result = -1 + else: + result = 0 + return result diff --git a/packages/vipeye-internal/src/tests/__flowchart__/countdown.vp b/packages/vipeye-internal/src/tests/__flowchart__/countdown.vp new file mode 100644 index 00000000..55025fa4 --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/countdown.vp @@ -0,0 +1,7 @@ + +def countdown(n: int) -> int: + while n > 0: + n = n - 1 + else: + n = 0 + return n diff --git a/packages/vipeye-internal/src/tests/__flowchart__/early.vp b/packages/vipeye-internal/src/tests/__flowchart__/early.vp new file mode 100644 index 00000000..d4425c64 --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/early.vp @@ -0,0 +1,6 @@ + +def early(n: int) -> int: + try: + return n + 1 + finally: + n = 0 diff --git a/packages/vipeye-internal/src/tests/__flowchart__/handle.vp b/packages/vipeye-internal/src/tests/__flowchart__/handle.vp new file mode 100644 index 00000000..1b62682c --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/handle.vp @@ -0,0 +1,9 @@ + +def handle(n: int) -> int: + try: + n = n + 1 + except ValueError: + n = 0 + finally: + n = n * 2 + return n diff --git a/packages/vipeye-internal/src/tests/__flowchart__/mixed.vp b/packages/vipeye-internal/src/tests/__flowchart__/mixed.vp new file mode 100644 index 00000000..c00ebbac --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/mixed.vp @@ -0,0 +1,9 @@ + +def mixed(n: int) -> int: + total: int = 0 + for i in n: + if i > 0: + total = total + i + else: + total = total - i + return total diff --git a/packages/vipeye-internal/src/tests/__flowchart__/route.vp b/packages/vipeye-internal/src/tests/__flowchart__/route.vp new file mode 100644 index 00000000..c1479e74 --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/route.vp @@ -0,0 +1,9 @@ + +def route(n: int) -> int: + match n: + case 0: + return 1 + case 1: + return 2 + case _: + return 3 diff --git a/packages/vipeye-internal/src/tests/__flowchart__/route2.vp b/packages/vipeye-internal/src/tests/__flowchart__/route2.vp new file mode 100644 index 00000000..fa98a438 --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/route2.vp @@ -0,0 +1,6 @@ + +def route2(n: int) -> int: + match n: + case 0: + n = 1 + return n diff --git a/packages/vipeye-internal/src/tests/__flowchart__/scan.vp b/packages/vipeye-internal/src/tests/__flowchart__/scan.vp new file mode 100644 index 00000000..b2ac9a04 --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/scan.vp @@ -0,0 +1,12 @@ + +def scan(n: int) -> int: + total: int = 0 + for i in n: + if i == 0: + continue + if i < 0: + break + total = total + i + else: + total = -1 + return total diff --git a/packages/vipeye-internal/src/tests/__flowchart__/scan2.vp b/packages/vipeye-internal/src/tests/__flowchart__/scan2.vp new file mode 100644 index 00000000..0333aac7 --- /dev/null +++ b/packages/vipeye-internal/src/tests/__flowchart__/scan2.vp @@ -0,0 +1,9 @@ + +def scan2(n: int) -> int: + for i in n: + try: + if i < 0: + break + finally: + n = n * 2 + return n diff --git a/packages/vipeye-internal/src/tests/viperFlowChart.test.ts b/packages/vipeye-internal/src/tests/viperFlowChart.test.ts new file mode 100644 index 00000000..22dcd81e --- /dev/null +++ b/packages/vipeye-internal/src/tests/viperFlowChart.test.ts @@ -0,0 +1,384 @@ +/* + * viperFlowChart.test.ts + * + * 验证函数跳转图(AST → Mermaid 控制流图)在各类语句结构下的产出: + * - if / elif / else + * - for / for-else、while / while-else(break 绕过 else、continue 回流条件) + * - match / case(含不可反驳的 `case _`) + * - try / except / finally(异常未匹配时的向上传播) + * - 节点 id 与「节点 → 行号」映射一一对应(点击跳转依赖该映射) + * + * 运行:npx jest src/tests/viperFlowChart.test.ts --runInBand + */ + +import assert from 'assert'; +import * as path from 'path'; + +import { buildFlowChartForFunction } from '../analyzer/flowChartBuilder'; +import { ImportResolver } from '../analyzer/importResolver'; +import { FunctionNode } from '../parser/parseNodes'; +import { ParseTreeWalker } from '../analyzer/parseTreeWalker'; +import { Program } from '../analyzer/program'; +import { ConfigOptions, ExecutionEnvironment } from '../common/configOptions'; +import { NullConsole } from '../common/console'; +import { FullAccessHost } from '../common/fullAccessHost'; +import { RealTempFile, createFromRealFileSystem } from '../common/realFileSystem'; +import { createServiceProvider } from '../common/serviceProviderExtensions'; +import { UriEx } from '../common/uri/uriUtils'; + +(global as any).__rootDirectory = path.resolve(); + +class FunctionCollector extends ParseTreeWalker { + readonly functions: FunctionNode[] = []; + + override visitFunction(node: FunctionNode): boolean { + this.functions.push(node); + return true; + } +} + +function buildProgramForCode(code: string, fileName: string) { + const tempFile = new RealTempFile(); + const serviceProvider = createServiceProvider(createFromRealFileSystem(tempFile), new NullConsole(), tempFile); + const projectRoot = UriEx.file(path.join(__dirname, '__flowchart__')); + const configOptions = new ConfigOptions(projectRoot); + configOptions.internalTestMode = true; + configOptions.initializeTypeCheckingMode('standard'); + + const execEnv = new ExecutionEnvironment( + 'python', + projectRoot, + configOptions.diagnosticRuleSet, + configOptions.defaultPythonVersion, + configOptions.defaultPythonPlatform, + [projectRoot], + /* skipNativeLibraries */ false, + /* defaultDialect */ 'viper' + ); + configOptions.executionEnvironments = [execEnv]; + + const importResolver = new ImportResolver(serviceProvider, configOptions, new FullAccessHost(serviceProvider)); + const program = new Program(importResolver, configOptions, serviceProvider); + + const fs = require('fs'); + const testDir = path.join(__dirname, '__flowchart__'); + fs.mkdirSync(testDir, { recursive: true }); + const testFile = path.join(testDir, fileName); + fs.writeFileSync(testFile, code, 'utf8'); + + const uri = UriEx.file(testFile); + program.setTrackedFiles([uri]); + while (program.analyze()) { + // 循环直到分析完成 + } + + return { program, uri }; +} + +/** 解析 code 并把名为 functionName 的函数的控制流图构建出来。 */ +function buildChart(code: string, functionName: string) { + const { program, uri } = buildProgramForCode(code, `${functionName}.vp`); + const parseResults = program.getParseResults(uri); + assert(parseResults, `parse results missing for ${functionName}`); + + const collector = new FunctionCollector(); + collector.walk(parseResults!.parserOutput.parseTree); + + const functionNode = collector.functions.find((func) => func.d.name.d.value === functionName); + assert(functionNode, `function ${functionName} not found`); + + return buildFlowChartForFunction(functionNode!, parseResults!.text, parseResults!.parserOutput.lines); +} + +interface ParsedEdge { + from: string; + label: string | undefined; + to: string; +} + +/** 还原 Mermaid 标签里的 HTML 实体(构建器会把 `#`/`&`/`"`/`<`/`>` 转义掉)。 */ +function decodeMermaidLabel(text: string): string { + return text + .replace(/#quot;/g, '"') + .replace(/#38;/g, '&') + .replace(/#60;/g, '<') + .replace(/#62;/g, '>') + .replace(/#35;/g, '#'); +} + +/** 从 Mermaid 文本里解析出节点标签与边,便于做结构性断言。 */ +function parseChart(mermaidText: string) { + const nodeLabels = new Map(); + const edges: ParsedEdge[] = []; + + for (const line of mermaidText.split('\n')) { + const trimmed = line.trim(); + const nodeMatch = /^(n\d+)(?:\["(.+)"\]|\{"(.+)"\}|\(\["(.+)"\]\))$/.exec(trimmed); + if (nodeMatch) { + nodeLabels.set(nodeMatch[1], decodeMermaidLabel(nodeMatch[2] ?? nodeMatch[3] ?? nodeMatch[4] ?? '')); + continue; + } + + const edgeMatch = /^(n\d+)\s*-->\s*(?:\|"(.+?)"\|\s*)?(n\d+)$/.exec(trimmed); + if (edgeMatch) { + edges.push({ from: edgeMatch[1], label: edgeMatch[2], to: edgeMatch[3] }); + } + } + + return { + nodeLabels, + edges, + /** 按标签片段找节点 id(取第一个匹配)。 */ + findNode(labelPart: string): string { + for (const [id, label] of nodeLabels) { + if (label.includes(labelPart)) { + return id; + } + } + throw new Error(`node containing "${labelPart}" not found in:\n${mermaidText}`); + }, + hasEdge(from: string, to: string): boolean { + return edges.some((edge) => edge.from === from && edge.to === to); + }, + }; +} + +test('if / elif / else 串成连续菱形,True/False 分支齐全', () => { + const code = ` +def classify(n: int) -> int: + result: int = 0 + if n > 0: + result = 1 + elif n < 0: + result = -1 + else: + result = 0 + return result +`; + const chart = buildChart(code, 'classify'); + console.log('=== if/elif/else 图 ==='); + console.log(chart.mermaidText); + + const parsed = parseChart(chart.mermaidText); + assert(chart.mermaidText.startsWith('flowchart TD'), 'should be a mermaid flowchart'); + + const ifNode = parsed.findNode('if n > 0'); + const elifNode = parsed.findNode('elif n < 0'); + const elseBody = parsed.findNode('result = 0'); + const thenBody = parsed.findNode('result = 1'); + const elifBody = parsed.findNode('result = -1'); + + // if 的 True 进 then、False 进 elif;elif 的 True 进 elif 体、False 进 else。 + assert(parsed.hasEdge(ifNode, thenBody), 'if True 应连到 then 体'); + assert(parsed.hasEdge(ifNode, elifNode), 'if False 应连到 elif'); + assert(parsed.hasEdge(elifNode, elifBody), 'elif True 应连到 elif 体'); + assert(parsed.hasEdge(elifNode, elseBody), 'elif False 应连到 else 体'); + // 两条分支都汇入 return。 + const returnNode = parsed.findNode('return result'); + assert(parsed.hasEdge(thenBody, returnNode), 'then 出口应汇入 return'); + assert(parsed.hasEdge(elseBody, returnNode), 'else 出口应汇入 return'); +}); + +test('for-else:break 绕过 else,continue 回流条件', () => { + const code = ` +def scan(n: int) -> int: + total: int = 0 + for i in n: + if i == 0: + continue + if i < 0: + break + total = total + i + else: + total = -1 + return total +`; + const chart = buildChart(code, 'scan'); + console.log('=== for-else 图 ==='); + console.log(chart.mermaidText); + + const parsed = parseChart(chart.mermaidText); + const loopCond = parsed.findNode('for i in n'); + const elseBody = parsed.findNode('total = -1'); + const breakNode = parsed.findNode('break'); + const continueNode = parsed.findNode('continue'); + const returnNode = parsed.findNode('return total'); + + // 迭代结束才进 else;break 直接跳过 else。 + assert(parsed.hasEdge(loopCond, elseBody), '迭代结束应进入 else'); + assert(!parsed.hasEdge(breakNode, elseBody), 'break 不应进入 else'); + assert(parsed.hasEdge(breakNode, returnNode), 'break 应直接到 else 之后的语句'); + + // continue 回流到循环条件;else 出口继续向下。 + assert(parsed.hasEdge(continueNode, loopCond), 'continue 应回流到循环条件'); + assert(parsed.hasEdge(elseBody, returnNode), 'else 出口应继续向下'); +}); + +test('while-else:条件为假的出口进入 else', () => { + const code = ` +def countdown(n: int) -> int: + while n > 0: + n = n - 1 + else: + n = 0 + return n +`; + const chart = buildChart(code, 'countdown'); + console.log('=== while-else 图 ==='); + console.log(chart.mermaidText); + + const parsed = parseChart(chart.mermaidText); + const condNode = parsed.findNode('while n > 0'); + const elseNode = parsed.findNode('n = 0'); + + assert(parsed.hasEdge(condNode, elseNode), 'while 条件为假应进入 else'); + + const falseEdge = parsed.edges.find((edge) => edge.from === condNode && edge.to === elseNode); + assert.strictEqual(falseEdge?.label, 'False', 'while → else 的边应标记 False'); +}); + +test('match-case:每个 case 一个菱形,不可反驳的 case 不再产生「都不匹配」出口', () => { + const code = ` +def route(n: int) -> int: + match n: + case 0: + return 1 + case 1: + return 2 + case _: + return 3 +`; + const chart = buildChart(code, 'route'); + console.log('=== match-case 图 ==='); + console.log(chart.mermaidText); + + const parsed = parseChart(chart.mermaidText); + const case0 = parsed.findNode('case 0'); + const case1 = parsed.findNode('case 1'); + const wildcard = parsed.findNode('case _'); + + assert(parsed.hasEdge(case0, case1), 'case 0 不匹配应进入 case 1'); + assert(parsed.hasEdge(case1, wildcard), 'case 1 不匹配应进入 case _'); + assert(!chart.mermaidText.includes('都不匹配'), '最后一个 case 不可反驳时不应有「都不匹配」出口'); +}); + +test('match-case:最后一个 case 可反驳时有「都不匹配」出口', () => { + const code = ` +def route2(n: int) -> int: + match n: + case 0: + n = 1 + return n +`; + const chart = buildChart(code, 'route2'); + const parsed = parseChart(chart.mermaidText); + + const case0 = parsed.findNode('case 0'); + const returnNode = parsed.findNode('return n'); + assert(chart.mermaidText.includes('都不匹配'), '可反驳的最后一个 case 应有「都不匹配」出口'); + assert(parsed.hasEdge(case0, returnNode), '「都不匹配」应连线到 match 之后的语句'); +}); + +test('try / except / finally:异常向上传播与 finally 汇聚', () => { + const code = ` +def handle(n: int) -> int: + try: + n = n + 1 + except ValueError: + n = 0 + finally: + n = n * 2 + return n +`; + const chart = buildChart(code, 'handle'); + console.log('=== try/except/finally 图 ==='); + console.log(chart.mermaidText); + + const parsed = parseChart(chart.mermaidText); + const tryNode = parsed.findNode('try'); + const exceptNode = parsed.findNode('except ValueError'); + const finallyNode = parsed.findNode('finally'); + const propagateNode = parsed.findNode('异常向上传播'); + const exceptBody = parsed.findNode('n = 0'); + const returnNode = parsed.findNode('return n'); + + assert(parsed.hasEdge(tryNode, exceptNode), 'try 异常应进入 except'); + // 未匹配的异常也要先执行 finally,再向上传播。 + assert(parsed.hasEdge(exceptNode, finallyNode), '未匹配的异常应先进入 finally'); + assert(parsed.hasEdge(finallyNode, propagateNode), 'finally 执行完异常才向上传播'); + // 正常路径与 handler 路径都要汇聚到 finally,再继续向下。 + assert(parsed.hasEdge(exceptBody, finallyNode), 'except 体应汇聚到 finally'); + assert(parsed.hasEdge(finallyNode, returnNode), 'finally 出口应继续向下'); +}); + +test('try/finally:return 穿过 finally 之后才结束函数', () => { + const code = ` +def early(n: int) -> int: + try: + return n + 1 + finally: + n = 0 +`; + const chart = buildChart(code, 'early'); + console.log('=== try/finally return 穿透图 ==='); + console.log(chart.mermaidText); + + const parsed = parseChart(chart.mermaidText); + const returnNode = parsed.findNode('return n + 1'); + const finallyNode = parsed.findNode('finally'); + const endNode = parsed.findNode('结束'); + + assert(parsed.hasEdge(returnNode, finallyNode), 'return 应先进入 finally'); + assert(parsed.hasEdge(finallyNode, endNode), 'finally 执行完才到函数结束'); +}); + +test('try/finally:循环内 break 穿过 finally 之后才跳出循环', () => { + const code = ` +def scan2(n: int) -> int: + for i in n: + try: + if i < 0: + break + finally: + n = n * 2 + return n +`; + const chart = buildChart(code, 'scan2'); + console.log('=== try/finally break 穿透图 ==='); + console.log(chart.mermaidText); + + const parsed = parseChart(chart.mermaidText); + const breakNode = parsed.findNode('break'); + const finallyNode = parsed.findNode('finally'); + const returnNode = parsed.findNode('return n'); + + assert(parsed.hasEdge(breakNode, finallyNode), 'break 应先进入 finally'); + assert(parsed.hasEdge(finallyNode, returnNode), 'finally 执行完才跳出循环到 return'); +}); + +test('节点 id 与「节点 → 行号」映射一一对应,且行号在函数范围内', () => { + const code = ` +def mixed(n: int) -> int: + total: int = 0 + for i in n: + if i > 0: + total = total + i + else: + total = total - i + return total +`; + const chart = buildChart(code, 'mixed'); + const parsed = parseChart(chart.mermaidText); + + const nodeIds = Array.from(parsed.nodeLabels.keys()); + assert(nodeIds.length > 0, '图中应有节点'); + + for (const nodeId of nodeIds) { + const line = chart.nodeLines[nodeId]; + assert(typeof line === 'number', `节点 ${nodeId} 缺少行号映射`); + // mixed 从第 2 行开始,函数体结束在第 9 行。 + assert(line >= 2 && line <= 9, `节点 ${nodeId} 行号越界: ${line}`); + } + + assert.strictEqual(chart.functionName, 'mixed'); +}); diff --git a/packages/vscode-vipeye/media/flowChartWebview.js b/packages/vscode-vipeye/media/flowChartWebview.js new file mode 100644 index 00000000..38b36681 --- /dev/null +++ b/packages/vscode-vipeye/media/flowChartWebview.js @@ -0,0 +1,645 @@ +/* + * flowChartWebview.js + * + * 「函数流程图」面板的 Webview 侧脚本。 + * + * 以 + + +`; + } +} + +/** 注册面板 provider(供 extension.ts 调用)。 */ +export function registerFlowChartView(context: ExtensionContext, extensionUri: Uri): FlowChartViewProvider { + const provider = new FlowChartViewProvider(extensionUri); + context.subscriptions.push(window.registerWebviewViewProvider(FlowChartViewProvider.viewType, provider)); + return provider; +}