feat(viper): 新增函数跳转图(Ctrl+Alt+G,面板渲染 Mermaid,可拖动缩放并导出 PNG)
- vipeye-internal/flowChartBuilder:AST -> 语句级控制流图(基本块合并)输出 Mermaid, 覆盖 if/elif/else、for/for-else、while/while-else、match-case、try/except/else/finally、 break/continue/return/raise、with;嵌套 def/class 折叠为单节点 - finally 穿透:return/raise/break/continue 与未匹配异常都先经过 finally 再继续, 嵌套 finally 由 _routeThroughFinally 按最内层到最外层串联,并对边去重 - vipeye-internal/flowChartCommand:新增 viper.showFunctionFlowChart 命令,按 offset 取最内层函数,返回 Mermaid 与「节点 -> 行号」映射 - vscode-vipeye:底部停靠面板 WebviewView 渲染 mermaid(外部脚本 media/flowChartWebview.js, 数据经 postMessage 下发),支持拖拽平移(指针锁定无限拖 + 视口内保留可见图块)、 滚轮缩放、适应宽度/重置、点击节点跳转源码行 - 导出 PNG:倍率/宽高/裁剪空白边距/透明或白色背景 + 实时预计尺寸, 经 data: URL -> canvas 光栅化(导出前用 htmlLabels:false 重渲染,避开 foreignObject) - 入口:快捷键 Ctrl+Alt+G、编辑器右键菜单、命令面板 - 依赖与构建:新增 mermaid 依赖,rspack 拷贝 mermaid 预构建产物与面板脚本到 dist/media - 新增 9 个用例覆盖上述语句结构与 finally 穿透 - 同步 package-lock 中的版本号与 mermaid 依赖
This commit is contained in:
1 parent
cfccbac6f4
commit
640d7e4a07
21 files changed
+3567
-7
No files matched your search
+2
-2
@@ -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",
|
||||
|
||||
@@ -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<TextRange>
|
||||
): 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<string, ChartNode>();
|
||||
private _nodeLines: FlowChartNodeLines = {};
|
||||
private _nextNodeId = 1;
|
||||
|
||||
// 「待消费的边标签」:下次从该节点连出的边会带上这个标签。
|
||||
// 用于给分支出口(True/False、匹配/不匹配)打标签,而无需让 _buildStatements 感知标签。
|
||||
private _pendingEdgeLabels = new Map<string, string>();
|
||||
|
||||
// try-finally 作用域栈(外层在前、内层在后)。为空表示当前不在任何 finally 内。
|
||||
private _finallyStack: FinallyScope[] = [];
|
||||
|
||||
// 已连接过的边(from|to|label),避免「多条出口穿过同一个 finally」时产生重复边。
|
||||
private _edgeKeys = new Set<string>();
|
||||
|
||||
// 函数结束节点,按需创建(正常落到末尾、或终止语句穿过 finally 之后都汇到它)。
|
||||
private _endNodeId: string | undefined;
|
||||
private _endNodeOffset = 0;
|
||||
|
||||
constructor(private _sourceText: string, private _lines: TextRangeCollection<TextRange>) {}
|
||||
|
||||
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('<br/>');
|
||||
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, '#60;')
|
||||
.replace(/>/g, '#62;');
|
||||
}
|
||||
@@ -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<any> {
|
||||
@@ -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<string>(1, 'Unsupported command');
|
||||
}
|
||||
|
||||
@@ -18,4 +18,5 @@ export const enum Commands {
|
||||
dumpTypes = 'viper.dumpTypes',
|
||||
dumpCachedTypes = 'viper.dumpCachedTypes',
|
||||
dumpCodeFlowGraph = 'viper.dumpCodeFlowGraph',
|
||||
showFunctionFlowChart = 'viper.showFunctionFlowChart',
|
||||
}
|
||||
@@ -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<FlowChartResponse | undefined> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
def countdown(n: int) -> int:
|
||||
while n > 0:
|
||||
n = n - 1
|
||||
else:
|
||||
n = 0
|
||||
return n
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
def early(n: int) -> int:
|
||||
try:
|
||||
return n + 1
|
||||
finally:
|
||||
n = 0
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
def handle(n: int) -> int:
|
||||
try:
|
||||
n = n + 1
|
||||
except ValueError:
|
||||
n = 0
|
||||
finally:
|
||||
n = n * 2
|
||||
return n
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
def route(n: int) -> int:
|
||||
match n:
|
||||
case 0:
|
||||
return 1
|
||||
case 1:
|
||||
return 2
|
||||
case _:
|
||||
return 3
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
def route2(n: int) -> int:
|
||||
match n:
|
||||
case 0:
|
||||
n = 1
|
||||
return n
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
def scan2(n: int) -> int:
|
||||
for i in n:
|
||||
try:
|
||||
if i < 0:
|
||||
break
|
||||
finally:
|
||||
n = n * 2
|
||||
return n
|
||||
@@ -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<string, string>();
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,645 @@
|
||||
/*
|
||||
* flowChartWebview.js
|
||||
*
|
||||
* 「函数流程图」面板的 Webview 侧脚本。
|
||||
*
|
||||
* 以 <script src> 外部文件加载(不用内联脚本 + nonce),避免 CSP 对内联脚本的限制;
|
||||
* 图数据由扩展通过 postMessage 下发:{ type: 'render', payload },payload 为 null 表示空态。
|
||||
*
|
||||
* 功能:
|
||||
* - 拖拽平移(确认拖动后锁定指针,可无限拖,类似 Blender 的 Continuous Grab)、滚轮缩放
|
||||
* - 点击节点跳转到源码行(postMessage { type: 'jump' })
|
||||
* - 导出 PNG(导出的重渲染用 htmlLabels:false:<foreignObject> 在 <img> 光栅化时不会被绘制)
|
||||
*
|
||||
* 注意:该文件是纯 JS 资源(不经 TypeScript 编译),由 rspack 拷到 dist/media 后由 Webview 加载。
|
||||
*/
|
||||
(function () {
|
||||
// Webview 里没有全局 `vscode`,必须通过 acquireVsCodeApi() 拿通信句柄(每个文档只能调用一次)。
|
||||
const vscodeApi = typeof acquireVsCodeApi === 'function' ? acquireVsCodeApi() : undefined;
|
||||
|
||||
const chartElement = document.getElementById('chart');
|
||||
const innerElement = document.getElementById('chart-inner');
|
||||
const emptyElement = document.getElementById('empty');
|
||||
const titleElement = document.getElementById('title');
|
||||
const hintElement = document.getElementById('hint');
|
||||
const errorElement = document.getElementById('error');
|
||||
const zoomLabelElement = document.getElementById('zoom-label');
|
||||
const exportPanelElement = document.getElementById('export-panel');
|
||||
const estimateElement = document.getElementById('exp-estimate');
|
||||
const exportRunButton = document.getElementById('btn-export-run');
|
||||
|
||||
/** 当前图数据(含 mermaidText/nodeLines/functionName/行范围)。 */
|
||||
let payload;
|
||||
/** 当前缩放与平移。 */
|
||||
let zoom = 1;
|
||||
let panX = 0;
|
||||
let panY = 0;
|
||||
/** 当前渲染出的 svg 根元素。 */
|
||||
let svgElement;
|
||||
|
||||
let renderSeq = 0;
|
||||
let lastTheme = '';
|
||||
/** 本次拖拽是否真的移动过(移动过就不触发节点跳转)。 */
|
||||
let dragMoved = false;
|
||||
/** 是否正在拖拽(用于兜底释放「拖拽结束后才异步生效」的指针锁定)。 */
|
||||
let dragging = false;
|
||||
/** 导出中标记,避免重复触发。 */
|
||||
let exporting = false;
|
||||
|
||||
function isDarkTheme() {
|
||||
const className = document.body.className || '';
|
||||
if (className.indexOf('vscode-high-contrast') >= 0) {
|
||||
return className.indexOf('vscode-high-contrast-light') < 0;
|
||||
}
|
||||
return className.indexOf('vscode-light') < 0;
|
||||
}
|
||||
|
||||
function mermaidTheme() {
|
||||
return isDarkTheme() ? 'dark' : 'default';
|
||||
}
|
||||
|
||||
function showEmpty() {
|
||||
innerElement.innerHTML = '';
|
||||
svgElement = undefined;
|
||||
titleElement.textContent = '';
|
||||
hintElement.style.display = 'none';
|
||||
errorElement.style.display = 'none';
|
||||
emptyElement.style.display = '';
|
||||
updateEstimate();
|
||||
}
|
||||
|
||||
function showError(text) {
|
||||
innerElement.innerHTML = '';
|
||||
svgElement = undefined;
|
||||
hintElement.style.display = 'none';
|
||||
emptyElement.style.display = 'none';
|
||||
errorElement.style.display = 'block';
|
||||
errorElement.textContent = text;
|
||||
}
|
||||
|
||||
// ===== 平移 / 缩放 =====
|
||||
|
||||
function applyTransform() {
|
||||
clampPan();
|
||||
innerElement.style.transform = 'translate(' + panX + 'px, ' + panY + 'px) scale(' + zoom + ')';
|
||||
if (zoomLabelElement) {
|
||||
zoomLabelElement.textContent = Math.round(zoom * 100) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制平移范围:不管怎么拖,视口内至少保留一小块图(默认 80px),
|
||||
* 避免把图整个拖出可视区后「什么都没有、也不知道往哪拖回来」。
|
||||
*/
|
||||
function clampPan() {
|
||||
if (!svgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = getSvgSize(svgElement);
|
||||
const chartWidth = size.width * zoom;
|
||||
const chartHeight = size.height * zoom;
|
||||
const keepVisible = 80;
|
||||
|
||||
const minPanX = -Math.max(0, chartWidth - keepVisible);
|
||||
const maxPanX = Math.max(0, chartElement.clientWidth - keepVisible);
|
||||
const minPanY = -Math.max(0, chartHeight - keepVisible);
|
||||
const maxPanY = Math.max(0, chartElement.clientHeight - keepVisible);
|
||||
|
||||
panX = Math.min(maxPanX, Math.max(minPanX, panX));
|
||||
panY = Math.min(maxPanY, Math.max(minPanY, panY));
|
||||
}
|
||||
|
||||
/** 取 svg 的自然尺寸(优先 width/height 属性,退化为 bbox)。 */
|
||||
function getSvgSize(svgEl) {
|
||||
const attrWidth = parseFloat(svgEl.getAttribute('width'));
|
||||
const attrHeight = parseFloat(svgEl.getAttribute('height'));
|
||||
if (attrWidth > 0 && attrHeight > 0) {
|
||||
return { width: attrWidth, height: attrHeight };
|
||||
}
|
||||
|
||||
try {
|
||||
const box = svgEl.getBBox();
|
||||
if (box.width > 0 && box.height > 0) {
|
||||
return { width: box.width, height: box.height };
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
return { width: 800, height: 600 };
|
||||
}
|
||||
|
||||
function fitToWidth() {
|
||||
if (!svgElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const size = getSvgSize(svgElement);
|
||||
const available = Math.max(80, chartElement.clientWidth - 20);
|
||||
zoom = Math.max(0.1, Math.min(4, available / size.width));
|
||||
panX = 10;
|
||||
panY = 10;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
zoom = 1;
|
||||
panX = 10;
|
||||
panY = 10;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
function zoomBy(factor) {
|
||||
const newZoom = Math.max(0.1, Math.min(8, zoom * factor));
|
||||
const ratio = newZoom / zoom;
|
||||
const px = chartElement.clientWidth / 2;
|
||||
const py = chartElement.clientHeight / 2;
|
||||
panX = px - (px - panX) * ratio;
|
||||
panY = py - (py - panY) * ratio;
|
||||
zoom = newZoom;
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
chartElement.addEventListener('mousedown', function (event) {
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
dragMoved = false;
|
||||
dragging = true;
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startPanX = panX;
|
||||
const startPanY = panY;
|
||||
let locked = false;
|
||||
// 锁定生效后重新基准化:只累加 movement 增量,避免与锁定前的坐标体系混用。
|
||||
let lockedBasePanX = 0;
|
||||
let lockedBasePanY = 0;
|
||||
let lockedDeltaX = 0;
|
||||
let lockedDeltaY = 0;
|
||||
// 锁定的生效是异步的,浏览器会先用一次较大的 movement 与真实光标对齐,需丢弃这一次。
|
||||
let skipFirstLockedMove = false;
|
||||
|
||||
function onMove(moveEvent) {
|
||||
// 指针锁定后光标不再受面板边界限制,用 movementX/Y 累加平移量(无限拖动)。
|
||||
if (document.pointerLockElement === chartElement) {
|
||||
const moveX = moveEvent.movementX || 0;
|
||||
const moveY = moveEvent.movementY || 0;
|
||||
|
||||
// 丢弃「对齐用的第一次位移」以及明显异常的尖峰,否则图会瞬间跳走。
|
||||
if (skipFirstLockedMove || Math.abs(moveX) > 300 || Math.abs(moveY) > 300) {
|
||||
skipFirstLockedMove = false;
|
||||
return;
|
||||
}
|
||||
|
||||
lockedDeltaX += moveX;
|
||||
lockedDeltaY += moveY;
|
||||
dragMoved = true;
|
||||
panX = lockedBasePanX + lockedDeltaX;
|
||||
panY = lockedBasePanY + lockedDeltaY;
|
||||
applyTransform();
|
||||
return;
|
||||
}
|
||||
|
||||
const dx = moveEvent.clientX - startX;
|
||||
const dy = moveEvent.clientY - startY;
|
||||
|
||||
// 先按位移确认「确实是拖动」,确认后才锁指针并开始平移:
|
||||
// - 单击(未移动)不锁指针,节点点击跳转仍然可用;
|
||||
// - 小于阈值的抖动也不会平移,避免「快速连点」时图被一点点推走。
|
||||
if (!locked && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||||
locked = true;
|
||||
dragMoved = true;
|
||||
try {
|
||||
const request = chartElement.requestPointerLock && chartElement.requestPointerLock();
|
||||
// 新版 Chrome 返回 Promise,被拒绝时忽略(退化为普通拖拽)。
|
||||
if (request && typeof request.catch === 'function') {
|
||||
request.catch(function () {});
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
if (dragMoved) {
|
||||
panX = startPanX + dx;
|
||||
panY = startPanY + dy;
|
||||
applyTransform();
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerLockChange() {
|
||||
if (document.pointerLockElement === chartElement) {
|
||||
// 锁定生效:以当前平移量为基准,之后只累加 movement 增量。
|
||||
lockedBasePanX = panX;
|
||||
lockedBasePanY = panY;
|
||||
lockedDeltaX = 0;
|
||||
lockedDeltaY = 0;
|
||||
skipFirstLockedMove = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (locked) {
|
||||
// 用户按 Esc 解除锁定时结束本次拖拽,避免坐标跳变。
|
||||
onUp();
|
||||
}
|
||||
}
|
||||
|
||||
function onUp() {
|
||||
dragging = false;
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
document.removeEventListener('pointerlockchange', onPointerLockChange);
|
||||
if (document.pointerLockElement === chartElement) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
chartElement.style.cursor = 'grab';
|
||||
}
|
||||
|
||||
chartElement.style.cursor = 'grabbing';
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
document.addEventListener('pointerlockchange', onPointerLockChange);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// 兜底:指针锁定的生效是异步的。若锁定在拖拽结束后才真正生效,光标会被一直锁住
|
||||
// (看不到光标、也无法正常操作),这里检测到就立刻释放。
|
||||
document.addEventListener('pointerlockchange', function () {
|
||||
if (document.pointerLockElement === chartElement && !dragging) {
|
||||
document.exitPointerLock();
|
||||
}
|
||||
});
|
||||
|
||||
chartElement.addEventListener(
|
||||
'wheel',
|
||||
function (event) {
|
||||
if (!svgElement) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
|
||||
const rect = chartElement.getBoundingClientRect();
|
||||
const px = event.clientX - rect.left;
|
||||
const py = event.clientY - rect.top;
|
||||
const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||
const newZoom = Math.max(0.1, Math.min(8, zoom * factor));
|
||||
const ratio = newZoom / zoom;
|
||||
|
||||
panX = px - (px - panX) * ratio;
|
||||
panY = py - (py - panY) * ratio;
|
||||
zoom = newZoom;
|
||||
applyTransform();
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
|
||||
// ===== 渲染 =====
|
||||
|
||||
function resolveNodeId(element, data) {
|
||||
const dataId = element.getAttribute('data-id');
|
||||
if (dataId && data.nodeLines[dataId]) {
|
||||
return dataId;
|
||||
}
|
||||
|
||||
const stripped = (element.id || '').replace(/^flowchart-/, '').replace(/-\d+$/, '');
|
||||
if (data.nodeLines[stripped]) {
|
||||
return stripped;
|
||||
}
|
||||
|
||||
const elementId = element.id || '';
|
||||
const keys = Object.keys(data.nodeLines);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
if (elementId.indexOf('-' + keys[i] + '-') >= 0) {
|
||||
return keys[i];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function attachJumpHandlers(data) {
|
||||
const nodeElements = chartElement.querySelectorAll('g.node');
|
||||
nodeElements.forEach(function (element) {
|
||||
const nodeId = resolveNodeId(element, data);
|
||||
const line = nodeId ? data.nodeLines[nodeId] : undefined;
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.style.cursor = 'pointer';
|
||||
element.addEventListener('click', function () {
|
||||
if (dragMoved || !vscodeApi) {
|
||||
return;
|
||||
}
|
||||
vscodeApi.postMessage({ type: 'jump', line: line });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function render(data) {
|
||||
const seq = ++renderSeq;
|
||||
|
||||
if (typeof mermaid === 'undefined') {
|
||||
showError('未能加载 mermaid(dist/media/mermaid.min.js)。请确认已 npm install 并重新构建扩展。');
|
||||
return;
|
||||
}
|
||||
|
||||
lastTheme = mermaidTheme();
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: lastTheme,
|
||||
// useMaxWidth=false:svg 保持自然尺寸,缩放/平移/导出都按像素精确计算。
|
||||
flowchart: { useMaxWidth: false, htmlLabels: true },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await mermaid.render('viperFlowChart' + seq, data.mermaidText);
|
||||
if (seq !== renderSeq) {
|
||||
return;
|
||||
}
|
||||
|
||||
innerElement.innerHTML = result.svg;
|
||||
svgElement = innerElement.querySelector('svg');
|
||||
if (svgElement) {
|
||||
svgElement.style.maxWidth = 'none';
|
||||
}
|
||||
|
||||
emptyElement.style.display = 'none';
|
||||
errorElement.style.display = 'none';
|
||||
hintElement.style.display = '';
|
||||
titleElement.textContent =
|
||||
data.functionName + '(第 ' + data.startLine + '-' + data.endLine + ' 行)';
|
||||
|
||||
attachJumpHandlers(data);
|
||||
fitToWidth();
|
||||
updateEstimate();
|
||||
} catch (err) {
|
||||
if (seq !== renderSeq) {
|
||||
return;
|
||||
}
|
||||
showError('Mermaid 渲染失败:' + (err && err.message ? err.message : String(err)));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 导出 PNG =====
|
||||
|
||||
/** 读取导出设置(倍率 / 宽高 / 裁剪 / 背景)。 */
|
||||
function readExportOptions() {
|
||||
return {
|
||||
scaleFactor: parseFloat(document.getElementById('exp-scale').value) || 1,
|
||||
width: document.getElementById('exp-width').value.trim(),
|
||||
height: document.getElementById('exp-height').value.trim(),
|
||||
crop: document.getElementById('exp-crop').checked,
|
||||
whiteBackground: document.getElementById('exp-bg-white').checked,
|
||||
};
|
||||
}
|
||||
|
||||
/** 按设置与内容尺寸算出输出像素尺寸(导出与「预计尺寸」共用,保证两者一致)。 */
|
||||
function computeOutputSize(options, viewBox) {
|
||||
let outWidth = options.width ? parseFloat(options.width) : viewBox.width * options.scaleFactor;
|
||||
let outHeight = options.height ? parseFloat(options.height) : viewBox.height * options.scaleFactor;
|
||||
if (options.width && !options.height) {
|
||||
outHeight = outWidth * (viewBox.height / viewBox.width);
|
||||
} else if (!options.width && options.height) {
|
||||
outWidth = outHeight * (viewBox.width / viewBox.height);
|
||||
}
|
||||
return { width: Math.max(1, Math.round(outWidth)), height: Math.max(1, Math.round(outHeight)) };
|
||||
}
|
||||
|
||||
/** 用当前渲染出的 svg 估算导出尺寸(≈ 实际值,导出时会用 htmlLabels:false 重渲染)。 */
|
||||
function updateEstimate() {
|
||||
if (!estimateElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!svgElement) {
|
||||
estimateElement.textContent = '预计导出尺寸:—';
|
||||
return;
|
||||
}
|
||||
|
||||
const options = readExportOptions();
|
||||
const natural = getSvgSize(svgElement);
|
||||
let content = natural;
|
||||
try {
|
||||
const box = svgElement.getBBox();
|
||||
if (box.width > 0 && box.height > 0) {
|
||||
content = { width: box.width, height: box.height };
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
const padding = 8;
|
||||
const viewBox = options.crop
|
||||
? { width: content.width + padding * 2, height: content.height + padding * 2 }
|
||||
: { width: natural.width, height: natural.height };
|
||||
const size = computeOutputSize(options, viewBox);
|
||||
|
||||
estimateElement.textContent = '预计导出尺寸:≈ ' + size.width + ' × ' + size.height + ' px';
|
||||
}
|
||||
|
||||
function svgToImage(svgText) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const image = new Image();
|
||||
// data: URL 不会污染 canvas,且不需要放宽 img-src(CSP 已允许 data:)。
|
||||
image.onload = function () {
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = function () {
|
||||
reject(new Error('SVG 转图片失败'));
|
||||
};
|
||||
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgText);
|
||||
});
|
||||
}
|
||||
|
||||
async function exportPng() {
|
||||
if (!payload || exporting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const options = readExportOptions();
|
||||
exporting = true;
|
||||
if (exportRunButton) {
|
||||
exportRunButton.disabled = true;
|
||||
exportRunButton.textContent = '导出中…';
|
||||
}
|
||||
|
||||
try {
|
||||
// 导出的重渲染关闭 htmlLabels:<foreignObject> 里的 HTML 标签在 <img> 光栅化时不会绘制。
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
theme: mermaidTheme(),
|
||||
flowchart: { useMaxWidth: false, htmlLabels: false },
|
||||
});
|
||||
|
||||
const result = await mermaid.render('viperFlowChartExport' + ++renderSeq, payload.mermaidText);
|
||||
|
||||
// 离屏测量:只有放进 DOM 才能 getBBox。
|
||||
const holder = document.createElement('div');
|
||||
holder.style.cssText = 'position: absolute; left: -100000px; top: 0;';
|
||||
holder.innerHTML = result.svg;
|
||||
document.body.appendChild(holder);
|
||||
|
||||
try {
|
||||
const svgEl = holder.querySelector('svg');
|
||||
if (!svgEl) {
|
||||
throw new Error('导出用的 SVG 生成失败');
|
||||
}
|
||||
|
||||
const natural = getSvgSize(svgEl);
|
||||
|
||||
let contentBox;
|
||||
try {
|
||||
const box = svgEl.getBBox();
|
||||
contentBox = { x: box.x, y: box.y, width: box.width, height: box.height };
|
||||
} catch (e) {
|
||||
contentBox = { x: 0, y: 0, width: natural.width, height: natural.height };
|
||||
}
|
||||
if (!(contentBox.width > 0) || !(contentBox.height > 0)) {
|
||||
contentBox = { x: 0, y: 0, width: natural.width, height: natural.height };
|
||||
}
|
||||
|
||||
// 裁剪:按内容盒子(留 8px 边距);不裁剪:按 svg 自身尺寸。
|
||||
const padding = 8;
|
||||
const viewBox = options.crop
|
||||
? {
|
||||
x: contentBox.x - padding,
|
||||
y: contentBox.y - padding,
|
||||
width: contentBox.width + padding * 2,
|
||||
height: contentBox.height + padding * 2,
|
||||
}
|
||||
: { x: 0, y: 0, width: natural.width, height: natural.height };
|
||||
|
||||
const size = computeOutputSize(options, viewBox);
|
||||
|
||||
svgEl.setAttribute('viewBox', viewBox.x + ' ' + viewBox.y + ' ' + viewBox.width + ' ' + viewBox.height);
|
||||
svgEl.setAttribute('width', String(size.width));
|
||||
svgEl.setAttribute('height', String(size.height));
|
||||
svgEl.removeAttribute('style');
|
||||
svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
||||
svgEl.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||
|
||||
if (options.whiteBackground) {
|
||||
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
||||
rect.setAttribute('x', String(viewBox.x));
|
||||
rect.setAttribute('y', String(viewBox.y));
|
||||
rect.setAttribute('width', String(viewBox.width));
|
||||
rect.setAttribute('height', String(viewBox.height));
|
||||
rect.setAttribute('fill', '#ffffff');
|
||||
svgEl.insertBefore(rect, svgEl.firstChild);
|
||||
}
|
||||
|
||||
const svgText = new XMLSerializer().serializeToString(svgEl);
|
||||
const image = await svgToImage(svgText);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size.width;
|
||||
canvas.height = size.height;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(image, 0, 0, size.width, size.height);
|
||||
|
||||
if (vscodeApi) {
|
||||
vscodeApi.postMessage({
|
||||
type: 'exportPng',
|
||||
dataUrl: canvas.toDataURL('image/png'),
|
||||
suggestedName: payload.functionName + '.png',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
holder.remove();
|
||||
}
|
||||
} catch (err) {
|
||||
showError('导出 PNG 失败:' + (err && err.message ? err.message : String(err)));
|
||||
} finally {
|
||||
exporting = false;
|
||||
if (exportRunButton) {
|
||||
exportRunButton.disabled = false;
|
||||
exportRunButton.textContent = '导出文件…';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 事件绑定 =====
|
||||
|
||||
function bindClick(id, handler) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.addEventListener('click', handler);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExportPanel(show) {
|
||||
if (!exportPanelElement) {
|
||||
return;
|
||||
}
|
||||
exportPanelElement.style.display = show ? '' : 'none';
|
||||
if (show) {
|
||||
updateEstimate();
|
||||
}
|
||||
}
|
||||
|
||||
bindClick('btn-zoom-in', function () {
|
||||
zoomBy(1.2);
|
||||
});
|
||||
bindClick('btn-zoom-out', function () {
|
||||
zoomBy(1 / 1.2);
|
||||
});
|
||||
bindClick('btn-reset', resetView);
|
||||
bindClick('btn-fit', fitToWidth);
|
||||
bindClick('btn-export-toggle', function () {
|
||||
toggleExportPanel(exportPanelElement && exportPanelElement.style.display === 'none');
|
||||
});
|
||||
bindClick('btn-export-close', function () {
|
||||
toggleExportPanel(false);
|
||||
});
|
||||
bindClick('btn-export-run', function () {
|
||||
void exportPng();
|
||||
});
|
||||
|
||||
['exp-scale', 'exp-width', 'exp-height'].forEach(function (id) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.addEventListener('input', updateEstimate);
|
||||
element.addEventListener('change', updateEstimate);
|
||||
}
|
||||
});
|
||||
['exp-crop', 'exp-bg-transparent', 'exp-bg-white'].forEach(function (id) {
|
||||
const element = document.getElementById(id);
|
||||
if (element) {
|
||||
element.addEventListener('change', updateEstimate);
|
||||
}
|
||||
});
|
||||
|
||||
// 数据由扩展下发;payload 为 null 时显示空态提示。
|
||||
window.addEventListener('message', function (event) {
|
||||
const message = event.data;
|
||||
if (!message || message.type !== 'render') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message.payload) {
|
||||
payload = undefined;
|
||||
showEmpty();
|
||||
return;
|
||||
}
|
||||
|
||||
payload = message.payload;
|
||||
void render(payload);
|
||||
});
|
||||
|
||||
// 主题切换(跟随 VS Code)时重画,否则图会停在旧主题色上。
|
||||
new MutationObserver(function () {
|
||||
if (payload && mermaidTheme() !== lastTheme) {
|
||||
void render(payload);
|
||||
}
|
||||
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// 告诉扩展「脚本已就绪」,扩展收到后再下发数据(避免消息早于脚本加载而丢失)。
|
||||
if (vscodeApi) {
|
||||
vscodeApi.postMessage({ type: 'ready' });
|
||||
}
|
||||
})();
|
||||
Generated
+1096
-4
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "vscode-vipeye",
|
||||
"name": "vipeye",
|
||||
"displayName": "Vipeye",
|
||||
"description": "VS Code language support for Viper (LSP client)",
|
||||
"version": "1.1.413",
|
||||
@@ -24,6 +24,7 @@
|
||||
],
|
||||
"activationEvents": [
|
||||
"onLanguage:viper",
|
||||
"onView:viper.flowChartView",
|
||||
"workspaceContains:**/*.vpj",
|
||||
"workspaceContains:**/*.vp"
|
||||
],
|
||||
@@ -100,6 +101,13 @@
|
||||
"title": "Viper",
|
||||
"icon": "images/viper-icon.svg"
|
||||
}
|
||||
],
|
||||
"panel": [
|
||||
{
|
||||
"id": "viper-flowchart",
|
||||
"title": "Viper 流程图",
|
||||
"icon": "images/viper-icon.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
@@ -116,6 +124,13 @@
|
||||
"id": "viper.settingsView",
|
||||
"name": "Settings"
|
||||
}
|
||||
],
|
||||
"viper-flowchart": [
|
||||
{
|
||||
"id": "viper.flowChartView",
|
||||
"name": "函数流程图",
|
||||
"type": "webview"
|
||||
}
|
||||
]
|
||||
},
|
||||
"viewsWelcome": [
|
||||
@@ -258,6 +273,12 @@
|
||||
"category": "Viper",
|
||||
"icon": "$(refresh)"
|
||||
},
|
||||
{
|
||||
"command": "viper.showFunctionFlowChart",
|
||||
"title": "Show Function Flow Chart",
|
||||
"category": "Viper",
|
||||
"enablement": "editorLangId == viper"
|
||||
},
|
||||
{
|
||||
"command": "viper.openVpjAsJson",
|
||||
"title": "Open .vpj as JSON",
|
||||
@@ -271,6 +292,12 @@
|
||||
"title": "Organize Imports",
|
||||
"group": "Viper",
|
||||
"when": "editorLangId == viper"
|
||||
},
|
||||
{
|
||||
"command": "viper.showFunctionFlowChart",
|
||||
"title": "Show Function Flow Chart",
|
||||
"group": "Viper",
|
||||
"when": "editorLangId == viper"
|
||||
}
|
||||
],
|
||||
"commandPalette": [
|
||||
@@ -294,6 +321,10 @@
|
||||
"command": "viper.dumpCodeFlowGraph",
|
||||
"when": "editorLangId == viper && viper.development"
|
||||
},
|
||||
{
|
||||
"command": "viper.showFunctionFlowChart",
|
||||
"when": "editorLangId == viper"
|
||||
},
|
||||
{
|
||||
"command": "viper.openVpjAsJson",
|
||||
"when": "resourceExtname == .vpj"
|
||||
@@ -321,6 +352,14 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "viper.showFunctionFlowChart",
|
||||
"key": "ctrl+alt+g",
|
||||
"mac": "cmd+alt+g",
|
||||
"when": "editorLangId == viper && editorTextFocus"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"type": "object",
|
||||
"title": "Vipeye",
|
||||
@@ -1811,6 +1850,7 @@
|
||||
"webpack-dev": "npm run clean && rspack build -c rspack.config.js --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"mermaid": "^11.12.0",
|
||||
"vscode-jsonrpc": "^9.0.0-next.8",
|
||||
"vscode-languageclient": "^10.0.0-next.16",
|
||||
"vscode-languageserver": "^10.0.0-next.13",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { createRequire } = require('module');
|
||||
const { monorepoResourceNameMapper, tsconfigResolveAliases } = require('../../build/lib/webpack');
|
||||
|
||||
@@ -8,6 +9,42 @@ const typeshedFallback = path.resolve(__dirname, '..', 'vipeye-internal', 'types
|
||||
const vipershedFallback = path.resolve(__dirname, '..', 'vipeye-internal', 'vipershed-fallback');
|
||||
const viperCore = path.resolve(__dirname, '..', 'viper');
|
||||
|
||||
/**
|
||||
* 找 mermaid 的预构建 UMD 产物(暴露全局 mermaid),供「函数流程图」Webview 用 <script src> 加载。
|
||||
* 刻意用 node_modules 里的成品而不是自己打包:mermaid 内部有动态 import 的 diagram chunk,
|
||||
* 自行打包会引入额外 chunk 与资源路径问题。
|
||||
*
|
||||
* 不用 require.resolve('mermaid/dist/...'):包的 package.json 若声明了 exports,该子路径可能未被
|
||||
* 导出,会误报 MODULE_NOT_FOUND。这里只按目录找文件,覆盖「本包内安装」与「仓库根提升安装」两种情况。
|
||||
*/
|
||||
function findMermaidBundle() {
|
||||
const packageRoots = [
|
||||
path.join(__dirname, 'node_modules', 'mermaid'),
|
||||
path.join(__dirname, '..', '..', 'node_modules', 'mermaid'),
|
||||
];
|
||||
const bundleNames = ['mermaid.min.js', 'mermaid.js'];
|
||||
const tried = [];
|
||||
|
||||
for (const root of packageRoots) {
|
||||
if (!fs.existsSync(root)) {
|
||||
continue;
|
||||
}
|
||||
for (const name of bundleNames) {
|
||||
const candidate = path.join(root, 'dist', name);
|
||||
tried.push(candidate);
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'找不到 mermaid 的预构建产物(函数跳转图的 Webview 需要它)。\n' +
|
||||
'请先安装依赖:在 packages/vscode-vipeye(或仓库根)执行 npm install。\n' +
|
||||
`已尝试以下路径:\n ${tried.join('\n ')}`
|
||||
);
|
||||
}
|
||||
|
||||
/** @type {(env: any, argv: { mode: 'production' | 'development' | 'none' }) => any} */
|
||||
module.exports = (_, { mode }) => {
|
||||
return {
|
||||
@@ -65,6 +102,10 @@ module.exports = (_, { mode }) => {
|
||||
// 源码不复制,仅构建产物引用。
|
||||
{ from: path.join(viperCore, 'images', 'vpj-icon.svg'), to: 'images/vpj-icon.svg' },
|
||||
{ from: path.join(viperCore, 'syntaxes', 'vpj.tmLanguage.json'), to: 'syntaxes/vpj.tmLanguage.json' },
|
||||
// mermaid 的预构建产物,见 findMermaidBundle。
|
||||
{ from: findMermaidBundle(), to: 'media/mermaid.min.js' },
|
||||
// 「函数流程图」Webview 的渲染脚本(外部脚本,避免内联脚本受 CSP nonce 限制)。
|
||||
{ from: path.join(__dirname, 'media', 'flowChartWebview.js'), to: 'media/flowChartWebview.js' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -39,11 +39,15 @@ import {
|
||||
import { Commands } from 'vipeye-internal/commands/commands';
|
||||
import { isThenable } from 'vipeye-internal/common/core';
|
||||
|
||||
// 仅用类型:type-only import 打包时会被擦除,不会把服务端代码带进客户端。
|
||||
import type { FlowChartResponse } from 'vipeye-internal/commands/flowChartCommand';
|
||||
|
||||
// Viper 核心(packages/viper)——侧边栏活动栏(Project/Toolchain/Settings)、
|
||||
// .vpj 项目管理与工具链。源码单一,本包直接引用,构建时一并打包。
|
||||
import { activate as activateViperCore } from '../../viper/src/extension';
|
||||
|
||||
import { FileBasedCancellationStrategy } from './cancellationUtils';
|
||||
import { registerFlowChartView } from './flowChartView';
|
||||
|
||||
let cancellationStrategy: FileBasedCancellationStrategy | undefined;
|
||||
|
||||
@@ -290,6 +294,33 @@ export async function activate(context: ExtensionContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// 「函数跳转图」停靠面板:服务端算 Mermaid(AST → 控制流图),这里只做展示与跳转。
|
||||
const flowChartProvider = registerFlowChartView(context, context.extensionUri);
|
||||
context.subscriptions.push(
|
||||
commands.registerTextEditorCommand(Commands.showFunctionFlowChart, async (editor) => {
|
||||
const documentUri = editor.document.uri;
|
||||
const offset = editor.document.offsetAt(editor.selection.active);
|
||||
|
||||
try {
|
||||
const chart = await client.sendRequest<FlowChartResponse | undefined>('workspace/executeCommand', {
|
||||
command: Commands.showFunctionFlowChart,
|
||||
arguments: [documentUri.toString(), offset],
|
||||
});
|
||||
|
||||
if (!chart) {
|
||||
void window.showWarningMessage('Viper:当前光标不在任何函数内,无法生成跳转图。');
|
||||
return;
|
||||
}
|
||||
|
||||
await flowChartProvider.showChart(chart, documentUri);
|
||||
} catch (err) {
|
||||
void window.showErrorMessage(
|
||||
`Viper:生成函数跳转图失败 —— ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
await client.start();
|
||||
|
||||
// 导出 Viper 核心 API(合并后随本扩展一起提供)。
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
* flowChartView.ts
|
||||
*
|
||||
* 「函数流程图」停靠面板(WebviewView)的客户端实现。
|
||||
*
|
||||
* 职责边界:
|
||||
* - 服务端(vipeye-internal 的 flowChartCommand)用 AST 算出 Mermaid 文本与「节点 → 行号」映射;
|
||||
* - 本文件负责把数据通过 postMessage 下发给 Webview、把「点击节点」转成跳转源码行、
|
||||
* 并把 Webview 导出的 PNG 落盘。
|
||||
*
|
||||
* 面板形态:注册在底部/侧边的停靠面板容器 `viper-flowchart` 里(VS Code 没有公开 API 能在编辑器
|
||||
* 上层叠浮层,停靠面板是公开 API 中最接近 hover 嵌入感的形式)。
|
||||
*
|
||||
* 渲染脚本刻意放在 media/flowChartWebview.js(以 <script src> 外部文件加载,不用内联脚本 + nonce):
|
||||
* 外部脚本只需要 CSP 允许 `webview.cspSource`,不依赖 nonce,也不会因 hash/nonce 不匹配而被静默拦掉。
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
commands,
|
||||
ExtensionContext,
|
||||
Range,
|
||||
Selection,
|
||||
TextEditorRevealType,
|
||||
Uri,
|
||||
ViewColumn,
|
||||
WebviewView,
|
||||
WebviewViewProvider,
|
||||
window,
|
||||
workspace,
|
||||
} from 'vscode';
|
||||
|
||||
// 仅用类型:type-only import 在打包时会被完全擦除,不会把服务端代码带进客户端。
|
||||
import type { FlowChartResponse } from 'vipeye-internal/commands/flowChartCommand';
|
||||
|
||||
/** Webview → 扩展的消息:点击节点跳转。 */
|
||||
interface JumpToLineMessage {
|
||||
type: 'jump';
|
||||
/** 目标行号(1 起)。 */
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** Webview → 扩展的消息:渲染脚本已加载完成,可以下发数据。 */
|
||||
interface ReadyMessage {
|
||||
type: 'ready';
|
||||
}
|
||||
|
||||
/** Webview → 扩展的消息:导出 PNG(dataUrl 形如 data:image/png;base64,...)。 */
|
||||
interface ExportPngMessage {
|
||||
type: 'exportPng';
|
||||
dataUrl: string;
|
||||
suggestedName: string;
|
||||
}
|
||||
|
||||
type WebviewMessage = JumpToLineMessage | ReadyMessage | ExportPngMessage;
|
||||
|
||||
export class FlowChartViewProvider implements WebviewViewProvider {
|
||||
static readonly viewType = 'viper.flowChartView';
|
||||
|
||||
private _view: WebviewView | undefined;
|
||||
/** 最近一次生成图时所在文档;点击节点与导出 PNG 都以它为基准。 */
|
||||
private _documentUri: Uri | undefined;
|
||||
/** 最近一次的图数据。面板尚未创建时先存这里,等 ready 后立即下发。 */
|
||||
private _chart: FlowChartResponse | undefined;
|
||||
/** Webview 侧脚本是否已就绪(未就绪时先不下发数据,避免消息丢失)。 */
|
||||
private _scriptReady = false;
|
||||
|
||||
constructor(private _extensionUri: Uri) {}
|
||||
|
||||
resolveWebviewView(webviewView: WebviewView): void {
|
||||
this._view = webviewView;
|
||||
this._scriptReady = false;
|
||||
|
||||
webviewView.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [Uri.joinPath(this._extensionUri, 'dist')],
|
||||
};
|
||||
|
||||
webviewView.webview.onDidReceiveMessage((message: WebviewMessage) => {
|
||||
if (message?.type === 'ready') {
|
||||
this._scriptReady = true;
|
||||
void this._postPayload();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === 'jump' && typeof message.line === 'number') {
|
||||
void this._revealLine(message.line);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.type === 'exportPng') {
|
||||
void this._saveExportedPng(message);
|
||||
}
|
||||
});
|
||||
|
||||
// 视图被销毁(隐藏后 VS Code 回收 webview)时清空引用,
|
||||
// 避免后续 showChart 往已销毁的 webview 上写 html。
|
||||
webviewView.onDidDispose(() => {
|
||||
if (this._view === webviewView) {
|
||||
this._view = undefined;
|
||||
this._scriptReady = false;
|
||||
}
|
||||
});
|
||||
|
||||
this._render();
|
||||
}
|
||||
|
||||
/**
|
||||
* 展示某函数的流程图。
|
||||
* 面板未创建时(首次按快捷键)先用 `.focus` 命令把面板显示出来,
|
||||
* 等 resolveWebviewView + ready 之后会自动下发已缓存的数据。
|
||||
*/
|
||||
async showChart(chart: FlowChartResponse, documentUri: Uri): Promise<void> {
|
||||
this._chart = chart;
|
||||
this._documentUri = documentUri;
|
||||
|
||||
if (!this._view) {
|
||||
// 面板还没创建:先把面板显示出来,resolveWebviewView + ready 后会收到数据。
|
||||
await commands.executeCommand(`${FlowChartViewProvider.viewType}.focus`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this._postPayload();
|
||||
|
||||
// 刷新时保留编辑器焦点,避免连续看图时不停抢焦点。
|
||||
this._view.show?.(true);
|
||||
}
|
||||
|
||||
/** 把当前图数据下发给 Webview(脚本未就绪时先不发,等 ready 消息回调)。 */
|
||||
private async _postPayload(): Promise<void> {
|
||||
if (!this._view || !this._scriptReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this._view.webview.postMessage({ type: 'render', payload: this._chart ?? null });
|
||||
}
|
||||
|
||||
/** 点击图节点后跳转到源码行。 */
|
||||
private async _revealLine(line: number): Promise<void> {
|
||||
const uri = this._documentUri;
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = new Range(line - 1, 0, line - 1, 0);
|
||||
|
||||
// 文档已打开时直接复用它的编辑器(含光标定位),不新开标签页。
|
||||
const openedEditor = window.visibleTextEditors.find(
|
||||
(editor) => editor.document.uri.toString() === uri.toString()
|
||||
);
|
||||
if (openedEditor) {
|
||||
openedEditor.selection = new Selection(target.start, target.start);
|
||||
openedEditor.revealRange(target, TextEditorRevealType.InCenter);
|
||||
return;
|
||||
}
|
||||
|
||||
const document = await workspace.openTextDocument(uri);
|
||||
await window.showTextDocument(document, { selection: target, viewColumn: ViewColumn.One });
|
||||
}
|
||||
|
||||
/** 保存 Webview 导出的 PNG:弹保存对话框,默认落在当前 .vp 文档同目录、文件名用函数名。 */
|
||||
private async _saveExportedPng(message: ExportPngMessage): Promise<void> {
|
||||
const base64 = message.dataUrl.replace(/^data:image\/png;base64,/, '');
|
||||
const documentUri = this._documentUri;
|
||||
const defaultUri = documentUri
|
||||
? Uri.file(path.join(path.dirname(documentUri.fsPath), message.suggestedName))
|
||||
: Uri.file(message.suggestedName);
|
||||
|
||||
const target = await window.showSaveDialog({
|
||||
defaultUri,
|
||||
filters: { 'PNG 图片': ['png'] },
|
||||
saveLabel: '导出',
|
||||
});
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
await workspace.fs.writeFile(target, Buffer.from(base64, 'base64'));
|
||||
void window.showInformationMessage(`已导出流程图:${target.fsPath}`);
|
||||
}
|
||||
|
||||
/** 写入 Webview 的静态骨架(数据随后用 postMessage 下发)。 */
|
||||
private _render(): void {
|
||||
if (!this._view) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 重写 html 会让 webview 重新加载脚本,故就绪标记要重置,等新的 ready 消息。
|
||||
this._scriptReady = false;
|
||||
this._view.webview.html = this._buildHtml(this._view.webview);
|
||||
}
|
||||
|
||||
private _buildHtml(webview: WebviewView['webview']): string {
|
||||
const mermaidUri = webview.asWebviewUri(
|
||||
Uri.joinPath(this._extensionUri, 'dist', 'media', 'mermaid.min.js')
|
||||
);
|
||||
const scriptUri = webview.asWebviewUri(
|
||||
Uri.joinPath(this._extensionUri, 'dist', 'media', 'flowChartWebview.js')
|
||||
);
|
||||
|
||||
// 脚本均为外部文件,故 script-src 只需允许 webview 资源域,无需 nonce。
|
||||
const contentSecurityPolicy = [
|
||||
`default-src 'none'`,
|
||||
`img-src ${webview.cspSource} data:`,
|
||||
`font-src ${webview.cspSource}`,
|
||||
// mermaid 渲染出的 SVG 带内联 style,必须允许 inline 样式。
|
||||
`style-src ${webview.cspSource} 'unsafe-inline'`,
|
||||
`script-src ${webview.cspSource}`,
|
||||
].join('; ');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="${contentSecurityPolicy}" />
|
||||
<title>函数流程图</title>
|
||||
<style>
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-panel-background, transparent);
|
||||
font-family: var(--vscode-font-family);
|
||||
font-size: var(--vscode-font-size);
|
||||
}
|
||||
#toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
|
||||
}
|
||||
#title {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#hint {
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#zoom-label {
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
button {
|
||||
background: var(--vscode-button-secondaryBackground, rgba(128, 128, 128, 0.25));
|
||||
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
button:hover {
|
||||
background: var(--vscode-button-secondaryHoverBackground, rgba(128, 128, 128, 0.4));
|
||||
}
|
||||
button.primary {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
button.primary:hover {
|
||||
background: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
button[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
button.link {
|
||||
background: transparent;
|
||||
padding: 2px 4px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
#export-panel {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 10px 10px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.35));
|
||||
background: var(--vscode-editorWidget-background, rgba(128, 128, 128, 0.06));
|
||||
}
|
||||
.exp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.exp-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.exp-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
gap: 6px 16px;
|
||||
}
|
||||
.exp-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.exp-estimate {
|
||||
opacity: 0.75;
|
||||
}
|
||||
input[type='number'] {
|
||||
width: 76px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
border: 1px solid var(--vscode-input-border, rgba(128, 128, 128, 0.4));
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
select {
|
||||
background: var(--vscode-dropdown-background);
|
||||
color: var(--vscode-dropdown-foreground);
|
||||
border: 1px solid var(--vscode-dropdown-border, rgba(128, 128, 128, 0.4));
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
#chart {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
#chart-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
#empty {
|
||||
padding: 16px;
|
||||
opacity: 0.8;
|
||||
line-height: 1.7;
|
||||
}
|
||||
kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.5));
|
||||
border-radius: 3px;
|
||||
font-family: var(--vscode-editor-font-family, monospace);
|
||||
}
|
||||
#error {
|
||||
padding: 16px;
|
||||
color: var(--vscode-errorForeground);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="toolbar">
|
||||
<span id="title"></span>
|
||||
<span id="hint" style="display: none;">点击节点可跳转到源码</span>
|
||||
<button id="btn-zoom-out" title="缩小">−</button>
|
||||
<span id="zoom-label">100%</span>
|
||||
<button id="btn-zoom-in" title="放大">+</button>
|
||||
<button id="btn-fit" title="缩放到适应宽度">适应宽度</button>
|
||||
<button id="btn-reset" title="恢复 100% 并回到原点">重置</button>
|
||||
<button id="btn-export-toggle" title="展开导出设置">导出 PNG</button>
|
||||
</div>
|
||||
<div id="export-panel" style="display: none;">
|
||||
<div class="exp-row">
|
||||
<span class="exp-title">导出设置</span>
|
||||
<button id="btn-export-close" class="link" title="收起设置">收起 ▲</button>
|
||||
</div>
|
||||
<div class="exp-grid">
|
||||
<label class="exp-field">
|
||||
缩放倍率
|
||||
<select id="exp-scale">
|
||||
<option value="1">1x</option>
|
||||
<option value="2" selected>2x</option>
|
||||
<option value="3">3x</option>
|
||||
<option value="4">4x</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="exp-field">宽度 <input id="exp-width" type="number" min="1" placeholder="自动" /> px</label>
|
||||
<label class="exp-field">高度 <input id="exp-height" type="number" min="1" placeholder="自动" /> px</label>
|
||||
<label class="exp-field"><input id="exp-crop" type="checkbox" checked /> 裁掉空白边距</label>
|
||||
<label class="exp-field">
|
||||
<input id="exp-bg-transparent" type="radio" name="exp-bg" value="transparent" checked /> 透明背景
|
||||
</label>
|
||||
<label class="exp-field">
|
||||
<input id="exp-bg-white" type="radio" name="exp-bg" value="white" /> 白色背景
|
||||
</label>
|
||||
</div>
|
||||
<div class="exp-row">
|
||||
<span id="exp-estimate" class="exp-estimate">预计导出尺寸:—</span>
|
||||
<button id="btn-export-run" class="primary">导出文件…</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart">
|
||||
<div id="chart-inner"></div>
|
||||
<div id="empty">
|
||||
把光标放到函数内,按 <kbd>Ctrl+Alt+G</kbd>(macOS: <kbd>Cmd+Alt+G</kbd>)生成该函数的跳转图。<br />
|
||||
支持 if / elif / else、for / for-else、while / while-else、match-case、try / except / finally。<br />
|
||||
图内可拖拽平移(指针锁定,可无限拖)、滚轮缩放;点击节点跳转到对应源码行。
|
||||
</div>
|
||||
<div id="error" style="display: none;"></div>
|
||||
</div>
|
||||
<script src="${mermaidUri}"></script>
|
||||
<script src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 注册面板 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;
|
||||
}
|
||||
Reference in new issue
Block a user