diff --git a/docs/prototype/code-reader/figma-plugin/README.md b/docs/prototype/code-reader/figma-plugin/README.md index 0112373..77b9d9c 100644 --- a/docs/prototype/code-reader/figma-plugin/README.md +++ b/docs/prototype/code-reader/figma-plugin/README.md @@ -27,7 +27,7 @@ | `Prototype / 枚举` | luffy 的 `referral/models.py` 里的 `CommissionStatus` | 枚举卡片:名称 / 访问级别 / 继承,成员收成一张表(成员 / 值 / 说明),说明取行尾注释;Python 用库规则(导入了 enum 才启用),TypeScript 的 enum 由语言插件报结构,画成同一张卡片 | | `Prototype / 库规则 · Celery` | luffy 的 `backend/src/luffy_agent/celery_app.py` | 库规则(导入了 celery 才启用)两张卡片:「Celery 应用」名称 / 应用名 / 消息队列 / 结果存储;「定时任务」应用 + 任务表(任务 / 频率 / 说明),频率换算成「每 5 分钟」「每天」,说明取行尾注释,`**` 展开的另起一行 | | `Prototype / 库规则 · FastAPI 接口` | luffy 的 `user/routes.py` 里的 `router` 和 `update_user` | 路由器(`APIRouter(...)`)和接口是两张卡片。「路由器」卡片:名称 / 前缀 / 标签(写了依赖、默认响应再加行),接口表列这个文件里挂在它上面的全部接口(方法 / 完整路径 / 函数 / 说明),函数名 Cmd+单击跳到接口卡片。库规则(导入了 fastapi 才启用)把挂在路由对象上的函数整块换成「接口」卡片,函数体照常是子块:名称 / 说明 / 路由器 / 标签 / 异步 / 依赖(`Depends(...)` 注入的参数),然后两张表。请求表一项一行:URL(路由对象的 prefix + 装饰器里的路径)/ 方法 / 路径参数 / 查询参数 / 请求体,没有的项不列。响应表每个状态码一行(状态码 / 内容 / 说明):成功那行来自 `status_code` 和 `response_model`,`responses={…}` 里写的各占一行,函数体里直接抛的异常按异常类上的 `status_code` 归行、说明列出抛出时的消息,有参数时 FastAPI 自动带的 422 淡色。挂到应用上时再叠的前缀(`/api/v1`)在别的文件里,不算 | -| `Prototype / 调用 · 构造`(`routes.py · 预览` 的块树里也是这样) | routes.py 的 `return UserDetailResponse(...)`、`user = await user_service.update_user(...)`、`avatar_url = await user_service.upload_avatar(...)` | 跨了多行写的调用把实参摊成一张表(参数 / 值,没名字的写「第 N 个」):返回里写「返回 新建 UserDetailResponse · 11 个参数」,变量的「初始值」写「等待 调用 user_service.update_user · 10 个参数」。被调用的名字是类就是「新建」,是函数就是「调用」(插件查定义,原型按首字母大写);一行写完的调用照旧一行 | +| `Prototype / 调用 · 构造`(`routes.py · 预览` 的块树里也是这样) | routes.py 的 `return UserDetailResponse(...)`、`user = await user_service.update_user(...)`、`avatar_url = await user_service.upload_avatar(...)` | 跨了多行写的调用把实参摊成一张表(参数 / 值,没名字的写「第 N 个」):返回里写「返回 新建 UserDetailResponse · 11 个参数」,变量的「初始值」写「等待 调用 user_service.update_user · 10 个参数」。被调用的名字是类就是「新建」,是函数就是「调用」(插件查定义,原型按首字母大写);一行写完的调用照旧一行。第三块是嵌套:luffy `commerce/payments/console/routes.py` 的 `return PaymentCapabilityDiagnostic(...)`(15 个参数),`credentials_present` 的值又是跨多行的 `CredentialPresence(...)`,值格里再写「新建 CredentialPresence · 4 个参数」和它的实参表,再套也一样;实参里一行写完的调用、跨行的 `and` 表达式照旧是一行字 | | `Prototype / 块种类图标` | — | 每种块一个图标、颜色、名称的图例,库规则带的种类后面淡色写规则名;最上面一块「返回」停着鼠标、图标下面弹出名称提示 | | `Prototype / 遍历 · 推导式` | luffy 邀请码工具的 `"".join(secrets.choice(ALPHABET) for _ in range(length))`,外加带条件的列表、字典推导 | 写在表达式里的遍历原地套一个带循环图标和结果类型(生成器 / 列表 / 字典)的小框,框里按读的顺序重排:「遍历 range(length) 的每个 _,仅当 …,取 …」 | | `Prototype / 侧栏 · 复制菜单` | 同上 | 悬停行行尾第一个按钮「复制」(带小箭头),点开一组复制方式:文件名 / 文件名(不带后缀)/ 相对路径 / 绝对路径,右边淡色预览会复制出什么;文件夹没有「不带后缀」 | diff --git a/docs/prototype/code-reader/figma-plugin/code.js b/docs/prototype/code-reader/figma-plugin/code.js index aca7bb7..c2e10ee 100644 --- a/docs/prototype/code-reader/figma-plugin/code.js +++ b/docs/prototype/code-reader/figma-plugin/code.js @@ -998,20 +998,49 @@ function setText(root, name, characters) { // 调用在变量的初始值里,表放在「初始值」下面;在返回、语句里,块里先写一行「返回 新建 X」,下面是表。 // 没名字的实参按位置写「第 N 个」;值照常译,实参里嵌套的调用不再展开。 const CALL_ARG_KEY_W = 180; +// 嵌套示例:luffy 的 backend/src/luffy_agent/commerce/payments/console/routes.py,credentials_present 的值又是一个跨多行的构造 +const NESTED_CALL_SAMPLE = [ + "PaymentCapabilityDiagnostic(", + " native_enabled=bool(settings.WECHAT_PAY_MCH_ID),", + " h5_enabled=settings.WECHAT_H5_ENABLED,", + " jsapi_enabled=settings.WECHAT_JSAPI_ENABLED,", + " jsapi_ready=jsapi_ready(settings),", + " pay_app_id_masked=_mask_app_id(settings.WECHAT_PAY_APP_ID),", + " mp_app_id_masked=_mask_app_id(settings.WECHAT_MP_APP_ID),", + " credentials_present=CredentialPresence(", + " mp_app_secret=bool(_secret_text(settings.WECHAT_MP_APP_SECRET)),", + " wechat_pay_api_v3_key=bool(settings.WECHAT_PAY_API_V3_KEY),", + " wechat_pay_private_key=bool(private_key_path)", + " and Path(private_key_path).is_file(),", + " jsapi_intent_master_key=_intent_key_present(settings),", + " ),", + " oauth_callback_host=oauth_host,", + " pay_origin=pay_origin,", + " allowed_pay_origins=allowed,", + " jsapi_auth_dir_hint=[f\"{origin}/\" for origin in hint_origins if origin],", + " domain_whitelist=\"unknown_manual_check\",", + " merchant_binding=\"unknown_manual_check\",", + " recovery_required_count=count,", + " recovery_required_earliest=earliest,", + ")", +].join("\n"); /** 跨多行的调用拆出被调用的名字、有没有 await、各个实参;不是跨多行的调用返回 null。 */ function parseCall(code) { if (!code.includes("\n")) return null; - const match = /^(await\s+)?([\w.]+)\(([\s\S]*)\)$/.exec(code.trim()); - if (!match) return null; + const text = code.trim(); + const match = /^(await\s+)?([\w.]+)\(([\s\S]*)\)$/.exec(text); + // 开头那个括号要一直括到末尾:`bool(x)\n and Path(x).is_file()` 这种是式子,不是一次调用 + if (!match || matchingParen(text, text.indexOf("(")) !== text.length - 1) return null; const [, awaited, callee, inner] = match; const cuts = [-1, ...topLevelIndexes(inner, ","), inner.length]; let position = 0; + // 每个实参:[名字, 一行的值, 值本身又是跨多行的调用时拆好的那个调用] const args = cuts.slice(1).map((end, index) => inner.slice(cuts[index] + 1, end).trim()).filter(Boolean).map((part) => { const keyword = /^(\w+)\s*=\s*([\s\S]+)$/.exec(part); - if (keyword) return [keyword[1], keyword[2].replace(/\s+/g, " ")]; - position += 1; - return [`第 ${position} 个`, part.replace(/\s+/g, " ")]; + const raw = keyword ? keyword[2] : part; + if (!keyword) position += 1; + return [keyword ? keyword[1] : `第 ${position} 个`, raw.replace(/\s+/g, " "), parseCall(raw)]; }); return { awaited: Boolean(awaited), callee, construct: /^[A-Z]/.test(callee.split(".").pop()), args }; } @@ -1058,7 +1087,11 @@ function argsTable(ctx, parent, args) { const header = ctx.lib.argRow.get("header").createInstance(); header.name = "Header"; append(table, header, true); - for (const [name, value] of args) { + for (const [name, value, nested] of args) { + if (nested) { + nestedArgRow(ctx, table, name, nested); + continue; + } const row = ctx.lib.argRow.get("row").createInstance(); row.name = `Arg / ${name}`; append(table, row, true); @@ -1073,6 +1106,23 @@ function argsTable(ctx, parent, args) { } } +/** 值本身是跨多行调用的那一行:参数格照旧,值格里先写这个调用的头一行,下面是它自己的实参表,再套也一样。 */ +function nestedArgRow(ctx, table, name, call) { + const row = filled(ctx, table, `Arg / ${name}`, { dir: "HORIZONTAL" }); + row.strokes = paint(COLORS.borderStrong); + row.strokeTopWeight = 1; + row.strokeBottomWeight = row.strokeLeftWeight = row.strokeRightWeight = 0; + const key = append(row, frame(ctx, "参数", { width: CALL_ARG_KEY_W, px: 8, py: 3 })); + key.layoutSizingVertical = "FILL"; + key.strokes = paint(COLORS.borderStrong); + key.strokeRightWeight = 1; + key.strokeTopWeight = key.strokeBottomWeight = key.strokeLeftWeight = 0; + richText(ctx, key, "Name", [{ text: name, color: name.startsWith("第 ") ? COLORS.desc : COLORS.tokenVariable }]); + const value = filled(ctx, row, "值", { px: 8, py: 4, gap: 4 }); + richText(ctx, value, "Head", callHead(call)); + argsTable(ctx, value, call.args); +} + /** Reader / CallBlock — Call=<被调用的名字>:返回、语句里跨多行的调用,块里一行「返回 新建 X」,下面是实参表。 */ function callBlock(ctx, kind, call, name) { const { node, content } = cardShell(ctx, name, BLOCK_KINDS[kind], { kids: false }); @@ -2137,11 +2187,13 @@ function createComponents(ctx, page) { if (call) calls.set(code, { kind: item.kind, call, at: [...(calls.has(code) ? calls.get(code).at : []), `${fileKey}:${lineRange(item.at)[0]}`] }); }); } + calls.set(NESTED_CALL_SAMPLE, { kind: "return", call: parseCall(NESTED_CALL_SAMPLE), at: [] }); const callBlocks = make("Reader / CallBlock", [...calls.keys()], 1, (code) => { const entry = calls.get(code); return callBlock(ctx, entry.kind, entry.call, `Call=${entry.call.callee}`); }, (item) => item.node); lib.callBlockAt = new Map([...calls].flatMap(([code, entry]) => entry.at.map((key) => [key, callBlocks.get(code)]))); + lib.nestedCall = callBlocks.get(NESTED_CALL_SAMPLE); lib.tree = make("Reader / BlockTree", fileKeys, 2, (fileKey) => blockTree(ctx, lib, fileKey), (item) => item.node); return { lib, sets }; } @@ -2506,7 +2558,8 @@ async function build(api, stage = () => {}) { // 调用与构造:块树里这几块在画板下方被截掉,单独摆出来看 const callBoard = frame(ctx, "Prototype / 调用 · 构造", { width: TREE_W + 2 * VIEW_PAD, pad: VIEW_PAD, gap: 8, fill: COLORS.editor }); page.appendChild(callBoard); - for (const [key, component] of [["routes:83", lib.card.get("routes:83").get("preview")], ["routes:45", lib.callBlockAt.get("routes:45")]]) { + for (const [key, component] of [["routes:83", lib.card.get("routes:83").get("preview")], ["routes:45", lib.callBlockAt.get("routes:45")], + ["嵌套", lib.nestedCall]]) { const instance = component.node.createInstance(); instance.name = `Block / ${key}`; append(callBoard, instance, true); @@ -2630,6 +2683,6 @@ async function run(api) { } if (typeof module !== "undefined") { - module.exports = { parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments }; + module.exports = { NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, blockAttributes, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, parseImport, parseVariable, translateCode, build, run, nextPageName, PAGE_NAME, NAMESPACE, TREE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, typeSegments }; } if (typeof figma !== "undefined") void run(figma); diff --git a/docs/prototype/code-reader/figma-plugin/test.js b/docs/prototype/code-reader/figma-plugin/test.js index 36f3721..7bcfc5a 100644 --- a/docs/prototype/code-reader/figma-plugin/test.js +++ b/docs/prototype/code-reader/figma-plugin/test.js @@ -2,7 +2,7 @@ const assert = require("node:assert/strict"); const { test } = require("node:test"); const fs = require("node:fs"); const path = require("node:path"); -const { parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js"); +const { NESTED_CALL_SAMPLE, parseCall, ROUTER_SAMPLE, ROUTE_SAMPLE, REVIEW, REVIEW_ISSUES, ASK_SAMPLE, BEAT_SCHEDULE, ENUM_SAMPLE, SQLA_MODEL, ITERATION_SAMPLES, iterationSegments, facetCounts, filteredOut, filterPresets, FILTER_FILE, NOTES, NOTE_DRAFT, PLAIN_KINDS, build, run, nextPageName, PAGE_NAME, NAMESPACE, WORKSPACES, FILES, CODE_TREE, BLOCK_KINDS, SOURCES, blockCode, lineRange, walkTree, translateBlock, highlightCode, parseSignature, parseImport, parseVariable, typeSegments } = require("./code.js"); const { fakeFigma } = require("../../figma-lab/fake-figma"); function action(node) { return node.reactions[0].actions[0]; } @@ -88,7 +88,7 @@ const EXPECTED_SETS = [ ["Function / upload_current_user_avatar", 2], ["Variable / payload", 2], ["Variable / avatar_url", 2], - ["Reader / CallBlock", 1], + ["Reader / CallBlock", 2], ["Reader / BlockTree", 3], ]; @@ -487,7 +487,7 @@ test("calls written over several lines spread their arguments into a table; one- const update = parseCall(SOURCES.routes.slice(82, 94).join("\n").replace(/^\s*user = /, "")); assert.equal(update.awaited, true); assert.equal(update.construct, false); - assert.deepEqual(update.args.slice(0, 3), [["第 1 个", "db"], ["user_id", "user_id"], ["display_name", "body.display_name"]]); + assert.deepEqual(update.args.slice(0, 3).map(([name, value]) => [name, value]), [["第 1 个", "db"], ["user_id", "user_id"], ["display_name", "body.display_name"]]); assert.equal(update.args.length, 10); assert.equal(parseCall("await user_service.get_user(db, user_id=user_id)"), null, "one line stays a line"); @@ -506,6 +506,17 @@ test("calls written over several lines spread their arguments into a table; one- assert.equal(named(named(variable, "Prop / 初始值"), "Value").characters, "等待 调用 user_service.update_user · 10 个参数"); assert.equal(named(named(variable, "Arg / 第 1 个"), "Value").characters, "db"); assert.ok(!named(lib.card.get("routes:43").get("preview").node, "Args"), "one-line call keeps its one-line value"); + // 嵌套:实参的值本身是跨多行的调用,值格里再放它的头一行和实参表;一行写完的实参照旧一行 + const nested = parseCall(NESTED_CALL_SAMPLE); + assert.equal(nested.args.length, 15); + const inner = nested.args.find(([name]) => name === "credentials_present")[2]; + assert.equal(inner.callee, "CredentialPresence"); + assert.equal(inner.args.length, 4); + assert.equal(nested.args.find(([name]) => name === "jsapi_ready")[2], null); + assert.equal(inner.args.find(([name]) => name === "wechat_pay_private_key")[2], null, "a multi-line `and` expression is not a call"); + const row = named(lib.nestedCall.node, "Arg / credentials_present"); + assert.equal(named(row, "Head").characters, "新建 CredentialPresence · 4 个参数"); + assert.equal(row.findAll((node) => node.name.startsWith("Arg / ")).length, 4); }); test("block kinds show as icons, the legend lists every kind with its name", async () => {