Compare commits

...
11 Commits
42 changed files with 17748 additions and 84 deletions

No files matched your search

+660
View File
@@ -0,0 +1,660 @@
// 示例是手写的短 Rust,不是仓库里的文件。名字照桌面端和接口服务:AgentRow、start_server。
// 库只覆盖 serde、thiserror、axum、sqlx、tauri。tokio 不单独成卡,异步写在函数上。
// 颜色取自代码阅读原型里的 VS Code Dark Modern。
const C = {
editor: "#1f1f1f",
side: "#181818",
border: "#2b2b2b",
borderStrong: "#3c3c3c",
fg: "#cccccc",
fgStrong: "#e8e8e8",
desc: "#9d9d9d",
dim: "#6e7681",
activityIdle: "#868686",
activityOn: "#d7d7d7",
focus: "#0078d4",
selection: "#04395e",
input: "#313131",
field: "#111111",
toggleOn: "#37373d",
comment: "#6a9955",
control: "#c586c0",
storage: "#569cd6",
variable: "#9cdcfe",
fn: "#dcdcaa",
type: "#4ec9b0",
string: "#ce9178",
folder: "#8b9ba8",
rust: "#dea584",
toml: "#c0754a",
added: "#2ea043",
get: "#61affe",
};
const KIND = {
use: { label: "导入", color: "#c586c0", icon: "import" },
struct: { label: "结构体", color: "#4ec9b0", icon: "braces" },
enum: { label: "枚举", color: "#b180d7", icon: "list-ordered" },
impl: { label: "实现", color: "#dcdcaa", icon: "square-function" },
fn: { label: "函数", color: "#dcdcaa", icon: "square-function" },
trait: { label: "特质", color: "#569cd6", icon: "boxes" },
error: { label: "错误", color: "#f14c4c", icon: "triangle-alert" },
route: { label: "接口", color: "#61afef", icon: "globe" },
query: { label: "查询", color: "#ce9178", icon: "database" },
command: { label: "命令", color: "#c0754a", icon: "app-window" },
};
const TREE = [
{ kind: "folder", name: "agents", open: true, depth: 0 },
{ kind: "folder", name: "src", open: true, depth: 1 },
{ kind: "file", name: "agent.rs", depth: 2 },
{ kind: "file", name: "error.rs", depth: 2 },
{ kind: "file", name: "routes.rs", depth: 2 },
{ kind: "file", name: "store.rs", depth: 2 },
{ kind: "file", name: "Cargo.toml", depth: 1, ext: "TM" },
];
function solid(color) {
return [{ type: "solid", color }];
}
function text(parent, content, o) {
const s = String(content);
const opt = o || {};
const mono = Boolean(opt.mono) && !/[^\x00-\x7F]/.test(s);
const node = {
type: "text",
name: opt.name || s.slice(0, 32),
content: s,
fontFamily: mono ? "Menlo" : "PingFang SC",
fontSize: opt.size || 13,
fontWeight: opt.weight || 400,
lineHeight: opt.lh || 1.45,
fill: solid(opt.color || C.fg),
height: "fit_content",
};
if (opt.wrap) {
node.width = opt.width || "fill_container";
node.textGrowth = "fixed-width";
} else {
node.width = "fit_content";
node.textGrowth = "auto";
}
return I(parent, node);
}
function icon(parent, name, color, size) {
return I(parent, {
type: "icon_font",
name: name,
iconFontName: name,
width: size,
height: size,
fill: solid(color),
});
}
function row(parent, name, o) {
const opt = o || {};
return I(parent, {
type: "frame",
name,
layout: "horizontal",
width: opt.width || "fill_container",
height: opt.height || "fit_content",
gap: opt.gap == null ? 8 : opt.gap,
padding: opt.padding,
alignItems: opt.align || "center",
justifyContent: opt.justify,
fill: opt.fill ? solid(opt.fill) : undefined,
cornerRadius: opt.radius,
});
}
function col(parent, name, o) {
const opt = o || {};
return I(parent, {
type: "frame",
name,
layout: "vertical",
width: opt.width || "fill_container",
height: "fit_content",
gap: opt.gap == null ? 4 : opt.gap,
padding: opt.padding,
alignItems: opt.align,
fill: opt.fill ? solid(opt.fill) : undefined,
cornerRadius: opt.radius,
stroke: opt.stroke ? { thickness: 1, fill: solid(opt.stroke) } : undefined,
clipContent: false,
});
}
function prop(parent, label, value, color, mono) {
const line = row(parent, "Prop / " + label, { gap: 12, align: "start" });
const key = I(line, { type: "frame", name: "Key", width: 72, height: "fit_content", layout: "vertical" });
text(key, label, { size: 12, color: C.desc, wrap: true });
const val = I(line, { type: "frame", name: "Value", width: "fill_container", height: "fit_content", layout: "vertical" });
if (typeof value === "function") value(val);
else text(val, value, { size: 13, color: color || C.fg, mono: mono !== false, wrap: true });
return line;
}
function tag(parent, label, color) {
const pill = I(parent, {
type: "frame",
name: "Tag / " + label,
layout: "horizontal",
width: "fit_content",
height: "fit_content",
padding: [1, 6],
cornerRadius: 4,
alignItems: "center",
fill: solid("#2a2a2a"),
stroke: { thickness: 1, fill: solid(color) },
});
text(pill, label, { size: 11, color });
return pill;
}
function nameLine(parent, name, color, extra) {
const line = row(parent, "Prop / 名称", { gap: 12 });
const key = I(line, { type: "frame", name: "Key", width: 72, height: "fit_content" });
text(key, "名称", { size: 12, color: C.desc });
text(line, name, { size: 13, color: color || C.fn, mono: true, weight: 600 });
if (extra) extra(line);
}
function table(parent, columns, rows) {
const wrap = col(parent, "Table wrap", { gap: 0, padding: [2, 0, 0, 4] });
const grid = I(wrap, {
type: "frame",
name: "Table",
layout: "vertical",
width: "fill_container",
height: "fit_content",
cornerRadius: 4,
stroke: { thickness: 1, fill: solid(C.borderStrong) },
clipContent: true,
gap: 0,
});
function draw(cells, header) {
const line = I(grid, {
type: "frame",
name: header ? "Header" : "Row",
layout: "horizontal",
width: "fill_container",
height: "fit_content",
alignItems: "stretch",
fill: header ? solid(C.side) : undefined,
});
cells.forEach((cell, index) => {
const box = I(line, {
type: "frame",
name: columns[index].name,
layout: "vertical",
width: columns[index].width,
height: "fit_content",
padding: [5, 8],
justifyContent: "center",
});
if (typeof cell === "function") cell(box);
else text(box, cell, {
size: 12,
color: header ? C.desc : (columns[index].color || C.fg),
mono: !header && columns[index].mono !== false,
wrap: true,
});
});
}
draw(columns.map((column) => column.name), true);
for (const cells of rows) draw(cells, false);
}
function tools(parent) {
const bar = row(parent, "Tools", { width: "fit_content", gap: 2 });
for (const name of ["sparkles", "pencil"]) {
const button = I(bar, {
type: "frame",
name: name,
layout: "horizontal",
width: 22,
height: 22,
alignItems: "center",
justifyContent: "center",
cornerRadius: 4,
});
icon(button, name, C.desc, 14);
}
}
function card(parent, kind, draw) {
const node = I(parent, {
type: "frame",
name: kind.label,
layout: "horizontal",
width: "fill_container",
height: "fit_content",
gap: 10,
padding: [8, 10, 8, 8],
cornerRadius: 8,
alignItems: "start",
fill: solid("#242424"),
stroke: { thickness: 1, fill: solid(kind.color) },
clipContent: false,
});
const fold = I(node, {
type: "frame",
name: "Fold",
layout: "horizontal",
width: 14,
height: 20,
alignItems: "center",
justifyContent: "center",
});
icon(fold, "chevron-down", C.desc, 14);
icon(node, kind.icon, kind.color, 18);
const content = col(node, "Content", { gap: 4 });
draw(content);
tools(node);
return node;
}
function activity(parent) {
const bar = I(parent, {
type: "frame",
name: "Activity bar",
layout: "vertical",
width: 48,
height: "fit_content",
justifyContent: "space_between",
fill: solid(C.side),
});
const top = col(bar, "Top", { width: 48, gap: 0 });
const icons = ["files", "search", "git-branch", "play", "monitor", "blocks", "rocket", "book-open-text"];
for (const name of icons) {
const on = name === "book-open-text";
const cell = I(top, {
type: "frame",
name: "Item / " + name,
layout: "horizontal",
width: 48,
height: 42,
alignItems: "center",
justifyContent: on ? "start" : "center",
gap: on ? 10 : 0,
});
if (on) I(cell, { type: "rectangle", name: "Active bar", width: 2, height: 42, fill: solid(C.focus) });
icon(cell, name, on ? C.activityOn : C.activityIdle, 22);
}
return bar;
}
function sidebar(parent, selected) {
const side = col(parent, "Sidebar", { width: 280, gap: 0, fill: C.side });
const header = row(side, "Header", { height: 34, padding: [0, 12, 0, 16], justify: "space_between" });
text(header, "代码阅读", { size: 11, color: C.fg });
const actions = row(header, "Actions", { width: "fit_content", gap: 10 });
for (const name of ["git-pull-request", "file-plus", "refresh-cw"]) icon(actions, name, C.fg, 14);
const list = col(side, "Tree", { gap: 0, padding: [4, 0, 8, 0] });
for (const item of TREE) {
const line = I(list, {
type: "frame",
name: item.name,
layout: "horizontal",
width: "fill_container",
height: 26,
alignItems: "center",
gap: 6,
padding: [0, 8, 0, 10 + item.depth * 14],
fill: item.name === selected ? solid(C.selection) : undefined,
});
if (item.kind === "folder") icon(line, item.open ? "chevron-down" : "chevron-right", C.fg, 14);
else I(line, { type: "frame", name: "Twisty", width: 14, height: 14 });
if (item.kind === "folder") icon(line, "folder", C.folder, 15);
else text(line, item.ext || "RS", { size: 9, weight: 700, color: item.ext === "TM" ? C.toml : C.rust });
text(line, item.name, { size: 13, color: item.name === selected ? C.fgStrong : C.fg });
}
return side;
}
function editorChrome(parent, file) {
const bar = row(parent, "Editor title", { height: 36, justify: "space_between", fill: C.side, gap: 0 });
const tab = I(bar, {
type: "frame",
name: "Tab",
layout: "vertical",
width: 148,
height: "fill_container",
fill: solid(C.editor),
gap: 0,
});
I(tab, { type: "rectangle", name: "Active", width: "fill_container", height: 2, fill: solid(C.focus) });
const label = row(tab, "Tab label", { height: "fill_container", padding: [0, 12], gap: 6 });
text(label, "RS", { size: 9, weight: 700, color: C.rust });
text(label, file, { size: 13, color: C.fg });
const mode = I(bar, {
type: "frame",
name: "Mode",
layout: "horizontal",
width: "fit_content",
height: "fit_content",
padding: [2, 2],
gap: 2,
cornerRadius: 5,
alignItems: "center",
fill: solid(C.field),
stroke: { thickness: 1, fill: solid(C.borderStrong) },
});
const edit = row(mode, "Seg / 编辑", { width: "fit_content", padding: [2, 8], gap: 4, radius: 3 });
icon(edit, "pencil", C.desc, 13);
text(edit, "编辑", { size: 12, color: C.desc });
const preview = row(mode, "Seg / 预览", { width: "fit_content", padding: [2, 8], gap: 4, radius: 3, fill: C.toggleOn });
icon(preview, "eye", C.fgStrong, 13);
text(preview, "预览", { size: 12, color: C.fgStrong });
I(bar, { type: "frame", name: "Pad", width: 8, height: 8 });
}
function crumb(parent, file) {
const line = row(parent, "Breadcrumb", { padding: [6, 16], gap: 6 });
const parts = ["agents", "src", file];
parts.forEach((part, index) => {
if (index) text(line, "/", { size: 12, color: C.dim });
text(line, part, { size: 12, color: index === parts.length - 1 ? C.fg : C.desc, mono: part.endsWith(".rs") });
});
}
function filters(parent, chips, count) {
const bar = row(parent, "Filter", { height: 36, padding: [0, 16], gap: 8 });
text(bar, "筛选", { size: 12, color: C.desc });
for (const chip of chips) {
const item = row(bar, "Chip / " + chip, { width: "fit_content", height: 24, padding: [0, 8], gap: 4, radius: 4, fill: C.input });
text(item, chip, { size: 12, color: C.fg });
icon(item, "chevron-down", C.desc, 12);
}
const spacer = I(bar, { type: "frame", name: "Spacer", width: "fill_container", height: 8 });
text(bar, count + " 块", { size: 12, color: C.desc });
return spacer;
}
function status(parent) {
const bar = row(parent, "Status", {
height: 26,
padding: [0, 12],
justify: "space_between",
fill: C.side,
});
const left = row(bar, "Left", { width: "fit_content", gap: 12 });
const branch = row(left, "Branch", { width: "fit_content", gap: 4 });
icon(branch, "git-branch", C.fg, 13);
text(branch, "master", { size: 12 });
const right = row(bar, "Right", { width: "fit_content", gap: 16 });
text(right, "代码阅读", { size: 12 });
text(right, "Rust", { size: 12 });
}
function windowOf(name, x, file, chips, count, drawCards) {
const win = I(null, {
type: "frame",
name,
x,
y: 0,
width: 1280,
height: "fit_content",
layout: "vertical",
gap: 0,
fill: solid(C.side),
clipContent: false,
});
const title = row(win, "Title bar", { height: 36, padding: [0, 12], justify: "space_between" });
const lights = row(title, "Lights", { width: "fit_content", gap: 8 });
for (const color of ["#ff5f57", "#febc2e", "#28c840"]) {
I(lights, { type: "ellipse", width: 12, height: 12, fill: solid(color) });
}
text(title, "agents — " + file, { size: 12, color: C.desc });
I(title, { type: "frame", name: "Balance", width: 52, height: 12 });
const body = I(win, {
type: "frame",
name: "Body",
layout: "horizontal",
width: "fill_container",
height: "fit_content",
gap: 0,
alignItems: "start",
fill: solid(C.side),
clipContent: false,
});
activity(body);
sidebar(body, file);
const editor = col(body, "Editor", { gap: 0, fill: C.editor });
editorChrome(editor, file);
crumb(editor, file);
filters(editor, chips, count);
const preview = col(editor, "Preview", { gap: 10, padding: [8, 20, 24, 20] });
drawCards(preview);
status(win);
return win;
}
function useCard(parent, source, names) {
card(parent, KIND.use, (content) => {
prop(content, "来源", source, C.string, true);
prop(content, "导入项", names.length + " 个", C.desc, false);
table(content,
[{ name: "名字", width: 220, color: C.variable }, { name: "别名", width: 220, color: C.dim }],
names.map((item) => [item.name, item.alias || "—"]));
});
}
function languageCards(preview) {
useCard(preview, "serde", [{ name: "Serialize" }]);
useCard(preview, "std::sync", [{ name: "Arc" }]);
card(preview, KIND.struct, (content) => {
nameLine(content, "AgentRow", C.type);
prop(content, "说明", "桌面端列表里的一行代理。", C.comment, false);
prop(content, "可见性", "公开", C.control, false);
prop(content, "字段", "2 个", C.desc, false);
table(content,
[{ name: "字段", width: 180, color: C.variable }, { name: "类型", width: 220, color: C.type }, { name: "可见性", width: 160 }],
[
["id", "Uuid", { text: "公开", color: C.control }],
["name", "String", { text: "本模块", color: C.desc }],
].map(([field, type, vis]) => [field, type, (box) => text(box, vis.text, { size: 12, color: vis.color })]));
});
card(preview, KIND.enum, (content) => {
nameLine(content, "AgentState", C.type);
prop(content, "说明", "这个代理现在处在哪一步。", C.comment, false);
prop(content, "变体", "3 个", C.desc, false);
table(content,
[{ name: "变体", width: 220, color: C.type }, { name: "携带的数据", width: 360 }],
[
["Idle", "—"],
["Running", "{ pid: u32 }"],
["Failed", "String"],
]);
});
card(preview, KIND.impl, (content) => {
nameLine(content, "AgentRow 的方法", C.fn);
prop(content, "方法", "1 个", C.desc, false);
table(content,
[{ name: "名称", width: 180, color: C.fn }, { name: "参数", width: 320 }, { name: "返回", width: 140, color: C.dim }],
[["rename", "&mut self, name: String", "()"]]);
const nested = col(content, "展开 / rename", { gap: 0, padding: [4, 0, 0, 8] });
card(nested, KIND.fn, (body) => {
nameLine(body, "rename", C.fn);
prop(body, "参数", "2 个", C.desc, false);
table(body,
[{ name: "名称", width: 180, color: C.variable }, { name: "类型", width: 280 }],
[["self", "&mut self"], ["name", "String"]]);
});
});
card(preview, KIND.fn, (content) => {
nameLine(content, "start_server", C.fn);
prop(content, "说明", "在本机拉起接口服务。", C.comment, false);
prop(content, "异步", "是", C.storage, false);
prop(content, "可见性", "公开", C.control, false);
prop(content, "输入参数", "1 个", C.desc, false);
table(content,
[{ name: "名称", width: 180, color: C.variable }, { name: "类型", width: 280, color: C.type }],
[["addr", "SocketAddr"]]);
prop(content, "返回", "Result<(), StartError>", C.type, true);
prop(content, "分情况", "2 支", C.desc, false);
table(content,
[{ name: "分支", width: 220, color: C.type }, { name: "做什么", width: 420, mono: false }],
[
["Ok(listener)", "记下监听地址"],
["Err(err)", "返回 StartError"],
]);
});
card(preview, KIND.trait, (content) => {
nameLine(content, "Store", C.storage);
prop(content, "说明", "按 id 读写代理。", C.comment, false);
prop(content, "方法", "2 个", C.desc, false);
table(content,
[{ name: "名称", width: 140, color: C.fn }, { name: "参数", width: 280 }, { name: "返回", width: 280, color: C.type }],
[
["get", "&self, id: Uuid", "Result<AgentRow, StoreError>"],
["save", "&mut self, row: AgentRow", "Result<(), StoreError>"],
]);
});
}
function libraryCards(preview) {
card(preview, KIND.struct, (content) => {
nameLine(content, "AgentRow", C.type, (line) => tag(line, "可序列化", C.string));
prop(content, "说明", "桌面端列表里的一行代理。", C.comment, false);
prop(content, "可见性", "公开", C.control, false);
prop(content, "选项", "字段名改驼峰", C.string, false);
prop(content, "字段", "3 个", C.desc, false);
table(content,
[
{ name: "字段", width: 140, color: C.variable },
{ name: "类型", width: 160, color: C.type },
{ name: "可见性", width: 120 },
{ name: "序列化", width: 220 },
],
[
["id", "Uuid", (box) => text(box, "公开", { size: 12, color: C.control }), (box) => text(box, "改名 agent_id", { size: 12, color: C.string, wrap: true })],
["name", "String", (box) => text(box, "公开", { size: 12, color: C.control }), "—"],
["secret", "String", (box) => text(box, "本模块", { size: 12, color: C.desc }), (box) => text(box, "不输出", { size: 12, color: C.desc })],
]);
});
card(preview, KIND.error, (content) => {
nameLine(content, "AgentError", "#f14c4c");
prop(content, "说明", "读写代理时出的错。", C.comment, false);
prop(content, "变体", "2 个", C.desc, false);
table(content,
[
{ name: "变体", width: 150, color: C.type },
{ name: "何时", width: 200, mono: false },
{ name: "展示", width: 280 },
],
[
["NotFound", "这个 id 没有代理", (box) => text(box, "agent {id} 不存在", { size: 12, color: C.string, wrap: true })],
["Upstream", "调用上游失败", (box) => {
const line = row(box, "展示", { gap: 6 });
text(line, "上游错误", { size: 12, color: C.string });
tag(line, "透传来源", C.control);
}],
]);
});
card(preview, KIND.route, (content) => {
nameLine(content, "get_agent", C.fn);
prop(content, "说明", "按 id 取一个代理。", C.comment, false);
prop(content, "异步", "是", C.storage, false);
prop(content, "请求", "GET /agents/{id}", C.desc, true);
table(content,
[{ name: "项", width: 140 }, { name: "值", width: 420 }],
[
[(box) => text(box, "方法", { size: 12, color: C.desc }), (box) => text(box, "GET", { size: 12, color: C.get, weight: 600, mono: true })],
[(box) => text(box, "路径", { size: 12, color: C.desc }), (box) => text(box, "/agents/{id}", { size: 12, color: C.string, mono: true })],
[(box) => text(box, "路径参数", { size: 12, color: C.desc }), (box) => text(box, "id: Uuid", { size: 12, color: C.variable, mono: true })],
]);
prop(content, "返回", "Result<Json<AgentRow>, StatusCode>", C.type, true);
});
card(preview, KIND.query, (content) => {
prop(content, "说明", "按 id 取一行。", C.comment, false);
prop(content, "SQL", "SELECT id, name FROM agents WHERE id = $1", C.fg, true);
prop(content, "参数", "$1 = id", C.variable, true);
prop(content, "返回类型", "AgentRow", C.type, true);
});
card(preview, KIND.command, (content) => {
nameLine(content, "rename_agent", C.fn, (line) => tag(line, "桌面命令", C.toml));
prop(content, "说明", "让窗口把这一行改名。", C.comment, false);
prop(content, "异步", "是", C.storage, false);
prop(content, "参数", "2 个", C.desc, false);
table(content,
[
{ name: "名称", width: 140, color: C.variable },
{ name: "类型", width: 200, color: C.type },
{ name: "来源", width: 200, mono: false },
],
[
["state", "State<App>", "窗口状态"],
["name", "String", "调用方传入"],
]);
prop(content, "返回", "Result<String, String>", C.type, true);
});
}
function note(parent, label, color, sentence) {
const line = row(parent, "Note / " + label, { gap: 10, align: "start" });
if (label) tag(line, label, color || C.desc);
const body = I(line, { type: "frame", name: "Sentence", width: "fill_container", height: "fit_content", layout: "vertical" });
text(body, sentence, { size: 13, color: C.fg, wrap: true });
}
function legend(x) {
const board = I(null, {
type: "frame",
name: "Prototype / Rust · 标记说明",
x,
y: 0,
width: 880,
height: "fit_content",
layout: "vertical",
gap: 14,
padding: 28,
fill: solid(C.editor),
clipContent: false,
});
text(board, "标记说明", { size: 18, weight: 600, color: C.fgStrong });
text(board, "可见性", { size: 14, weight: 600, color: C.fgStrong });
note(board, "公开", C.control, "pub,别的模块也能用。");
note(board, "本模块", C.desc, "没写 pub,只有这个文件里能用。");
note(board, "父模块", C.string, "pub(super),上一层模块能用。");
text(board, "语言卡片", { size: 14, weight: 600, color: C.fgStrong });
note(board, "导入", KIND.use.color, "一条 use。来源是路径,下面表是名字和别名。");
note(board, "结构体", KIND.struct.color, "字段表:字段、类型、可见性。没有的属性不占一行。");
note(board, "枚举", KIND.enum.color, "变体表。没带数据的格子是破折号,不硬凑类型。");
note(board, "实现", KIND.impl.color, "标题写成「某某的方法」。点方法名展开一张函数子卡片。");
note(board, "函数", KIND.fn.color, "异步写「是」。函数体里的 match 收成「分情况」,不展开源码。");
note(board, "特质", KIND.trait.color, "方法表只列签名,不展开函数体。");
text(board, "库", { size: 14, weight: 600, color: C.fgStrong });
note(board, "改名", C.string, "serde rename:序列化那一列写成「改名 agent_id」。");
note(board, "不输出", C.desc, "serde skip:这一列写成「不输出」。没标的是破折号。");
note(board, "驼峰", C.string, "rename_all = camelCase 收成选项行「字段名改驼峰」。");
note(board, "展示", KIND.error.color, "thiserror 的文案写在「展示」列。透传的上游错误另标「透传来源」。");
note(board, "接口", KIND.route.color, "axum 处理函数和 FastAPI 接口是同一张卡:请求表加返回。");
note(board, "查询", KIND.query.color, "sqlx 一张小卡:一句说明、等宽 SQL、参数、返回类型。");
note(board, "桌面命令", KIND.command.color, "tauri 命令在名称后标「桌面命令」。参数写明来自窗口状态还是调用方。");
note(board, "异步", C.storage, "tokio 不单独做卡。spawn 和 select 仍是普通调用。");
return board;
}
windowOf("Prototype / Rust · 语言", 0, "agent.rs", ["种类", "可见性"], 7, languageCards);
windowOf("Prototype / Rust · 库", 1360, "routes.rs", ["种类", "库"], 5, libraryCards);
legend(2720);
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

File diff suppressed because it is too large. Load diff
+52 -11
View File
@@ -15,6 +15,7 @@
| 流程 | 源码 →(tree-sitter)语法树 → 通用代码块树 → 译文 |
| 第一版翻译 | 只做中文模板直译;AI 解读以后作为另一个翻译插件加 |
| 第一版语言 | TypeScript / TSX(含 JavaScript / JSX)、Python、Shell 脚本(bash / sh / zsh)、Dockerfile |
| Rust | `.rs`(VS Code 的 rust):读成导入、结构体、枚举、实现、函数、特质;serde、thiserror、axum、sqlx、tauri 各一张库卡片。tokio 不单独做。见「Rust」 |
| 扩展方式 | 写代码注册:语言插件、翻译插件都实现约定接口,通过本插件 `activate()` 导出的 API 注册;内置实现也走同一个 API |
| 多种翻译怎么选 | 预览顶部筛选栏最右边的下拉框,整个文件一起换,按语言记住选择;只有一种时不显示 |
| 块筛选 | 预览顶部钉住一条筛选栏,按块的属性(种类、访问级别、异步……)筛;属性由语言插件按和语言无关的 id 报(通用的由宿主从 facts 推),名字由翻译插件起,webview 不认识任何语言;筛选整个工作区共用、记住 |
@@ -146,9 +147,10 @@
| command 命令 | `square-terminal` | pipeline 管道 | `square-terminal` | | |
| directive 解析指令 | `terminal` | stage 阶段 | `layers` | run 执行 | `square-terminal` |
| copy 复制 | `copy` | setting 设置 | `sliders-horizontal` | label 标签 | `tag` |
| health 健康检查 | `activity` | start 启动 | `play` | | |
| health 健康检查 | `activity` | start 启动 | `play` | trait 特质 | `network` |
| impl 实现 | `square-function` | module 模块 | `layers` | | |
库规则的种类用规则给的图标(`KindBadge.icon`),跟在语言的种类图标后面:SQLAlchemy 数据表 `database`、数据类 `package`、Pydantic 数据模型 `braces`(和类型块同一个图标,颜色不同)、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`、FastAPI 路由器 `network`、React 组件 `atom`、副作用 `zap`、事件处理 `mouse-pointer-click`。数据类写了 `frozen=True` 再加一把锁(`lock`,悬停「不可修改」)。
库规则的种类用规则给的图标(`KindBadge.icon`),跟在语言的种类图标后面:SQLAlchemy 数据表 `database`、数据类 `package`、Pydantic 数据模型 `braces`(和类型块同一个图标,颜色不同)、Celery 应用 `server-cog`、定时任务 `calendar-clock`、FastAPI 接口 `globe`、FastAPI 路由器 `network`、React 组件 `atom`、副作用 `zap`、事件处理 `mouse-pointer-click`、serde 可序列化 `braces`、thiserror 错误 `triangle-alert`、axum 接口 `globe`、sqlx 查询 `database`、tauri 桌面命令 `square-function`。数据类写了 `frozen=True` 再加一把锁(`lock`,悬停「不可修改」)。实现块再由规则 `rust.impl` 换成卡片,左边因此有两个「实现」图标。
这组图标由插件自己定义,是一个封闭的集合:契约里的 `KIND_ICON_NAMES`(上表 26 个——命令、管道、执行共用一个,解释器、解析指令共用一个,选项、设置共用一个,字段、标签共用一个——加库规则用的 9 个(React 组件的原子、副作用的闪电、事件处理的点击手势也在里面),lucide 的 kebab-case 名字),类型 `KindIconName` 是它们的联合,概念的 `icon` 只能写其中一个,写别的是类型错误。内置种类 → 图标名在 `contract.ts` 的 `KIND_ICONS`(宿主写进概念列表)。webview 给每个名字配一个逐个 import 的 lucide 图标组件(`kindIcons.tsx` 的 `KIND_ICON_COMPONENTS`,打包只带这些,预览脚本约 157 kB);webview 的 `KindIconName` 照契约手抄,`kindIconNames.test.ts` 核对两边一样。规则要新图标就把名字加进这组、在 webview 配上图标,没有按任意名字找图标的路。
- 右上角的图标按钮(原型组件 `Reader / BlockTools`,普通块、属性表块、库规则的卡片共用):只有图标、不带字,名字写在悬停提示和 `aria-label` 里;整排是 `role="group"`(`aria-label="块操作"`)。先是「问 AI」(sparkles 图标,点开 / 收起块下面的问答区,展开时带按下的底色、`aria-expanded`;文件不在工作区文件夹里时没有,见「问 AI」),再是「编辑 / 预览」切换:预览时铅笔「编辑」,编辑时眼睛「预览」、带按下的底色(`--vscode-toolbar-activeBackground`)。按钮宽 24px、高 20px(和种类图标同高),图标 14px、`descriptionForeground` 色;悬停 `--vscode-toolbar-hoverBackground`,键盘聚焦描 1px `--vscode-focusBorder`;Tab 能走到,回车 / 空格切换。
@@ -572,6 +574,7 @@
```ts
export type BlockKind =
| 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable'
| 'trait' | 'impl' | 'module' // Rust:特质、实现、模块(属性表)
| 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment'
| 'interpreter' | 'options' | 'case' | 'command' | 'pipeline' // shell:解释器、选项(属性表),分情况、命令、管道(一行译文)
| 'directive' | 'stage' | 'run' | 'copy' | 'setting' | 'label' | 'health' | 'start' // Dockerfile:解析指令、阶段、健康检查、启动(属性表),执行、复制、设置、标签(一行译文)
@@ -1119,7 +1122,7 @@ export interface CommandRule {
### 启用、认领、优先
- 启用:宿主收集这份源码里各层导入块的 `ImportFacts.sources`(去掉引号),有一个等于规则的某个库、或以「库.」「库/」开头(子模块),规则才对这份源码启用。规则不用自己读导入语句。`libraries` 是空数组的规则不看导入,`languageIds` 里这几种语言的源码都启用(React:新的 JSX 写法不用导入 react。Pydantic:子类文件常常只导入父模型、不再导入 pydantic,见「内置:Pydantic 数据模型」)。
- 启用:宿主收集这份源码里各层导入块的 `ImportFacts.sources`(去掉引号),有一个等于规则的某个库、或以「库.」「库/」「库::」开头(子模块;Rust 的路径用 `::`,`use axum::routing::get` 的来源是 `axum::routing`),规则才对这份源码启用。规则不用自己读导入语句。`libraries` 是空数组的规则不看导入,`languageIds` 里这几种语言的源码都启用(React:新的 JSX 写法不用导入 react。Pydantic:子类文件常常只导入父模型、不再导入 pydantic,见「内置:Pydantic 数据模型」)。
- 认领:宿主从顶层往下每块问一遍启用的规则,按注册顺序,第一个认领的算(先注册的优先);被认领的块留下的子块照样问。
- 出错:规则抛错、或替身不合约定(种类 id 没带规则 id 前缀、并进来的不是这块的子块、属性表里的引用标的不是自己 / 规则没给 `resolve` / 引用的段没带下标、放进卡片里的块不是留下的子块 / 放了几处 / 没有那一行或子行、要略去的块还有子块)时,宿主记一条警告日志(输出面板「Superpowers」),当它没认、接着问下一个规则;都没认这块照常显示。预览不会因为规则出错而失败。
- 并进来的子块:不再单独成块,由规则挑(`absorbs`),宿主不替规则多并。没并进来的子块(方法、规则不讲的注释)不管写在哪(字段中间也算),都照源码顺序是替身的子块,预览里照常看得见。替身的编辑范围同别的块,是原块整条语句(从头部开始,到原块连同各子块的最后一行,类就是整个类),盖住留下的子块的行:替身编辑时预览不画这些子块,同一行不会同时在两处能改(见「预览(代码块树)」)。内置规则并成员时连同紧挨在它前面的注释块一起并(讲的是这个成员),讲方法的注释、最后面的注释照常留下。
@@ -1554,7 +1557,7 @@ export interface CommandRule {
| --- | --- |
| 名称 | 组件名(组件色,带下标);后面跟小标签(`tag` 框):「具名导出」「默认导出」(同 import / export 关键字的颜色;`export default X`、`export { X }` 写在文件别处也算)、「客户端组件」(组件色)、「记忆化」「转发 ref」(同 const 关键字的颜色,memo / forwardRef 包着) |
| 说明 | 组件上面的 JSDoc,Markdown |
| 属性 | `N 个`(hint);属性类型写的是一个名字(接口、类型别名、导入的类型)时后面写「 · 来自 XxxProps」,名字带下标(⌘ + 点击跳到类型)。下面一张表格「名称 \| 类型 \| 默认值 \| 必填」,有属性带说明时多一列「说明」。没写参数时写「无」 |
| 构造参数 | `N 个`(hint);属性类型写的是一个名字(接口、类型别名、导入的类型)时后面写「 · 来自 XxxProps」,名字带下标(⌘ + 点击跳到类型)。下面一张表格「名称 \| 类型 \| 默认值 \| 必填」,有属性带说明时多一列「说明」。没写参数时写「无」 |
| 状态 | useState / useReducer:「名称 \| 初始值 \| 改它的」 |
| 引用 | useRef:「名称 \| 初始值 \| 用在」 |
| 读取的数据 | 别的钩子(`useXxx(…)` 赋给变量):「变量 \| 来自 \| 意思」 |
@@ -1563,7 +1566,7 @@ export interface CommandRule {
| 事件处理 | 组件体里定义的函数(函数声明、值是函数的常量):「名称 \| 绑定在 \| 做什么」,点名称在表下面展开它的子卡片 |
| 渲染 | `N 处返回`(hint),下面是返回的 JSX 画成的结构大纲 |
属性表:属性类型的字段照类型里的顺序一行一个(类型字面量、同文件的接口 / 类型别名,交叉类型各部分接起来,`Readonly<…>`、`PropsWithChildren<…>` 看里面;没标注参数类型时 `const X: FC<Props>` 的泛型实参、`forwardRef<R, Props>` 的第二个泛型实参),解构里有、类型里没有的接在后面,`...rest` 最后(类型写「其余的属性」)。名称是属性名(定义处带下标:解构出来的是解构那处,没解构的是字段;跳到定义只闪这一行),`children` 后面标「子元素」、函数类型的属性(`(x) => R`、方法签名)标「回调」(组件色的小标签),解构时改了名写「(组件里叫 x)」;类型照翻译插件翻,方法签名写成函数签名;默认值取解构的默认值,没有写「—」;必填看类型的 `?`(是 / 否,storage 色),没类型可看时写了默认值的「否」、别的「—」;说明是字段上面的 JSDoc / 注释、这一行行尾的注释(comment 色,几行接成一行)。类型是导入的名字时经工作区索引查它的字段(索引 `react.propTypes`:看工作区里所有 `**/*.{ts,tsx}`,预筛有 `interface X` / `type X` 才解析,按 typescriptreact 解析;从文件顶层认出接口、类型别名,键是名字、值是各字段的名字、类型写法、可省略、回调、说明的 JSON),查法同「规则认领时查别的文件里的定义」:先照解构的出行、类型「未标注」,查到了按字段重排、重发;别的文件里的类型照写文字(type 色)。
构造参数:属性类型的字段照类型里的顺序一行一个(类型字面量、同文件的接口 / 类型别名,交叉类型各部分接起来,`Readonly<…>`、`PropsWithChildren<…>` 看里面;没标注参数类型时 `const X: FC<Props>` 的泛型实参、`forwardRef<R, Props>` 的第二个泛型实参),解构里有、类型里没有的接在后面,`...rest` 最后(类型写「其余的属性」)。名称是属性名(定义处带下标:解构出来的是解构那处,没解构的是字段;跳到定义只闪这一行),`children` 后面标「子元素」、函数类型的属性(`(x) => R`、方法签名)标「回调」(组件色的小标签),解构时改了名写「(组件里叫 x)」;类型照翻译插件翻,方法签名写成函数签名;默认值取解构的默认值,没有写「—」;必填看类型的 `?`(是 / 否,storage 色),没类型可看时写了默认值的「否」、别的「—」;说明是字段上面的 JSDoc / 注释、这一行行尾的注释(comment 色,几行接成一行)。类型是导入的名字时经工作区索引查它的字段(索引 `react.propTypes`:看工作区里所有 `**/*.{ts,tsx}`,预筛有 `interface X` / `type X` 才解析,按 typescriptreact 解析;从文件顶层认出接口、类型别名,键是名字、值是各字段的名字、类型写法、可省略、回调、说明的 JSON),查法同「规则认领时查别的文件里的定义」:先照解构的出行、类型「未标注」,查到了按字段重排、重发;别的文件里的类型照写文字(type 色)。
状态、引用、读取的数据、派生值:
@@ -1586,14 +1589,14 @@ export interface CommandRule {
- `cond && <X/>` 前面加「当 … 时」小框(`condition`,条件照常翻);三元一行「如果 …」,下面第一行各带「是」「否」小框(`branch`),一支是 null 时写「不渲染」;`items.map((x) => …)` 一行「对 items 的每一项 x」(`iteration` 小框)后面「key …」,回调里先算的常量写「其中 isActive = …」,再下面是每一项渲染的;JSX 注释写成一行「// …」(comment 色)。
- 几处返回:分几段,提前 `return null`(`if (cond) return null`,主体只有这一句)写「当 … 时」(`skip` 小框)「不渲染」,提前返回 JSX 的「当 … 时」下面是它渲染的,最后一处「否则」(`branch` 小框);只有一处返回时直接是它的大纲。这些 return(和提前返回的 if)并进卡片。
组件体里归不进上面分区的语句(没讲进分区的注释、别的 if、循环、单独调用的钩子)照常是组件卡片留下的子块。卡片「编辑」是组件的完整代码(整条语句);副作用、处理函数子卡片各自能编辑、问 AI。代码差异在视图数据这一层比:组件卡片的属性表逐行(属性表按名称配对,加的属性标新加的一行),副作用、处理函数子卡片是块,照常配对、逐行标(改了依赖,「时机」那一行逐截标)。
组件体里归不进上面分区的语句(没讲进分区的注释、别的 if、循环、单独调用的钩子)照常是组件卡片留下的子块。卡片「编辑」是组件的完整代码(整条语句);副作用、处理函数子卡片各自能编辑、问 AI。状态「改它的」里每一处、读取的数据的「来自」各自有同一套铅笔,打开后只出现那一段源码(整行切好、着色好,和块同一套编辑框、同一条改文件的消息):来自是这次钩子调用占的行,不是左边的变量名;改它的里 JSX 是整个属性(名字和值),处理函数、派生值、另一处读取、重置条件各开各的那段(函数、那个值、那次调用、那个条件),纯渲染没有铅笔。这段的行落在组件里面,组件正在编辑时不画这段,两边不能同时编辑。代码差异在视图数据这一层比:组件卡片的属性表逐行(属性表按名称配对,加的属性标新加的一行),副作用、处理函数子卡片是块,照常配对、逐行标(改了依赖,「时机」那一行逐截标)。
样例(`fixtures/desktopTitlebar.tsx.txt`,测试里逐字核对;`⟦…⟧` 是小标签、小框):
```
名称: DesktopTitlebar ⟦具名导出⟧ ⟦客户端组件⟧
说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.
属性: 1 个
构造参数: 1 个
platform | DesktopShell["platform"] | — | 是
读取的数据: 2 个
pathname | usePathname() | 当前路径
@@ -1611,6 +1614,16 @@ export interface CommandRule {
和原型不一样的地方:原型里「做什么」「意思」、派生值的读法(「title;没有就按路径查 PAGE_TITLES」)是照设计手写的中文,实现里是注释原文、照翻译插件翻的译文和词表;条件照翻译插件读(`===` 是「全等」,原型写「是」);渲染大纲带下一层的行有折叠箭头;副作用子卡片收起时不换成一行摘要,「做什么」那一行的箭头收起它下面的语句。
### 内置:Rust 的库
文件导入了对应 crate 才启用(来源等于 crate 名,或以 `crate::` 开头)。tokio 不单独做卡片,异步已经写在函数上。样例 `fixtures/rustLibs.rs`。
- serde(`rules/serde.ts`,`libraries: ['serde']`):`derive` 了 `Serialize` 或 `Deserialize` 的结构体。种类 `serde.struct`,标签「可序列化」,图标 `braces`,颜色同结构体(暗 #4ec9b0 / 亮 #267f99)。名称后面再标一个「可序列化」。`rename_all = "camelCase"` 收成选项「字段名改驼峰」。字段表多一列「序列化」:`rename` 写「改名 xxx」,`skip` / `skip_serializing` 写「不输出」,没标是「—」。
- thiserror(`rules/thiserror.ts`,`libraries: ['thiserror']`):`derive` 了 `Error` 的枚举。种类 `thiserror.error`,标签「错误」,图标 `triangle-alert`,颜色同异常(暗 #f14c4c / 亮 #cd3131)。变体表「变体 | 何时 | 展示」:何时是变体上面的文档注释,展示是 `#[error("…")]` 的文案。变体上的 `#[from]` 或 `#[source]` 在展示后面标「透传来源」。
- axum(`rules/axum.ts`,`libraries: ['axum']`):处理函数上的 `#[get("/path")]`、`#[axum::post(…)]` 等(get / post / put / delete / patch / head / options / trace),或顶层语句里 `Router` 的 `.route(路径, get(函数))`。函数已经标了路由属性时,这句 `route` 不再单独成卡。种类 `axum.route`,标签「接口」,图标 `globe`,颜色同 FastAPI 接口(暗 #61afef / 亮 #1a6fb5)。属性表:名称、说明、异步(是)、请求(一项「方法」、一项「路径」、路径里的 `{名字}` 对上参数写成「路径参数」,`Path(id): Path<Uuid>` 写 `id: Uuid`)、返回。
- sqlx(`rules/sqlx.ts`,`libraries: ['sqlx']`):`query!` / `query_as!` / `query_scalar!`。种类 `sqlx.query`,标签「查询」,图标 `database`,颜色暗 #ce9178 / 亮 #a31515。紧挨在前面的注释收成「说明」,不再单独成块。SQL 原文等宽,参数 `$1` 对上传入的表达式,`query_as!` / `query_scalar!` 的第一个实参是返回类型,`query!` 的返回类型是「—」。
- tauri(`rules/tauri.ts`,`libraries: ['tauri']`):`#[tauri::command]` 的函数。种类 `tauri.command`,标签「桌面命令」,图标 `square-function`,颜色暗 #e5c07b / 亮 #9a6a00。名称后面再标「桌面命令」。参数表「名称 | 类型 | 来源」:`State<…>` 是「窗口状态」,其余是「调用方传入」。
## 命令规则
shell 脚本大半是一条条命令,一条命令读成什么取决于命令本身(`rm -rf` 是「删除(连同里面的东西、不提示)」,`tar -xzf` 是「解压 tar.gz」)。这些既不是语法(语言插件不管),也不是某种语言的措辞(翻译插件不该认识几十个命令)。命令规则是第五种插件:按命令名注册,把一条命令连同它的选项读成一句译文,像库规则按库认写法一样。
@@ -1724,6 +1737,23 @@ shell 脚本大半是一条条命令,一条命令读成什么取决于命令
- `run_instruction` → run(整条指令,shell 写法的带 `embedded`);`copy_instruction`、`add_instruction` → copy;`label_instruction` → label;`env_instruction`、`arg_instruction` → variable(facts 的 scope 分构建参数、环境变量);WORKDIR / USER / EXPOSE / VOLUME / STOPSIGNAL / SHELL / MAINTAINER → setting;`healthcheck_instruction` → health;`cmd_instruction`、`entrypoint_instruction` → start;`onbuild_instruction` → setting,包着的指令照这些规则是它的子块;别的(语法错误)→ statement。
- 连着写的同种指令(紧挨着下一行)并成一块:ENV、ARG 并成一个变量块(declarators 是各条的全部),LABEL 和从同一个地方复制的 COPY 并成一块(`TextBlock.group`,头部盖住全部);ADD、带 heredoc 的 COPY 不并。
### Rust
语法包是 `@vscode/tree-sitter-wasm` 里的 `tree-sitter-rust.wasm`(和 python 的 wasm 同一层:包入口就在 `wasm/`)。`languageIds: ['rust']`。样例 `fixtures/rustLang.rs`、`fixtures/rustLibs.rs`。原型是 `docs/prototype/code-reader/openpencil/exports/` 的 `rust-lang.png`、`rust-lib.png`、`rust-notes.png`。中文直译 `translators/rustZh.ts`:结构体不叫「类」,可见性不叫「访问级别」,`pub` 是「公开」。
可见性:`pub` 公开,没写是本模块,`pub(super)` 父模块,`pub(crate)` 和 `pub(in …)` 本 crate。公开写在 `facts.access`,其余写在块的 `access` 属性上(筛选值 `module` / `super` / `crate`)。说明是紧挨在前面的文档注释(`///`、`/**`)的第一句,句号留着。
- `use` → import。来源是路径去掉最后一节(`std::sync::Arc` 的来源是 `std::sync`)。导入项表「名字 | 别名」,没起别名是「—」,`*` 是「*」。
- `struct` → class(中文直译把这个种类叫「结构体」)。字段不单独成块,表「字段 | 类型 | 可见性」。元组结构体没有这张表。
- `enum` → enum。变体表「变体 | 携带的数据」:没带数据是「—」,`Failed(String)` 写类型,`Running { pid: u32 }` 写字段原文。
- `fn` → function。有才显示:说明、异步(只有 `async fn` 写「是」)、可见性、输入参数表「名称 | 类型」、返回(没写是 `()`)。函数体里直接写的 `match` 收成「分情况」表「分支 | 做什么」,N 支:优先用这一支里的注释,否则是 `return` 那句或这一支的第一句,不展开成子块。
- `trait` → trait(图标 `network`,颜色同声明关键字的蓝)。方法表「名称 | 参数 | 返回」,只列签名,不展开函数体。
- `impl` → impl(图标 `square-function`,颜色同函数)。标题是「某类型 的方法」(`impl Trait for Type` 取 Type)。方法是子块。不看导入的规则 `rules/rustImpl.ts`(`libraries: []`,种类 `rust.impl`)把每张方法卡放进方法表那一行,点方法名展开,和 React 事件处理同一条路。方法卡的参数表叫「参数」。身体里的 `const`、`static` 不收成语句,跟文件顶层同一条路(见下)。
- `mod 名字 { … }`(`mod_item` 有 `body`)→ module(中文「模块」,图标 `layers`,颜色同阶段)。卡片行:名称、说明(有文档注释才有)、可见性(没写也是本模块,跟结构体一样总有这一行)。身体用和文件顶层同一套规则拆成子块,排在这张卡片下面,不另做一张表把子块塞进某一行。模块自己的文档注释留在模块卡片上,身体里每项自己的文档注释留在那一项上。`mod 名字;` 没有身体,不拆,仍是一条 statement。
- `const`(`const_item`)→ const,`static`(`static_item`)→ variable。属性表不另写一套译文,走中文模板里常量 / 变量那一套:名称、说明、访问级别(公开才有,模板里叫「公共」)、类型、值(`static` 的值叫「初始值」)。文件顶层、模块身体、impl 身体同一条路。
- `extern "…" { … }`(`foreign_mod_item`)不单独成一块源码。里面的函数签名(`function_signature_item`,没有函数体)是函数卡片,跟普通函数一样有名称、说明、可见性、参数、返回,作为所在这一层(模块、文件顶层或 impl)的子块。
- 其余(`let`、宏调用)→ statement。不拆函数体。`//` 注释单独成块。模块身体里宏调用后面多出来的那个空 `;`(`empty_statement`)不成块。
### 属性表信息(facts)从哪来
- TypeScript 函数:`function_declaration` / `generator_function_declaration` / `function_signature`、类里的 `method_definition` / 方法签名、值是带语句块主体函数的变量和类字段、`export default` 的函数值。名称取声明名 / 变量名 / 字段名;说明取紧贴在前面的 JSDoc 注释节点(见上面代码块树规则);参数来自 `formal_parameters`(`required_parameter` / `optional_parameter` 的 pattern、type、value,JavaScript 的 `assignment_pattern`),不带括号的单参数箭头函数取 `parameter`;返回类型取 `return_type` 里的类型;异步看有没有 `async`;类成员的装饰器是类主体里排在它前面的 `decorator` 兄弟节点。
@@ -1738,6 +1768,7 @@ shell 脚本大半是一条条命令,一条命令读成什么取决于命令
- Shell:解释器取 `#!` 后面的整句,第一个词是 `env` 的是按 PATH 找、程序名是它后面那个词,否则是写死的路径、程序名是路径最后一节。选项:每个开关一项,单字母换成 bash 的长名字(e → errexit、u → nounset、x → xtrace……),`-o 名字` 照写,`-` 开、`+` 关。变量:`variable_assignment` 的 name / value,声明里没给值的只有 name。函数:name 是函数名,doc 是紧贴在上面的注释;`positional` 是主体里(不进嵌套的函数)用到的 `$N` / `${N}` / `${N:-…}`,照位置排,`local x="$N"`、`x=$N` 给它起名字;`prints` 是没重定向到别处的 `echo` / `printf`(进 if、循环、分情况,不进 `$(…)`、管道、嵌套的函数);`captured` 看同一文件里有没有 `$(函数名 …)`;`returns` 是主体里的 `return` 命令。
- Dockerfile:解析指令是每行注释 `# 名字=值` 的名字(小写)和值;阶段取 `image_spec` 的 name / tag / digest 字段、`as` 字段(image_alias),stage 看镜像名是不是前面某个阶段的名字(不分大小写),选项是 `param`、`mount_param` 节点的原文 `--名字=值`(值在语法树里没有单独的节点,按原文拆,记下值从哪开始);变量是 `env_pair` 的 name / value、`arg_instruction` 的 name / default;健康检查的 none 看有没有 NONE,检查命令是里面的 `cmd_instruction`;数组写法取 `json_string_array`,shell 写法取 `shell_command` 里各个 `shell_fragment`(`line_continuation` 不算),只有一个 `<<EOT` 的取它下面 `heredoc_block` 从第一行到最后一行。
- Python 变量:`assignment` 的 left / type / right,说明是紧跟在后面的属性 docstring 的字符串节点(见上面代码块树规则);right 是 `dictionary` 时 entries 是它的具名子节点(注释除外):`pair` 的 key / value(value 又是 `dictionary` 时连它的 entries)、`dictionary_splat` 里的表达式。
- Rust:函数(含 `extern` 块里没有函数体的 `function_signature_item`)的 name / parameters / return_type,`async` 只在写了时填;`self` 的类型节点是整个接收者(`&mut self`)。结构体、枚举、特质、实现、模块的名称、说明、可见性见「Rust」(模块用 ClassFacts:name、doc、access)。`const`、`static` 的名称、类型、值是 `const_item` / `static_item` 的 name / type / value(VariableFacts 的一项 declarator),可见性同别的项:`pub` 写在 `facts.access`,其余写在块的 `access` 属性上。字段和变体携带的数据由中文直译读语法树,不另做 facts。
## 中文模板直译
@@ -1890,7 +1921,7 @@ type BlockView = BlockViewBase & ({ translation: PreviewText, table?: PreviewTab
带自己颜色的段(`Segment.color`,库规则按库的配色给,例如 FastAPI 的 HTTP 方法、成功的状态码):暗色、亮色两个写在元素的 style 上(`--color-dark` / `--color-light`),`.cr-colored` 按主题挑一个,盖过词性的颜色;词性照旧报(`role`)。
种类(种类图标的颜色 + 块底 7% + 边框 35%;亮色主题同色系;图标见「预览(代码块树)」):import 导入 #c586c0;function 函数 #dcdcaa;class 类、type 类型 #4ec9b0;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);Shell 的 interpreter 解释器同语句 #9d9d9d,options 选项 #e5c07b(亮色 #9a6a00),case 分情况同条件 #ce9178,command 命令、pipeline 管道 #d7ba7d(亮色 #7c5f12);Dockerfile 的 directive 解析指令同语句,stage 阶段 #56b6c2(亮色 #127f8c),run 执行同命令,copy 复制同导入,setting 设置同选项,label 标签同变量,health 健康检查同尝试 / 捕获,start 启动同返回(原型 DOCKER_KINDS 的颜色);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c,FastAPI 接口 #61afef / #1a6fb5,FastAPI 路由器 #7aa2f7 / #34548a。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
种类(种类图标的颜色 + 块底 7% + 边框 35%;亮色主题同色系;图标见「预览(代码块树)」):import 导入 #c586c0;function 函数、impl 实现 #dcdcaa;class 类、type 类型 #4ec9b0;trait 特质同声明关键字的蓝;enum 枚举 #b180d7(亮色 #652d90);exception 异常 #f14c4c(亮色 #cd3131,和尝试、捕获、最终同一色系,更红一些);field 字段、const 常量、variable 变量 #9cdcfe;if 条件、else 否则 #ce9178;loop 循环 #8f9cff;try 尝试、catch 捕获、finally 最终 #f48771;return 返回 #89d185;statement 语句 #9d9d9d;comment 注释 #6a9955(虚线框);Shell 的 interpreter 解释器同语句 #9d9d9d,options 选项 #e5c07b(亮色 #9a6a00),case 分情况同条件 #ce9178,command 命令、pipeline 管道 #d7ba7d(亮色 #7c5f12);Dockerfile 的 directive 解析指令同语句,stage 阶段、module 模块 #56b6c2(亮色 #127f8c),run 执行同命令,copy 复制同导入,setting 设置同选项,label 标签同变量,health 健康检查同尝试 / 捕获,start 启动同返回(原型 DOCKER_KINDS 的颜色);库规则的种类用规则给的颜色(`badge.color` 的 dark / light,webview 写在块的 style 上按主题挑),内置 SQLAlchemy 数据表 #e8a860 / #a3590d,Celery 应用 #98c379 / #4d7f17,定时任务 #56b6c2 / #127f8c,数据类 #ef7fb0 / #b3306c,FastAPI 接口 #61afef / #1a6fb5,FastAPI 路由器 #7aa2f7 / #34548a。编辑状态:底色 `--vscode-editor-background`,边框 `--vscode-focusBorder`。
## 代码位置
@@ -1911,8 +1942,8 @@ vscode/src/codeReader/
rules/enum.ts 内置 enum 库规则(Python):枚举类认成枚举块,成员照 Python 的规则认
rules/dataclass.ts 内置 dataclasses 库规则(Python):数据类卡片,字段照 dataclasses 的规则认
rules/fastapi.ts 内置 FastAPI 库规则(Python):路由器卡片(接口表)、接口卡片(请求表、响应表),参数照 FastAPI 的规则放,异常类的状态码、类型别名、枚举经索引查
rules/react.ts 内置 React 库规则(TSX / JSX):组件卡片(名称标签、属性表、状态 / 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理、渲染大纲),副作用、处理函数的子卡片,导入的属性类型经索引查
rules/reactTree.ts React 规则读语法树的办法:认组件、属性、组件体里的钩子和处理函数、名字用在哪、渲染的 JSX 大纲、别的文件里的属性类型(纯函数)
rules/react.ts 内置 React 库规则(TSX / JSX):组件卡片(名称标签、构造参数、状态 / 引用 / 读取的数据 / 派生值 / 副作用 / 事件处理、渲染大纲),副作用、处理函数的子卡片,导入的属性类型经索引查
rules/reactTree.ts React 规则读语法树的办法:认组件、构造参数、组件体里的钩子和处理函数、名字用在哪、渲染的 JSX 大纲、别的文件里的属性类型(纯函数)
commandRules.ts 命令规则的宿主部分:命令名 → 规则(先注册的算),规则出错记下、当它没读
commands/reading.ts 命令规则共用的:认选项(连写拆开、带值的、长选项、--)、拼译文(动作、括号里的意思、照抄不认识的选项)
commands/systemCommands.ts 内置命令规则:新建用户 / 用户组(useradd、adduser、addgroup / groupadd)、gpg、install、chown,管 '*' 的只问版本的命令
@@ -1929,6 +1960,15 @@ vscode/src/codeReader/
languages/shared.ts 语言插件共用的建树工具;异常类(withExceptions:按整份文件认异常类,类块换成异常块,并类属性、取构造参数)
languages/typescript.ts (call:调用的结构,new 是新建;TYPESCRIPT_EXCEPTIONS:认异常类的父类名、类字段、constructor)
languages/python.ts (iteration:推导式的结构;call:调用的结构;PYTHON_EXCEPTIONS:自带的异常类、类属性、__init__)
languages/rust.ts Rust:代码块树
rules/rustTree.ts Rust 库规则读语法树的办法(可见性、属性、字段、变体、match)
rules/rustRows.ts Rust 属性表的行(中文直译和库规则共用)
rules/rustImpl.ts impl 的方法放进方法表那一行
rules/serde.ts serde:可序列化的结构体
rules/thiserror.ts thiserror:错误枚举
rules/axum.ts axum:接口
rules/sqlx.ts sqlx:查询
rules/tauri.ts tauri:桌面命令
languages/displayNames.ts 中文名的共用部分:第一小句、行尾记号、按优先级汇总
languages/pythonNames.ts Python 的中文名写法(Annotated、@Comment、行尾记号、docstring)
languages/bash.ts bash / sh / zsh(shellscript):代码块树、facts(解释器、选项、按位置的参数、输出、返回)、command(命令的结构、重定向)、symbols(同一文件里的函数、变量)
@@ -2016,6 +2056,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 语言插件(`languages/bash.test.ts`,真实解析):`fixtures/fetchRuntime.sh` 的代码块树(种类、行号、层级,同原型 `SHELL_BLOCKS`,原型略去的行也在);elif / else 展平、while / for 的主体是子块、条件串和命令串;解释器(env、写死的路径)、选项(连写拆开、`-o`、`+`、`set --` 不是)、变量(local 一句几个、没值的、readonly 和顶层全大写写死的是常量)、函数(按位置的参数和名字、打印的几句、被 `$(…)` 拿去用、写了的 return);命令的词(写死的文字、有展开的没有)和重定向;同一文件里的名字(函数、赋值、local、for、read、展开,第几个参数不算);行尾 `# @Comment`。
- 中文直译(`translators/bashZh.test.ts`,走 `readBlockViews`):`fetchRuntime.sh` 整份逐行同 `FETCH_READING`(即上面「Shell 脚本」的样例);展开(长度、去头去尾、截取、没设时、替换、特殊变量)、条件、重定向、命令规则(不认识的选项照抄、没有规则的照抄)、`&&` / `||`、遍历、`$(dirname "$0")`、常量;字符串色 / 变量色 / 常量色;规则出错照抄命令并报告。
- 宿主:命令规则按语言、注册顺序给(`registry.test.ts`),shellscript 的 document 消息带命令规则读的译文、shell 的种类名、同一文件里的名字(`editor/documentMessage.test.ts`),跳到之前最近的定义、定义处算写入(`localSymbols.test.ts`)。
- Rust(`languages/rust.test.ts`、`rules/rustLibs.test.ts`,真实解析):`fixtures/rustLang.rs` 的导入、结构体、枚举、实现(方法放进方法表那一行)、函数(异步、分情况)、特质逐行同预览;给结构体加一个字段,字段表那一行标成新增。`fixtures/rustLibs.rs` 的 serde(改名、不输出、字段名改驼峰)、thiserror(展示、透传来源)、axum(方法、路径、路径参数)、sqlx、tauri 逐行同预览;没导入 serde 时 derive 仍是结构体;改 axum 的路径,路径那一格标成改过。`axum::routing` 这种来源算导入了 axum。
- Dockerfile:
- 语言插件(`languages/dockerfile.test.ts`,真实解析):两份 luffy 的 Dockerfile 的代码块树(同原型,后端的三行注释是阶段说明);并块(紧挨着才并,空行、注释、别的指令隔开,COPY 要从同一个地方,ADD、heredoc 的 COPY 不并,第一个 FROM 之前的是顶层);ONBUILD 和各种设置;facts(解析指令、阶段的名称 / 序号 / 说明 / 标签 / 摘要 / 平台 / 接着前面的阶段、构建参数和环境变量并块、健康检查两种写法和 NONE、启动);嵌着的 shell 的几段(续行去掉、heredoc 正文);数组写法的命令(VOLUME、SHELL 的数组不算);同一文件里的名字(阶段名在 --from、--mount 的 from、FROM 里,变量);中文名;着色。
- 中文直译(`translators/dockerfileZh.test.ts`,走 `readBlockViews`,RUN 交给 bash 的插件):两份 luffy 的 Dockerfile 整份逐行同 `NEXT_READING`、`BACKEND_READING`;阶段的各种写法、变量的展开和注解、RUN 的选项 / 数组写法 / heredoc / 一条循环 / 带重定向的一串、没有 shell 插件时照抄、复制和 ADD、设置、健康检查、启动;阶段名和表里变量名的源码下标、RUN 的源码按 shell 着色。
@@ -2033,7 +2074,7 @@ vscode/webview-ui/code-reader.html + src/codeReader/ 预览界面(React,Vit
- 数据类(`rules/dataclass.test.ts`):`fixtures/dataclassModels.py` 的 `SandboxAccess` 逐字同上面的样例,`AdminUserRow`、`RedirectPayResult`(`field(init=False, default=…)`)、`SmsSent`(`default_factory=dict`)的行;种类 `dataclasses.dataclass`、叫「数据类」、颜色、访问级别同原来的类;编辑范围是整个类、写回原样,`__post_init__` 是子块;类名、字段名的定义范围和带下标的词指回源码原文;装饰器的写法(别名、`dataclasses.dataclass`、`dc.dataclass`)认,pydantic 的、`other.dataclass` 不认,没导入 dataclasses 不认;每个选项的译法、默认值不讲、别的照写、`**x`、别的装饰器一行;字段规则(`ClassVar`、`typing.ClassVar` 留作子块,`InitVar` 的类型和「仅初始化时使用」,`KW_ONLY` 记号并进来、后面的字段「只能按名字传参」、单独写 `kw_only=False` 的「可按位置传参」),`field(...)` 的默认值、`default_factory`(名字、lambda)、各参数、`metadata` 照写、`doc=`,说明取上面的注释块、行尾注释、属性 docstring;没注解的类属性、字段中间的方法照源码顺序是子块,「编辑」写回整个类。
- Pydantic 数据模型(`rules/pydantic.test.ts`):`fixtures/aigcMetadata.py` 的 `AIGCMetadata` 逐字同上面的样例(7 个无默认值字段、选项「严格 · 禁止多余字段」、`model_config` 不单成一块),`TextTrace` 的可空字段默认值是「空」;`fixtures/createShareRequest.py` 的 `CreateShareRequest` 逐字核对字段、说明、长度约束,`@field_validator` 仍是子块;种类 `pydantic.model`、叫「数据模型」、颜色、图标 `braces`;同一文件里继承模型的也算,`TypedDict`、`NamedTuple`、普通类、没导入 pydantic 的不是;别名、`Field` 的默认值 / `default_factory` / 约束、说明优先于注释,`ClassVar` 和没注解的类属性留下;类型里的名字带源码下标;索引只收本文件里能确定的模型、没写 `BaseModel` 的文件不解析,别的文件里的父类查不到先当普通类、查到再换成卡片;差异:加一个字段标新增,改一个默认值标那一格。
- FastAPI 接口(`rules/fastapi.test.ts`):`fixtures/routes.py` 的 `update_user` 逐字同上面的样例(查到 `Forbidden` 是 403 时四条消息写在一行,没查到时不列),名字、颜色、图标、列名、筛选属性、定义范围、编辑范围是整个函数、函数体是子块;着色(URL 的 `{参数}` / 路径,花括号里的参数名 `refers` 到函数的参数,方法的 Swagger 颜色、成功的状态码绿色、自动带的 422 淡色,带下标的词指回源码原文);`fixtures/skillHubRoutes.py` 的 `list_skills`(Annotated 里的 Query、`_` 的依赖)、`get_skill`(HTTPException 的 status_code / detail)、`install`(Request 不算、`-> None` 没有内容);`fixtures/consoleRoutes.py` 的 `create_user`(`status_code=201`、Pydantic 模型、调别的函数间接抛的不算)、`list_users`(查到的类型别名 `FilterQuery` 是依赖、Literal 是简单类型);参数放在哪儿的各条(路径里的名字含 `{x:path}`、各种标记两种写法、上传文件、枚举和类型别名查到的、Pydantic 模型、`Security`、`Depends()`);标签、依赖、`responses=` 叠上路由对象的,`status.HTTP_…` 常量、`response_model=None`、`response_description`、`raise … from`、只写 `raise`、嵌套函数里的;`FastAPI()` 上的接口、别的装饰器一行;不认的(没导入、路由对象在别的文件、不是路由对象、`websocket`、不带括号)。跨文件(工作区索引 + 假的语言扩展):`update_user` 的 `Forbidden` 按定义处查到 `core/exceptions/schemas.py` 的 403,同名的别处的类不算;索引认的类上的 `status_code`(带注解、常量)、枚举类、`Annotated` 类型别名,普通类不认。路由器卡片:`fixtures/routes.py` 的 `router` 逐字同上面的样例,名字、颜色、图标、列名、编辑范围、定义范围;接口表的函数名带下标、路径里的 `{user_id}` 各 `refers` 到各自函数的参数、方法的颜色;接口卡片的「路由器」行排在说明后面、名字带下标指回路由器;写了依赖、默认响应才有这两行,`FastAPI()` 没有前缀,属性 docstring 是说明,没挂接口时「0 个」、不画表格。
- React 组件(`rules/reactTree.test.ts`、`rules/react.test.ts`,luffy 的七个真实组件):认组件(七个文件里的组件、小写工具函数和不渲染 JSX 的不认;memo / forwardRef 包着的、默认导出、文件末尾 export 的、null 和 JSX 混着返回的);属性(行内类型字面量、同文件接口带 JSDoc、回调、children、默认值、改名、其余属性、方法签名、`FC<Props>`、`forwardRef<R, Props>`、导入的类型查不到和查到了);组件体归到哪儿、名字用在哪(副作用、渲染时重置、挂在 `<main>` 的 ref、处理函数绑在每个按钮上);渲染大纲(原生标签、组件、类、条件、三元、列表、片段、注释、几处返回)和 className 的写法。卡片:`fixtures/desktopTitlebar.tsx.txt` 逐字同上面的样例、没有留下的子块,`"use client"` 和 JSDoc 不单独成块;LegalDocPage 的各分区逐行、副作用 / 处理函数子卡片和它们放在哪一行、样式小标点开的全文;ShareButton 的 useCallback、只在挂载时的副作用;PosterDialog 没绑在 JSX 上的处理函数;差异:加一个属性、改一个副作用的依赖,组件卡片属性表那一行新加、副作用「时机」逐截标。宿主:`libraries` 空的规则都启用;略去的块不出块、有子块的不能略去;放进卡片里的子块记下行 / 子行,不合约定的记下来。webview:放进卡片里的子块画在那一行下面(`BlockTree.test.tsx`),点子行才展开的默认收着、名称是按钮、展开时那一行垫上种类色;默认折叠、跳到定义 / 上一处下一处 / 只看改动展开的是那一行(`viewState`、`reveal`、`diffView` 的测试);小标签、条件小框、类名小标的样子(`SegmentText.test.tsx`)。
- React 组件(`rules/reactTree.test.ts`、`rules/react.test.ts`,luffy 的七个真实组件):认组件(七个文件里的组件、小写工具函数和不渲染 JSX 的不认;memo / forwardRef 包着的、默认导出、文件末尾 export 的、null 和 JSX 混着返回的);构造参数(行内类型字面量、同文件接口带 JSDoc、回调、children、默认值、改名、其余属性、方法签名、`FC<Props>`、`forwardRef<R, Props>`、导入的类型查不到和查到了);组件体归到哪儿、名字用在哪(副作用、渲染时重置、挂在 `<main>` 的 ref、处理函数绑在每个按钮上);渲染大纲(原生标签、组件、类、条件、三元、列表、片段、注释、几处返回)和 className 的写法。卡片:`fixtures/desktopTitlebar.tsx.txt` 逐字同上面的样例、没有留下的子块,`"use client"` 和 JSDoc 不单独成块;LegalDocPage 的各分区逐行、副作用 / 处理函数子卡片和它们放在哪一行、样式小标点开的全文;ShareButton 的 useCallback、只在挂载时的副作用;PosterDialog 没绑在 JSX 上的处理函数;差异:加一个属性、改一个副作用的依赖,组件卡片属性表那一行新加、副作用「时机」逐截标。宿主:`libraries` 空的规则都启用;略去的块不出块、有子块的不能略去;放进卡片里的子块记下行 / 子行,不合约定的记下来。webview:放进卡片里的子块画在那一行下面(`BlockTree.test.tsx`),点子行才展开的默认收着、名称是按钮、展开时那一行垫上种类色;默认折叠、跳到定义 / 上一处下一处 / 只看改动展开的是那一行(`viewState`、`reveal`、`diffView` 的测试);小标签、条件小框、类名小标的样子(`SegmentText.test.tsx`)。
- 规则查别的文件里的定义(`ruleLookups.test.ts`):第一遍当没查到、记下问了什么(同一索引同名的只记一处);查到后叫重排,交出定义处正是的那一项;同一版本不再查;索引里没有同名的不问语言扩展,定义处不是索引里的项算没有,没变不叫重排;新版本先用上一版的、再查、变了才叫重排;语言扩展没答时已知的不动、隔一会儿再查。
- 带自己颜色的词(webview `SegmentText.test.tsx`):`cr-colored`,暗色、亮色写在 style 上;讲别处名字的段 `data-offset` 记那个名字的下标。
- 异常(`languages/exceptions.test.ts`,真实解析 + 中文直译 + 视图数据):`fixtures/appExceptions.py` 的 `AppException`、`NotFound` 逐字同「异常」的样例(列名随有没有类型、说明变),`GatewayTimeout` 的多段说明,方法是子块;`fixtures/commerceExceptions.py` 16 个类都是异常、只有说明的四行、写了 `__init__` 的构造参数;认哪些类(自带的异常类、`Warning`、不合后缀的自带类 `StopIteration` / `KeyboardInterrupt`、`pkg.errors.AppException`、同一文件里写在后面的父类一层层往下传、泛型和别的父类混写,关键字参数、`Mapping[...]`、`*bases` 不算,函数里的类也认);类属性(带不带注解、只写注解、说明取上面的注释块 / 行尾注释 / 属性 docstring,连着赋值、解构赋值、方法、讲方法的注释、最后面的注释留作子块),构造参数去掉 self、`*args` 开头的不去;`@dataclass` 的异常类照样是异常卡片;编辑范围是整个类、改卡片写回整个类,类名、属性名、参数名的定义范围和带下标的词指回源码原文;TypeScript `fixtures/exceptions.ts.txt`(`extends Error`、类字段含 static、参数属性、JSDoc、写在后面的父类、类表达式、`ns.X<T>`,父类名不合习惯的照常是类)、JavaScript 的类字段;TypeScript 和 Python 同样的异常得到一样的行和列名;筛选的种类叫「异常」、没有 badge;属性的中文名和去掉行尾记号。webview:异常块默认折叠(`viewState.test.ts`),跳到类名闪整块、属性名 / 构造参数名只闪表里那一行(`reveal.test.ts`)。
+2
View File
@@ -32,6 +32,8 @@ const TREE_SITTER_WASM = [
'tree-sitter-javascript.wasm',
'tree-sitter-python.wasm',
'tree-sitter-bash.wasm',
// 包入口就在 wasm/(main 指向 wasm/tree-sitter.js),rust 的语法包和 python 的在同一层
'tree-sitter-rust.wasm',
]
// @vscode/tree-sitter-wasm 没有的语法包,从 tree-sitter-wasm(预编译、签过名的各家语法包)拿:语言名 → 它的 wasm
+3 -3
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra",
"name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra",
"version": "0.2.235",
"version": "0.2.238",
"packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra",
@@ -379,7 +379,7 @@
"editor/title": [
{
"command": "superpowers.codeReader.showPreview",
"when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript|dockerfile)$/ || resourceLangId in superpowers.codeReader.languageIds)",
"when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript|dockerfile|rust)$/ || resourceLangId in superpowers.codeReader.languageIds)",
"group": "navigation"
},
{
@@ -411,7 +411,7 @@
},
{
"command": "superpowers.codeReader.showPreview",
"when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript|dockerfile)$/ || resourceLangId in superpowers.codeReader.languageIds)"
"when": "activeEditor == workbench.editors.files.textFileEditor && (resourceLangId =~ /^(typescript|typescriptreact|javascript|javascriptreact|python|shellscript|dockerfile|rust)$/ || resourceLangId in superpowers.codeReader.languageIds)"
},
{
"command": "superpowers.codeReader.showSource",
+4
View File
@@ -26,6 +26,10 @@ function factAttributes(block: CodeBlock): Partial<Record<CommonAttribute, strin
return { access: block.facts.access, async: flag(block.facts.async) }
case 'class':
return { access: block.facts.access, abstract: flag(block.facts.abstract) }
case 'trait':
case 'impl':
case 'module':
return { access: block.facts.access }
case 'enum':
case 'exception':
case 'const':
+7
View File
@@ -144,6 +144,7 @@ function ownName(block: CodeBlock): Node | undefined {
case 'class':
case 'enum':
case 'exception':
case 'module':
return block.facts.name
case 'const':
case 'variable':
@@ -289,6 +290,12 @@ export function toBlockViews(
translate: node => layerTranslator ? layerTranslator.expression(node, layerDocument) : [{ text: node.text, role: 'plain' }],
doc: node => layerTranslator?.doc(node, layerDocument),
lookup: layerRules.lookup ?? (() => undefined),
snippet: (node) => {
const startRow = node.startPosition.row
const end = node.endPosition
const endRow = end.column === 0 && end.row > startRow ? end.row - 1 : end.row
return sourceRows({ startIndex: node.startIndex, endIndex: node.endIndex, startRow, endRow })
},
}
/** 一行译文,连着摊开实参的表、块的说明(有才有)。 */
const textView = (block: TextBlock): { translation: PreviewText, table?: PreviewTable, doc?: Markdown } => {
+43 -3
View File
@@ -12,6 +12,7 @@ export type { Node }
*/
export type BlockKind
= | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable'
| 'trait' | 'impl' | 'module'
| 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment'
| 'interpreter' | 'options' | 'case' | 'command' | 'pipeline'
| 'directive' | 'stage' | 'run' | 'copy' | 'setting' | 'label' | 'health' | 'start'
@@ -474,6 +475,11 @@ export interface BlockShape {
/** 预览成属性表的块:语言插件从语法树读出结构化信息(facts),翻译插件把它排成属性表。 */
export interface FunctionBlock extends BlockShape { kind: 'function', facts: FunctionFacts }
export interface ClassBlock extends BlockShape { kind: 'class', facts: ClassFacts }
/** Rust 的 trait:方法只列签名,事实和类一样(名称、说明、可见性)。 */
export interface TraitBlock extends BlockShape { kind: 'trait', facts: ClassFacts }
/** Rust 的 impl:标题是「某类型 的方法」,方法是子块,放进方法表的某一行。 */
export interface ImplBlock extends BlockShape { kind: 'impl', facts: ClassFacts }
export interface ModuleBlock extends BlockShape { kind: 'module', facts: ClassFacts }
/** 枚举块的头部是整个枚举(成员都在属性表里讲),没有子块;库规则认出来的枚举(Python)是整个类,留下的方法等是子块。 */
export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts }
/** 异常块的头部是整个类(类属性都在属性表里讲),留下的方法(含构造函数)等是子块。 */
@@ -491,11 +497,11 @@ export interface StageBlock extends BlockShape { kind: 'stage', facts: StageFact
export interface HealthBlock extends BlockShape { kind: 'health', facts: HealthFacts }
export interface StartBlock extends BlockShape { kind: 'start', facts: StartFacts }
export type FactBlock
= | FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock
= | FunctionBlock | ClassBlock | TraitBlock | ImplBlock | ModuleBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock | InterpreterBlock | OptionsBlock
| DirectiveBlock | StageBlock | HealthBlock | StartBlock
/** 带说明(facts.doc)的块。 */
export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | VariableBlock
export type DocumentedBlock = FunctionBlock | ClassBlock | TraitBlock | ImplBlock | EnumBlock | ExceptionBlock | VariableBlock
/** 属性表块的种类和 facts,不带块的形状(节点、头部、子块):库规则把一块认成现成的一种属性表块时给它(RuleFacts)。 */
export type BlockFacts = FactBlock extends infer B ? B extends FactBlock ? Pick<B, 'kind' | 'facts'> : never : never
@@ -589,11 +595,40 @@ export type PreviewText = Inline[] | Markdown
*/
export interface Defines { defines?: SourceRange }
/**
* 能单独打开编辑的一段源码:这几行整行,去掉共同缩进,着色和块的 code / codeTokens 同一套切法。
* 网页不自己切。点铅笔之后只出现这一段,不换整张卡片。
*/
export interface SourceSnippet {
/** 起止行,0 起,含头含尾。 */
startLine: number
endLine: number
code: string
/** 着色,拼起来等于 code。 */
codeTokens: Segment[]
}
/**
* 属性表一个子行的某一格里,能单独打开的一段源码。
* cell 是第几格(0 起),from / to 是这一格文字里这一处的范围(含头不含尾)。
*/
export interface CellEdit {
cell: number
from: number
to: number
snippet: SourceSnippet
}
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */
export interface PropertyItem extends Defines {
cells: Inline[][]
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */
items?: PropertyItem[]
/**
* 这一行里能单独打开的几段源码(状态「改它的」的每一处、读取的数据的「来自」)。
* 打开后只出现那一段。没有能打开的就不写。
*/
edits?: CellEdit[]
/**
* 排在这一行最后一格里、那格文字下面的表:实参表里一个实参的值里有跨几行写的调用时,是那个调用的实参表,
* 表里的行又可以带 table,一层层往下套。总是展开的。
@@ -790,6 +825,9 @@ export const KIND_ICONS: Record<BlockKind, KindIconName> = {
import: 'import',
function: 'square-function',
class: 'box',
trait: 'network',
impl: 'square-function',
module: 'layers',
type: 'braces',
enum: 'list-ordered',
exception: 'triangle-alert',
@@ -909,6 +947,8 @@ export interface RuleContext {
displayLanguage?: string
/** 用当前翻译插件把一个表达式或类型节点翻成一行译文(Translator.expression):名字带源码下标,能跳到定义、高亮各处出现;没有翻译插件时照抄源码。 */
translate: (node: Node) => Inline[]
/** 一个节点占的整行源码和着色(见 SourceSnippet),和块视图切行是同一套。规则把它挂在译文旁边。 */
snippet: (node: Node) => SourceSnippet
/** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */
doc: (node: Node) => Markdown | undefined
/**
@@ -980,7 +1020,7 @@ export interface LibraryRule {
label: string // 'SQLAlchemy 数据表'
languageIds: string[]
/**
* 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」开头(它的子模块)才启用。
* 关心的库(模块名):这份源码里有一条导入(ImportFacts.sources,去掉引号)等于它、或以「它.」「它/」「它::」开头(它的子模块;Rust 的路径用 ::)才启用。
* 宿主按语言插件给的导入判断,规则不用自己读导入语句。空数组是不看导入、languageIds 里这几种语言的源码都启用
* (React 组件:新的 JSX 写法不用导入 react,TSX / JSX 文件里就是 React 组件)。
*/
@@ -0,0 +1,45 @@
use serde::Serialize;
use std::sync::Arc;
use foo::{Bar as Baz, Qux};
/// 桌面端列表里的一行代理。
/// 后面还有一句。
pub struct AgentRow {
pub id: Uuid,
name: String,
}
pub(crate) struct CrateOnly {
pub(super) up: u8,
}
/// 这个代理现在处在哪一步。
pub enum AgentState {
Idle,
Running { pid: u32 },
Failed(String),
}
impl AgentRow {
fn rename(&mut self, name: String) {}
}
/// 在本机拉起接口服务。
pub async fn start_server(addr: SocketAddr) -> Result<(), StartError> {
match result {
Ok(listener) => {
// 记下监听地址
self.listener = Some(listener);
}
Err(err) => {
// 返回 StartError
return Err(StartError);
}
}
}
/// 按 id 读写代理。
pub trait Store {
fn get(&self, id: Uuid) -> Result<AgentRow, StoreError>;
fn save(&mut self, row: AgentRow) -> Result<(), StoreError>;
}
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
/// 桌面端列表里的一行代理。
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentRow {
#[serde(rename = "agent_id")]
pub id: Uuid,
pub name: String,
#[serde(skip)]
secret: String,
}
use thiserror::Error;
/// 读写代理时出的错。
#[derive(Error, Debug)]
pub enum AgentError {
/// 这个 id 没有代理
#[error("agent {id} 不存在")]
NotFound { id: Uuid },
/// 调用上游失败
#[error("上游错误")]
Upstream(#[from] Other),
}
use axum::routing::get;
/// 按 id 取一个代理。
#[get("/agents/{id}")]
async fn get_agent(Path(id): Path<Uuid>) -> Result<Json<AgentRow>, StatusCode> {
todo!()
}
let app = Router::new().route("/health", get(health));
use sqlx::query_as;
// 按 id 取一行。
let row = sqlx::query_as!(AgentRow, "SELECT id, name FROM agents WHERE id = $1", id);
use tauri::State;
/// 让窗口把这一行改名。
#[tauri::command]
async fn rename_agent(state: State<App>, name: String) -> Result<String, String> {
todo!()
}
+16
View File
@@ -19,6 +19,7 @@ import { TreeFilters } from './explorer/treeFilters'
import { createBashAdapter } from './languages/bash'
import { createDockerfileAdapter } from './languages/dockerfile'
import { createPythonAdapter } from './languages/python'
import { createRustAdapter } from './languages/rust'
import { createTypeScriptAdapter } from './languages/typescript'
import { resolveReference } from './libraryRules'
import { indexedNames } from './nameResolution'
@@ -27,17 +28,24 @@ import { FileNotes } from './notes/fileNotes'
import { CodeQa } from './qa/codeQa'
import { CodeReaderRegistry } from './registry'
import { ReviewMode } from './review/reviewMode'
import { axumRule } from './rules/axum'
import { celeryRule } from './rules/celery'
import { dataclassRule } from './rules/dataclass'
import { enumRule } from './rules/enum'
import { fastapiRule } from './rules/fastapi'
import { pydanticRule } from './rules/pydantic'
import { reactRule } from './rules/react'
import { rustImplRule } from './rules/rustImpl'
import { serdeRule } from './rules/serde'
import { sqlalchemyRule } from './rules/sqlalchemy'
import { sqlxRule } from './rules/sqlx'
import { tauriRule } from './rules/tauri'
import { thiserrorRule } from './rules/thiserror'
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
import { bashZhTranslator } from './translators/bashZh'
import { dockerfileZhTranslator } from './translators/dockerfileZh'
import { pythonZhTranslator } from './translators/pythonZh'
import { rustZhTranslator } from './translators/rustZh'
import { typescriptZhTranslator } from './translators/typescriptZh'
import { UncommittedMode } from './uncommitted/uncommittedMode'
import { workspaceIndexHost } from './workspaceFiles'
@@ -147,10 +155,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
api.registerLanguage(createPythonAdapter(wasmDir)),
api.registerLanguage(createBashAdapter(wasmDir)),
api.registerLanguage(createDockerfileAdapter(wasmDir)),
api.registerLanguage(createRustAdapter(wasmDir)),
api.registerTranslator(typescriptZhTranslator),
api.registerTranslator(pythonZhTranslator),
api.registerTranslator(bashZhTranslator),
api.registerTranslator(dockerfileZhTranslator),
api.registerTranslator(rustZhTranslator),
...[...shellCommandRules, ...systemCommandRules, ...packageCommandRules].map(rule => api.registerCommandRule(rule)),
api.registerLibraryRule(sqlalchemyRule),
api.registerLibraryRule(celeryRule),
@@ -159,6 +169,12 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
api.registerLibraryRule(pydanticRule),
api.registerLibraryRule(fastapiRule),
api.registerLibraryRule(reactRule),
api.registerLibraryRule(rustImplRule),
api.registerLibraryRule(serdeRule),
api.registerLibraryRule(thiserrorRule),
api.registerLibraryRule(axumRule),
api.registerLibraryRule(sqlxRule),
api.registerLibraryRule(tauriRule),
)
return api
}
@@ -0,0 +1,182 @@
import type { BlockView } from '../blockView'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { blockMarks } from '../diff/viewDiff'
import { rustImplRule } from '../rules/rustImpl'
import { readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { rustZhTranslator } from '../translators/rustZh'
import { createRustAdapter } from './rust'
const adapter = createRustAdapter(WASM_DIR)
const sample = readFixture('rustLang.rs')
function views(source: string): Promise<BlockView[]> {
return withBlocks(adapter, 'rust', source, blocks =>
toBlockViews(blocks, { source, language: adapter, markers: [] }, rustZhTranslator, {
rules: [rustImplRule],
report: () => {},
}))
}
function card(list: BlockView[], kind: string): BlockView {
const found = list.find(block => block.kind === kind)
if (!found)
throw new Error(`没有 ${kind}`)
return found
}
describe('rust 语言卡片', () => {
it('use、结构体、枚举、实现、函数、特质', async () => {
expect(viewLines(await views(sample))).toEqual([
'import 1',
' 来源: serde',
' 导入项: 1 个',
' Serialize | —',
'import 2',
' 来源: std::sync',
' 导入项: 1 个',
' Arc | —',
'import 3',
' 来源: foo',
' 导入项: 2 个',
' Bar | Baz',
' Qux | —',
'class 5',
' 名称: AgentRow',
' 说明: 桌面端列表里的一行代理。',
' 可见性: 公开',
' 字段: 2 个',
' id | Uuid | 公开',
' name | String | 本模块',
'class 12',
' 名称: CrateOnly',
' 可见性: 本 crate',
' 字段: 1 个',
' up | u8 | 父模块',
'enum 16',
' 名称: AgentState',
' 说明: 这个代理现在处在哪一步。',
' 变体: 3 个',
' Idle | —',
' Running | { pid: u32 }',
' Failed | String',
'rust.impl 23',
' 名称: AgentRow 的方法',
' 方法: 1 个',
' rename | &mut self, name: String | ()',
' function 24',
' 名称: rename',
' 可见性: 本模块',
' 参数: 2 个',
' self | &mut self',
' name | String',
' 返回: ()',
'function 27',
' 名称: start_server',
' 说明: 在本机拉起接口服务。',
' 异步: 是',
' 可见性: 公开',
' 输入参数: 1 个',
' addr | SocketAddr',
' 返回: Result<(), StartError>',
' 分情况: 2 支',
' Ok(listener) | 记下监听地址',
' Err(err) | 返回 StartError',
'trait 41',
' 名称: Store',
' 说明: 按 id 读写代理。',
' 方法: 2 个',
' get | &self, id: Uuid | Result<AgentRow, StoreError>',
' save | &mut self, row: AgentRow | Result<(), StoreError>',
])
})
it('实现的方法放进方法表那一行', async () => {
const impl = card(await views(sample), 'rust.impl')
const [method] = impl.children
expect(method?.slot).toEqual({ row: 1, item: 0 })
})
it('给结构体加一个字段,多出来的那一行标成新增', async () => {
const before = 'pub struct AgentRow {\n name: String,\n}\n'
const after = 'pub struct AgentRow {\n name: String,\n id: Uuid,\n}\n'
const [oldCard] = await views(before)
const [newCard] = await views(after)
const marks = oldCard && newCard ? blockMarks(oldCard, newCard) : undefined
const field = newCard && 'properties' in newCard ? newCard.properties.findIndex(row => row.label === '字段') : -1
expect(field).toBeGreaterThanOrEqual(0)
expect(marks?.rows?.changed[field]).toEqual({
items: { changed: { 1: { added: true } }, removed: [] },
})
})
it('带身体的模块拆成子块,常量和外部函数是卡片', async () => {
const source = [
'/// 平台相关。',
'pub mod platform {',
' /// 读入。',
' use std::io::Read;',
' /// 上限。',
' pub const LIMIT: u32 = 8;',
' static NAME: &str = "a";',
' pub fn open() -> u32 { LIMIT }',
' extern "C" {',
' /// 外部。',
' pub fn bar(x: i32) -> i32;',
' }',
' let local = 1;',
' println!("hi");',
'}',
'mod other;',
'',
'impl Agent {',
' pub const N: u8 = 2;',
'}',
'',
].join('\n')
const list = await views(source)
const mod = list.find(block => block.kind === 'module')
expect(mod?.kind).toBe('module')
expect(list.some(block => block.kind === 'statement' && block.code.includes('const LIMIT'))).toBe(false)
expect(list.filter(block => block.kind === 'statement').map(block => block.code)).toEqual(['mod other;'])
expect(mod?.children.map(block => block.kind)).toEqual(['import', 'const', 'variable', 'function', 'function', 'statement', 'statement'])
expect(viewLines(mod ? [mod] : [])).toEqual([
'module 1',
' 名称: platform',
' 说明: 平台相关。',
' 可见性: 公开',
' import 3',
' 来源: std::io',
' 导入项: 1 个',
' Read | —',
' const 5',
' 名称: LIMIT',
' 说明: 上限。',
' 访问级别: 公共',
' 类型: u32',
' 值: 8',
' variable 7',
' 名称: NAME',
' 类型: &str',
' 初始值: "a"',
' function 8',
' 名称: open',
' 可见性: 公开',
' 返回: u32',
' function 10',
' 名称: bar',
' 说明: 外部。',
' 可见性: 公开',
' 输入参数: 1 个',
' x | i32',
' 返回: i32',
' statement 13: 令 local = 1;',
' statement 14: println!("hi")',
])
const impl = list.find(block => block.kind === 'rust.impl')
const inner = impl?.children.find(block => block.kind === 'const')
expect(inner?.kind).toBe('const')
expect(viewLines(inner ? [inner] : []).join('\n')).toContain('名称: N')
expect(viewLines(inner ? [inner] : []).join('\n')).toMatch(/类型: u8|值: 2/)
})
})
+320
View File
@@ -0,0 +1,320 @@
import type { Access, ClassBlock, ClassFacts, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, ImportItem, LanguageAdapter, ModuleBlock, Node, ParameterFacts, TokenRole, TraitBlock, VariableBlock } from '../contract'
import * as path from 'node:path'
import { leadingAttributes, visibilityFacts } from '../rules/rustTree'
import { compoundShape, extendHeader, headerSpan, leafBlock, leafShape, named } from './shared'
const STORAGE = new Set(['pub', 'async', 'const', 'static', 'mut', 'let'])
export function createRustAdapter(wasmDir: string): LanguageAdapter {
return {
id: 'rust',
languageIds: ['rust'],
grammarPath: () => path.join(wasmDir, 'tree-sitter-rust.wasm'),
toBlocks: root => topBlocks(named(root)),
tokenRole: rustTokenRole,
}
}
/** extern 块本身不成一块源码,里面的签名跟所在这一层的别的项走同一条路。 */
function unwrapExtern(item: Node): Node[] {
if (item.type !== 'foreign_mod_item')
return [item]
const body = item.childForFieldName('body')
return body ? named(body).flatMap(unwrapExtern) : []
}
function topBlocks(items: Node[]): CodeBlock[] {
const list = items.flatMap(unwrapExtern)
const blocks: CodeBlock[] = []
let index = 0
while (index < list.length) {
const item = list[index]
if (isDoc(item)) {
const docs = [item]
let next = index + 1
while (next < list.length && isDoc(list[next]) && list[next].startPosition.row <= docs[docs.length - 1].endPosition.row + 1)
docs.push(list[next++])
while (next < list.length && list[next].type === 'attribute_item')
next++
const target = list[next]
if (target && target.type !== 'line_comment' && target.type !== 'block_comment' && target.type !== 'empty_statement') {
blocks.push(withPreamble(itemBlock(target), docs))
index = next + 1
continue
}
}
if (item.type === 'attribute_item' || item.type === 'empty_statement') {
index++
continue
}
if (item.type === 'line_comment' || item.type === 'block_comment')
blocks.push(leafBlock('comment', item))
else
blocks.push(withPreamble(itemBlock(item), []))
index++
}
return blocks
}
function itemBlock(item: Node): CodeBlock {
switch (item.type) {
case 'use_declaration':
return useBlock(item)
case 'struct_item':
return structBlock(item)
case 'enum_item':
return enumBlock(item)
case 'impl_item':
return implBlock(item)
case 'trait_item':
return traitBlock(item)
case 'mod_item':
return moduleBlock(item)
case 'const_item':
return valueBlock(item, 'const')
case 'static_item':
return valueBlock(item, 'variable')
case 'function_item':
case 'function_signature_item':
return functionBlock(item)
default:
return leafBlock('statement', item)
}
}
/** 没有身体的 `mod 名字;` 不拆。有身体的模块,身体和文件顶层同一套规则。 */
function moduleBlock(node: Node): CodeBlock {
const body = node.childForFieldName('body')
if (!body)
return leafBlock('statement', node)
const { access, attributes } = visibilityFacts(node)
return {
kind: 'module',
...compoundShape(node, body.startIndex, named(body), items => topBlocks(items)),
...(attributes && { attributes }),
facts: classFacts(node, access),
}
}
/** const 是常量,static 是变量;名称、类型、值交给现成的常量 / 变量属性表。 */
function valueBlock(node: Node, kind: 'const' | 'variable'): CodeBlock {
const name = node.childForFieldName('name')
if (!name)
return leafBlock('statement', node)
const type = node.childForFieldName('type') ?? undefined
const value = node.childForFieldName('value') ?? undefined
const { access, attributes } = visibilityFacts(node)
return {
kind,
...leafShape(node),
...(attributes && { attributes }),
facts: { access, declarators: [{ name, type, value }] },
}
}
/** 文档注释和写在前面的属性算进头部。文档注释的第一句是说明。 */
function withPreamble(block: CodeBlock, docs: Node[]): CodeBlock {
const [doc] = docs
const attribute = leadingAttributes(block.node)[0]
let next = block
if (doc && isDocumentedKind(next))
next = { ...extendHeader(next, doc), facts: { ...next.facts, doc } } as typeof next
const from = doc ?? attribute
if (from && next.header.startIndex > from.startIndex)
next = extendHeader(next, from)
return next
}
function isDocumentedKind(block: CodeBlock): block is FunctionBlock | ClassBlock | TraitBlock | ImplBlock | ModuleBlock | EnumBlock | VariableBlock {
return block.kind === 'function' || block.kind === 'class' || block.kind === 'trait' || block.kind === 'impl' || block.kind === 'module' || block.kind === 'enum' || block.kind === 'const' || block.kind === 'variable'
}
function isDoc(node: Node): boolean {
return (node.type === 'line_comment' || node.type === 'block_comment') && named(node).some(child => child.type === 'outer_doc_comment_marker')
}
function useBlock(node: Node): ImportBlock {
const argument = node.childForFieldName('argument')
const { source, items } = argument ? useParts(argument) : { items: [] }
return { kind: 'import', ...leafShape(node), facts: { sources: source ? [source] : [], items } }
}
function useParts(node: Node): { source?: Node, items: ImportItem[] } {
switch (node.type) {
case 'scoped_identifier': {
const source = node.childForFieldName('path') ?? undefined
const name = node.childForFieldName('name')
return { source, items: name ? [{ kind: 'named', name }] : [] }
}
case 'scoped_use_list': {
const source = node.childForFieldName('path') ?? undefined
const list = node.childForFieldName('list')
return { source, items: list ? useList(list) : [] }
}
case 'use_list':
return { items: useList(node) }
case 'use_as_clause':
return { items: asItem(node) }
case 'use_wildcard':
return { items: [{ kind: 'namespace' }] }
case 'identifier':
return { items: [{ kind: 'named', name: node }] }
default:
return { items: [] }
}
}
function useList(list: Node): ImportItem[] {
return named(list).flatMap((child) => {
if (child.type === 'identifier')
return [{ kind: 'named' as const, name: child }]
if (child.type === 'use_as_clause')
return asItem(child)
if (child.type === 'use_wildcard')
return [{ kind: 'namespace' as const }]
if (child.type === 'scoped_identifier') {
const name = child.childForFieldName('name')
return name ? [{ kind: 'named' as const, name }] : []
}
return []
})
}
function asItem(node: Node): ImportItem[] {
const name = node.childForFieldName('path')
const alias = node.childForFieldName('alias') ?? undefined
return name ? [{ kind: 'named', name, alias }] : []
}
function structBlock(node: Node): CodeBlock {
const { access, attributes } = visibilityFacts(node)
return {
kind: 'class',
...shape(node),
...(attributes && { attributes }),
facts: classFacts(node, access),
}
}
function enumBlock(node: Node): EnumBlock {
const { access, attributes } = visibilityFacts(node)
const body = node.childForFieldName('body')
const members = named(body).flatMap((variant) => {
if (variant.type !== 'enum_variant')
return []
const name = variant.childForFieldName('name')
if (!name)
return []
const payload = variant.childForFieldName('body') ?? undefined
return [{ name, ...(payload && { value: payload }), docs: [] }]
})
return {
kind: 'enum',
...shape(node),
...(attributes && { attributes }),
facts: { name: node.childForFieldName('name') ?? node, doc: undefined, access, decorators: [], bases: [], members },
}
}
function traitBlock(node: Node): TraitBlock {
const { access, attributes } = visibilityFacts(node)
return { kind: 'trait', ...shape(node), ...(attributes && { attributes }), facts: classFacts(node, access) }
}
function implBlock(node: Node): ImplBlock {
const { access, attributes } = visibilityFacts(node)
const body = node.childForFieldName('body')
const children = body ? topBlocks(named(body)) : []
return {
kind: 'impl',
...compoundShape(node, body?.startIndex ?? node.endIndex, [], () => children),
...(attributes && { attributes }),
facts: classFacts(node, access, implType(node)),
}
}
function functionBlock(node: Node): FunctionBlock {
const { access, attributes } = visibilityFacts(node)
const body = node.childForFieldName('body')
const headerEnd = body?.startIndex ?? node.endIndex
return {
kind: 'function',
node,
header: headerSpan(node, headerEnd),
children: [],
...(attributes && { attributes }),
facts: {
name: node.childForFieldName('name') ?? undefined,
access,
...(isAsync(node) && { async: true }),
decorators: leadingAttributes(node),
parameters: parametersOf(node.childForFieldName('parameters')),
returnType: node.childForFieldName('return_type') ?? undefined,
},
}
}
function classFacts(node: Node, access: Access | undefined, name = node.childForFieldName('name') ?? undefined): ClassFacts {
return { name, access, decorators: leadingAttributes(node), typeParameters: [], bases: [], implements: [] }
}
/** impl 的自己的类型:impl Trait for Type 取 Type,impl Type 取 Type。 */
function implType(node: Node): Node | undefined {
return node.childForFieldName('type') ?? node.childForFieldName('trait') ?? undefined
}
function shape(node: Node): { node: Node, header: { startIndex: number, endIndex: number, startRow: number, endRow: number }, children: CodeBlock[] } {
const body = node.childForFieldName('body')
return body ? { node, header: headerSpan(node, body.startIndex), children: [] } : { ...leafShape(node) }
}
function parametersOf(list: Node | null): ParameterFacts[] {
if (!list)
return []
return named(list).flatMap((child) => {
if (child.type === 'self_parameter') {
const name = [...child.children].reverse().find(token => token?.type === 'self')
return name ? [{ name, optional: false, type: child }] : []
}
if (child.type !== 'parameter')
return []
const name = child.childForFieldName('pattern')
const type = child.childForFieldName('type') ?? undefined
return name ? [{ name, optional: false, type }] : []
})
}
function isAsync(node: Node): boolean {
const modifiers = named(node).find(child => child.type === 'function_modifiers')
return modifiers?.children.some(child => child?.type === 'async') ?? false
}
function rustTokenRole(leaf: Node): TokenRole {
if (leaf.type === 'line_comment' || leaf.type === 'block_comment' || inside(leaf, 'line_comment') || inside(leaf, 'block_comment'))
return 'comment'
if (inside(leaf, 'string_literal') || leaf.type === 'string_content')
return 'string'
if (leaf.type === 'integer_literal' || leaf.type === 'float_literal' || leaf.type === 'char_literal')
return 'number'
if (leaf.type === 'primitive_type' || leaf.type === 'type_identifier')
return 'type'
if (leaf.type === 'mutable_specifier')
return 'keyword'
if (!leaf.isNamed)
return STORAGE.has(leaf.type) ? 'storage' : /^[a-z]+$/.test(leaf.type) ? 'keyword' : 'plain'
if (leaf.type === 'identifier' || leaf.type === 'field_identifier' || leaf.type === 'self') {
const parent = leaf.parent?.type
return parent === 'function_item' || parent === 'function_signature_item' ? 'function' : 'variable'
}
return 'plain'
}
function inside(node: Node, type: string): boolean {
let current = node.parent
while (current) {
if (current.type === type)
return true
current = current.parent
}
return false
}
@@ -209,6 +209,9 @@ function ownDoc(block: CodeBlock): Node | undefined {
switch (block.kind) {
case 'function':
case 'class':
case 'trait':
case 'impl':
case 'module':
case 'enum':
case 'exception':
case 'const':
@@ -66,6 +66,7 @@ describe('库规则:启用', () => {
expect(fromLibrary('sqlalchemy', 'sqlalchemy')).toBe(true)
expect(fromLibrary('sqlalchemy.orm', 'sqlalchemy')).toBe(true)
expect(fromLibrary('drizzle-orm/pg-core', 'drizzle-orm')).toBe(true)
expect(fromLibrary('axum::routing', 'axum')).toBe(true)
expect(fromLibrary('sqlalchemy_utils', 'sqlalchemy')).toBe(false)
expect(fromLibrary('.sqlalchemy', 'sqlalchemy')).toBe(false)
})
+1 -1
View File
@@ -23,7 +23,7 @@ function moduleName(source: Node): string {
/** 模块是这个库或它的子模块:等于库名,或以「库名.」(Python 的点分模块名)「库名/」(JavaScript 的子路径)开头。 */
export function fromLibrary(module: string, library: string): boolean {
return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`)
return module === library || module.startsWith(`${library}.`) || module.startsWith(`${library}/`) || module.startsWith(`${library}::`)
}
/** 这份源码启用的规则,保持注册顺序:导入了规则关心的某个库,或规则不看导入(libraries 是空的)。 */
+226
View File
@@ -0,0 +1,226 @@
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, ThemeColor } from '../contract'
import { attributeName, docSentence, named, oneLine, returnText, stringContent } from './rustTree'
/** 和 FastAPI 接口同一个蓝。 */
const COLOR: ThemeColor = { dark: '#61afef', light: '#1a6fb5' }
const BADGE: KindBadge = { label: '接口', color: COLOR, icon: 'globe' }
const METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace'])
/**
* 处理函数上的 get / post 等路由属性,或 Router 上能看出来的 route。
* 请求表和 FastAPI 接口一样:方法、路径、路径参数,再加返回。
* 文件里导入了 axum 才启用。
*/
export const axumRule: LibraryRule = {
id: 'axum',
label: 'axum 接口',
languageIds: ['rust'],
libraries: ['axum'],
claim: (block) => {
if (block.kind === 'function') {
const route = attributeRoute(block)
return route ? card(block, route) : undefined
}
if (block.kind === 'statement') {
const [route] = routesOn(block.node).filter(item => !handled(block.node, item.handler))
return route ? routerCard(block.node, route) : undefined
}
return undefined
},
attributeNames: () => ({ attributes: {}, values: { kind: { 'axum.route': '接口' } } }),
}
interface Route {
method: string
path: string
handler?: string
}
function attributeRoute(block: FunctionBlock): Route | undefined {
for (const attr of block.facts.decorators) {
const method = attributeName(attr)?.split('::').pop()
if (!method || !METHODS.has(method))
continue
const path = attributeString(attr)
if (path)
return { method: method.toUpperCase(), path }
}
return undefined
}
function card(block: FunctionBlock, route: Route): RuleCard {
const doc = docSentence(block.facts.doc)
const params = pathParams(route.path, block.node)
const rows: PropertyRow[] = [
nameRow(block.facts.name),
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []),
requestRow(route, params),
{ label: '返回', value: [plain(returnText(block.node))] },
]
return {
kind: 'axum.route',
badge: BADGE,
rows,
...(block.facts.name && { defines: range(block.facts.name) }),
absorbs: [],
}
}
/** 同一文件里的处理函数已经标了路由属性时,Router 上这句不再单独成卡。 */
function handled(node: Node, handler: string | undefined): boolean {
if (!handler)
return false
const root = node.tree.rootNode
for (const item of named(root)) {
if (item.type !== 'function_item' || item.childForFieldName('name')?.text !== handler)
continue
return itemAttributesHasRoute(item)
}
return false
}
function routerCard(node: Node, route: Route & { handlerNode?: Node }): RuleCard {
const fn = route.handler ? functionNamed(node, route.handler) : undefined
const params = fn ? pathParams(route.path, fn) : []
const doc = fn ? docSentence(leadingDoc(fn)) : undefined
return {
kind: 'axum.route',
badge: BADGE,
rows: [
{ label: '名称', value: route.handler ? [plain(route.handler)] : [hint('路由')] },
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
requestRow(route, params),
{ label: '返回', value: [plain(fn ? returnText(fn) : '—')] },
],
absorbs: [],
}
}
function requestRow(route: Route, params: string[]): PropertyRow {
const items: PropertyItem[] = [
{ cells: [[plain('方法')], [keyword(route.method)]] },
{ cells: [[plain('路径')], [{ text: route.path, role: 'string' }]] },
...(params.length > 0 ? [{ cells: [[plain('路径参数')], params.map(text => plain(text)).flatMap((part, index) => index === 0 ? [part] : [plain(', '), part])] }] : []),
]
return {
label: '请求',
value: [keyword(route.method), plain(` ${route.path}`)],
columns: ['项', '值'],
items,
}
}
function routesOn(node: Node, found: (Route & { handlerNode?: Node })[] = []): (Route & { handlerNode?: Node })[] {
if (node.type === 'call_expression') {
const callee = node.childForFieldName('function')
const field = callee?.type === 'field_expression' ? callee.childForFieldName('field')?.text : undefined
if (field === 'route') {
const args = named(node.childForFieldName('arguments'))
const pathNode = args.find(arg => arg.type === 'string_literal')
const call = args.find(arg => arg.type === 'call_expression')
const method = call?.childForFieldName('function')?.text.split('::').pop()
const handlerNode = named(call?.childForFieldName('arguments'))[0]
const path = stringContent(pathNode)
if (path && method && METHODS.has(method)) {
found.push({
method: method.toUpperCase(),
path,
...(handlerNode?.type === 'identifier' && { handler: handlerNode.text, handlerNode }),
})
}
}
}
for (const child of named(node))
routesOn(child, found)
return found
}
/** 路径里的 {名字} 对上参数。Path(id): Path<Uuid> 写成 id: Uuid。 */
function pathParams(path: string, fn: Node): string[] {
const names = [...path.matchAll(/\{(\w+)\}/g)].map(match => match[1] ?? '')
return names.flatMap((name) => {
const param = parameterNamed(fn, name)
if (!param)
return [`${name}`]
const type = innerType(param.childForFieldName('type'))
return [type ? `${name}: ${type}` : name]
})
}
function parameterNamed(fn: Node, name: string): Node | undefined {
const list = fn.childForFieldName('parameters')
return named(list).find((param) => {
if (param.type !== 'parameter' && param.type !== 'self_parameter')
return false
const pattern = param.childForFieldName('pattern') ?? param
return namedDeep(pattern).some(node => (node.type === 'identifier' || node.type === 'self') && node.text === name)
})
}
function innerType(type: Node | null): string | undefined {
if (!type)
return undefined
if (type.type === 'generic_type' && type.childForFieldName('type')?.text === 'Path') {
const arg = named(type.childForFieldName('type_arguments'))[0]
if (arg)
return oneLine(arg.text)
}
return oneLine(type.text)
}
function itemAttributesHasRoute(fn: Node): boolean {
let prev = fn.previousNamedSibling
while (prev?.type === 'attribute_item') {
const method = attributeName(prev)?.split('::').pop()
if (method && METHODS.has(method))
return true
prev = prev.previousNamedSibling
}
return false
}
function functionNamed(node: Node, name: string): Node | undefined {
return named(node.tree.rootNode).find(item => item.type === 'function_item' && item.childForFieldName('name')?.text === name)
}
function leadingDoc(fn: Node): Node | undefined {
let prev = fn.previousNamedSibling
while (prev?.type === 'attribute_item')
prev = prev.previousNamedSibling
return prev && (prev.type === 'line_comment' || prev.type === 'block_comment') ? prev : undefined
}
function attributeString(attributeItem: Node): string | undefined {
const attribute = named(attributeItem).find(child => child.type === 'attribute')
const tree = attribute?.children.find(child => child?.type === 'token_tree')
return stringContent(tree?.children.find(child => child?.type === 'string_literal') ?? undefined)
}
function namedDeep(node: Node): Node[] {
return [node, ...named(node).flatMap(namedDeep)]
}
function nameRow(name: Node | undefined): PropertyRow {
return {
label: '名称',
value: name ? [{ text: name.text, role: 'function', offset: name.startIndex }] : [hint('匿名')],
...(name && { defines: range(name) }),
}
}
function keyword(text: string): Inline {
return { text, role: 'keyword' }
}
function plain(text: string): Inline {
return { text, role: 'plain' }
}
function hint(text: string): Inline {
return { text, role: 'hint' }
}
function range(node: Node): { startIndex: number, endIndex: number } {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
+44 -18
View File
@@ -64,7 +64,7 @@ describe('react 库规则:认领', () => {
expect(propertyLines(properties(view)).slice(0, 4)).toEqual([
'名称: DesktopTitlebar ⟦具名导出|⟧ ⟦客户端组件|⟧',
'说明: 窗口顶部 40px. 整条连同里面的文字都是拖拽区 (data-tauri-drag-region="deep"), 按钮除外. 侧栏开关只在侧栏顶部.',
'属性: 1 个',
'构造参数: 1 个',
' platform | DesktopShell["platform"] | — | 是',
])
})
@@ -83,13 +83,13 @@ describe('react 库规则:认领', () => {
})
})
describe('react 库规则:属性', () => {
describe('react 库规则:构造参数', () => {
it('luffy 的 LegalDocPage:来自 LegalDocPageProps(名字能跳到定义),字段带说明时多一列说明', async () => {
const view = await card(LEGAL, 'LegalDocPage')
const row = properties(view).find(item => item.label === '属性')
const row = properties(view).find(item => item.label === '构造参数')
expect(row?.columns).toEqual(['名称', '类型', '默认值', '必填', '说明'])
expect(rowLines(view, '属性')).toEqual([
'属性: 1 个 · 来自 LegalDocPageProps',
expect(rowLines(view, '构造参数')).toEqual([
'构造参数: 1 个 · 来自 LegalDocPageProps',
' doc | "terms" | "privacy" | — | 是 | terms = 用户服务协议; privacy = 隐私政策',
])
const from = row && !('markdown' in row.value) ? row.value.at(-1) : undefined
@@ -98,16 +98,16 @@ describe('react 库规则:属性', () => {
})
it('luffy 的 ShareButton:回调标「回调」、类型写成函数签名;StepLabel 的 children 标「子元素」', async () => {
expect(rowLines(await card(SHARE, 'ShareButton'), '属性')).toEqual([
'属性: 5 个',
expect(rowLines(await card(SHARE, 'ShareButton'), '构造参数')).toEqual([
'构造参数: 5 个',
' open | 布尔 | — | 是 | ',
' onOpenChange ⟦回调|⟧ | (next: 布尔) => 无 | — | 是 | ',
' deployUrl | 字符串 | 空 | — | 是 | 已发布的持久 URL; null = 尚未发布 (点击后由父组件驱动发布)。',
' sessionId | 字符串 | — | 是 | ',
' title | 字符串 | — | 是 | ',
])
expect(rowLines(await card(POSTER, 'StepLabel'), '属性')).toEqual([
'属性: 2 个',
expect(rowLines(await card(POSTER, 'StepLabel'), '构造参数')).toEqual([
'构造参数: 2 个',
' n | 数值 | — | 是',
' children ⟦子元素|⟧ | ReactNode | — | 是',
])
@@ -118,8 +118,8 @@ describe('react 库规则:属性', () => {
'interface Props { size?: number; onPick(v: string): void; label: string }',
'export const A = ({ size = 16, label: text, ...rest }: Props) => <i />;',
].join('\n')
expect(rowLines(await card(source, 'A'), '属性')).toEqual([
'属性: 4 个 · 来自 Props',
expect(rowLines(await card(source, 'A'), '构造参数')).toEqual([
'构造参数: 4 个 · 来自 Props',
' size | 数值 | 16 | 否',
' onPick ⟦回调|⟧ | (v: 字符串) => 无 | — | 是',
' label(组件里叫 text) | 字符串 | — | 是',
@@ -133,8 +133,8 @@ describe('react 库规则:属性', () => {
'export const Card = ({ title, onClose = noop }: CardProps) => <div />;',
'export function Empty() { return <i /> }',
].join('\n')
expect(rowLines(await card(source, 'Card'), '属性')).toEqual([
'属性: 2 个 · 来自 CardProps',
expect(rowLines(await card(source, 'Card'), '构造参数')).toEqual([
'构造参数: 2 个 · 来自 CardProps',
' title | 未标注 | — | —',
' onClose | 未标注 | noop | 否',
])
@@ -143,13 +143,13 @@ describe('react 库规则:属性', () => {
{ name: 'onClose', type: '() => void', optional: true, callback: true },
{ name: 'size', type: 'number', optional: true, callback: false },
])
expect(rowLines(await card(source, 'Card', known({ CardProps: fields })), '属性')).toEqual([
'属性: 3 个 · 来自 CardProps',
expect(rowLines(await card(source, 'Card', known({ CardProps: fields })), '构造参数')).toEqual([
'构造参数: 3 个 · 来自 CardProps',
' title | string | — | 是 | 标题',
' onClose ⟦回调|⟧ | () => void | noop | 否 | ',
' size | number | — | 否 | ',
])
expect(rowLines(await card(source, 'Empty'), '属性')).toEqual(['属性: 无'])
expect(rowLines(await card(source, 'Empty'), '构造参数')).toEqual(['构造参数: 无'])
})
})
@@ -257,11 +257,37 @@ describe('react 库规则:分区', () => {
})
})
describe('单独打开一段源码', () => {
it('读取的数据挂的是这次调用,改它的里 JSX 那一处是整个属性', async () => {
const source = [
'export function Panel() {',
' const pathname =',
' usePathname()',
' const [open, setOpen] = useState(false)',
' return (',
' <Dialog',
' onOpenChange={',
' setOpen',
' }',
' />',
' )',
'}',
].join('\n')
const view = await card(source, 'Panel')
const read = properties(view).find(row => row.label === '读取的数据')?.items?.[0]
expect(read?.edits?.map(edit => edit.snippet.code)).toEqual(['usePathname()'])
const state = properties(view).find(row => row.label === '状态')?.items?.[0]
expect(state?.edits?.map(edit => edit.snippet.code)).toEqual(['onOpenChange={\n setOpen\n}'])
expect(view.code).toContain('function Panel')
expect(read?.edits?.[0].snippet.code).not.toContain('function Panel')
})
})
describe('react 库规则:渲染大纲', () => {
it('luffy 的 DesktopTitlebar:整张卡片讲完了组件,没有留下的子块;渲染大纲每行一个元素,原生标签、组件、样式小标、属性、插值', async () => {
const view = await card(TITLEBAR, 'DesktopTitlebar')
expect(view.children).toEqual([])
expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '属性', '读取的数据', '派生值', '渲染'])
expect(properties(view).map(row => row.label)).toEqual(['名称', '说明', '构造参数', '读取的数据', '派生值', '渲染'])
expect(rowLines(view, '渲染')).toEqual([
'渲染: 1 处返回',
' div ⟦样式 8 个类|⟧ data-tauri-drag-region="deep"',
@@ -388,7 +414,7 @@ describe('react 库规则:代码差异', () => {
const componentChange = diff.blocks[component.anchor]
expect(componentChange).toMatchObject({ change: 'mod', own: true })
const propsIndex = properties(component).findIndex(row => row.label === '属性')
const propsIndex = properties(component).findIndex(row => row.label === '构造参数')
expect(Object.keys(componentChange.marks?.rows?.changed ?? {})).toEqual([String(propsIndex)])
expect(componentChange.marks?.rows?.changed[propsIndex].items?.changed[1]).toEqual({ added: true })
expect(componentChange.marks?.rows?.removed).toEqual([])
+56 -14
View File
@@ -1,11 +1,11 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { CellEdit, CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, RuleSlot, SegmentGroup, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts } from './reactTree'
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentsBefore, commentText, componentAt, componentOf, componentsIn, declaredTypes, enclosingComponent, isComponentTag, isFunction, named, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { hint, joined, name, plain, range } from './segments'
/**
* React 库规则:TSX / JSX 里的 React 函数组件(见 reactTree.ts 的 componentAt)整块换成「React 组件」卡片,不再是普通函数卡片的输入参数 / 输出参数。
* 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 属性(解构的每个属性一行,类型取属性类型里对应的字段;
* 卡片:名称(后面跟导出方式、客户端组件、记忆化、转发 ref 这些小标签)/ 说明 / 构造参数(解构的每个属性一行,类型取属性类型里对应的字段;
* 属性类型是接口、类型别名时写来自哪个类型,导入的类型经工作区索引查它的字段),再按钩子分区,没有就不画:
* 状态(useState / useReducer:名称 / 初始值 / 改它的,渲染时按条件重置状态的 if 也写进改它的)、引用(useRef)、
* 读取的数据(别的钩子:变量 / 来自 / 意思)、派生值(组件体里别的常量、useMemo / useCallback:名称 / 怎么算 / 什么时候算)、
@@ -123,7 +123,7 @@ const ZH: Words = {
labels: {
name: '名称',
doc: '说明',
props: '属性',
props: '构造参数',
state: '状态',
refs: '引用',
reads: '读取的数据',
@@ -254,7 +254,7 @@ function docRows(component: ComponentFacts, { context, words }: Env): PropertyRo
return doc ? [{ label: words.labels.doc, value: doc }] : []
}
// ─── 属性 ─────────────────────────────────────────────────────────────────────
// ─── 构造参数 ─────────────────────────────────────────────────────────────────
/** 工作区里各文件顶层声明的接口、类型别名:名字 → 各字段的文字(JSON,见 FieldText),组件的属性类型是导入的时候查它。 */
const PROP_TYPES_INDEX: WorkspaceIndex = {
@@ -357,7 +357,7 @@ function propDoc(entry: PropEntry): Inline[] {
return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : []
}
/** 属性:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「属性」后面写来自哪里。 */
/** 构造参数:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「构造参数」后面写来自哪里。 */
function propsRow(component: ComponentFacts, env: Env): PropertyRow {
const { context, words } = env
const props = propsOf(component)
@@ -487,16 +487,54 @@ function commentLine(comments: readonly Node[]): string | undefined {
// ─── 状态、引用、读取的数据、派生值 ──────────────────────────────────────────
/** 状态:名称 / 初始值 / 改它的(改状态的函数,括号里写在哪儿改:副作用、处理函数、渲染时按条件重置……)。 */
/** 这一处改状态的源码:JSX 取包着值的整个属性,处理函数取函数,派生值取那个值,另一处读取取那次调用,重置取条件。纯渲染没有节点。 */
function placeNode(place: Place, body: BodyFacts): Node | undefined {
switch (place.kind) {
case 'jsx':
return place.attribute.parent?.type === 'jsx_attribute' ? place.attribute.parent : undefined
case 'handler':
return body.handlers.find(handler => sameNode(handler.name, place.name))?.fn
case 'derived':
return body.derived.find(item => sameNode(item.name, place.name))?.value
case 'read':
return body.reads.find(read => sameNode(read.target, place.target))?.call
case 'reset':
return place.condition
case 'effect':
return body.effects[place.index]?.statement
default:
return undefined
}
}
/** 「改它的」这一格:函数名,括号里每一处各挂自己的源码。 */
function changesOf(analysis: Analysis, setter: Node, env: Env): { cell: Inline[], edits: CellEdit[] } {
const places = placesOf(setter, analysis.component, analysis.body)
const parts = places.map(place => placeText(place, analysis, env))
const cell = [name(setter, 'function'), ...inParens(parts, env)]
const edits: CellEdit[] = []
let at = 2
parts.forEach((part, index) => {
const node = placeNode(places[index], analysis.body)
if (node)
edits.push({ cell: 2, from: at, to: at + part.length, snippet: env.context.snippet(node) })
at += part.length + (index < parts.length - 1 ? 1 : 0)
})
return { cell, edits }
}
function stateRow(analysis: Analysis, env: Env): PropertyRow {
const { component, body } = analysis
const { body } = analysis
const { context, words } = env
const items = body.states.map((state): PropertyItem => {
const initial = state.initial ? context.translate(state.initial) : [hint(words.nothing)]
const reducer = state.reducer ? [hint(words.parens[0]), ...context.translate(state.reducer), hint(words.parens[1])] : []
const changes = state.setter
? [name(state.setter, 'function'), ...inParens(placesOf(state.setter, component, body).map(place => placeText(place, analysis, env)), env)]
: [hint(words.nothing)]
return { cells: [binding(state.value, 'variable', env), [...initial, ...reducer], changes], defines: range(state.value) }
const changes = state.setter ? changesOf(analysis, state.setter, env) : { cell: [hint(words.nothing)], edits: [] }
return {
cells: [binding(state.value, 'variable', env), [...initial, ...reducer], changes.cell],
...(changes.edits.length > 0 && { edits: changes.edits }),
defines: range(state.value),
}
})
return { label: words.labels.state, value: [hint(words.count(items.length))], items, columns: words.heads.state }
}
@@ -601,10 +639,14 @@ function readMeaning(read: ReadFacts, env: Env): Inline[] {
/** 读取的数据:变量 / 来自(钩子调用照常翻)/ 意思。 */
function readRow(analysis: Analysis, env: Env): PropertyRow {
const { context, words } = env
const items = analysis.body.reads.map((read): PropertyItem => ({
cells: [binding(read.target, 'variable', env), context.translate(read.call), readMeaning(read, env)],
defines: range(read.target),
}))
const items = analysis.body.reads.map((read): PropertyItem => {
const from = context.translate(read.call)
return {
cells: [binding(read.target, 'variable', env), from, readMeaning(read, env)],
edits: [{ cell: 1, from: 0, to: from.length, snippet: context.snippet(read.call) }],
defines: range(read.target),
}
})
return { label: words.labels.reads, value: [hint(words.count(items.length))], items, columns: words.heads.reads }
}
+1 -1
View File
@@ -1,7 +1,7 @@
import type { CodeBlock, Node } from '../contract'
/**
* 读 React 函数组件的语法树(React 库规则用,见 react.ts):认组件,读出它的属性、组件体里的各种钩子、处理函数和渲染的 JSX。
* 读 React 函数组件的语法树(React 库规则用,见 react.ts):认组件,读出它的构造参数、组件体里的各种钩子、处理函数和渲染的 JSX。
* 只读语法树、不翻译,读出来的节点都指向源码;怎么排成卡片、用什么词归 react.ts。
*/
+36
View File
@@ -0,0 +1,36 @@
import type { ImplBlock, KindBadge, LibraryRule, RuleCard, ThemeColor } from '../contract'
import { implRows, plainSay } from './rustRows'
/** 和函数卡同一个黄,实现块看起来仍是函数那一挂。 */
const COLOR: ThemeColor = { dark: '#dcdcaa', light: '#795e26' }
const BADGE: KindBadge = { label: '实现', color: COLOR, icon: 'square-function' }
/**
* impl 的方法表:点方法名展开那张函数卡。
* 子块放进属性表某一行,和 React 事件处理同一条路。不看导入,Rust 文件都启用。
*/
export const rustImplRule: LibraryRule = {
id: 'rust',
label: 'Rust 实现',
languageIds: ['rust'],
libraries: [],
claim: block => block.kind === 'impl' ? implCard(block) : undefined,
attributeNames: () => ({
attributes: {},
values: { kind: { 'rust.impl': '实现' } },
}),
}
function implCard(block: ImplBlock): RuleCard {
const rows = implRows(block, plainSay())
const row = rows.findIndex(item => item.label === '方法')
const methods = block.children.filter(child => child.kind === 'function')
return {
kind: 'rust.impl',
badge: BADGE,
rows,
...(block.facts.name && { defines: { startIndex: block.facts.name.startIndex, endIndex: block.facts.name.endIndex } }),
absorbs: [],
...(row >= 0 && { slots: methods.map((child, item) => ({ block: child, row, item })) }),
}
}
@@ -0,0 +1,126 @@
import type { BlockView } from '../blockView'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { blockMarks } from '../diff/viewDiff'
import { createRustAdapter } from '../languages/rust'
import { readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { rustZhTranslator } from '../translators/rustZh'
import { axumRule } from './axum'
import { rustImplRule } from './rustImpl'
import { serdeRule } from './serde'
import { sqlxRule } from './sqlx'
import { tauriRule } from './tauri'
import { thiserrorRule } from './thiserror'
const adapter = createRustAdapter(WASM_DIR)
const rules = [rustImplRule, serdeRule, thiserrorRule, axumRule, sqlxRule, tauriRule]
function views(source: string): Promise<BlockView[]> {
return withBlocks(adapter, 'rust', source, blocks =>
toBlockViews(blocks, { source, language: adapter, markers: [] }, rustZhTranslator, {
rules,
report: (message) => {
throw new Error(message)
},
}))
}
describe('serde 与 thiserror', () => {
it('可序列化的结构体、错误枚举', async () => {
expect(viewLines(await views(readFixture('rustLibs.rs')))).toEqual([
'import 1',
' 来源: serde',
' 导入项: 2 个',
' Deserialize | —',
' Serialize | —',
'serde.struct 3',
' 名称: AgentRow ⟦可序列化|⟧',
' 说明: 桌面端列表里的一行代理。',
' 可见性: 公开',
' 选项: 字段名改驼峰',
' 字段: 3 个',
' id | Uuid | 公开 | 改名 agent_id',
' name | String | 公开 | —',
' secret | String | 本模块 | 不输出',
'import 14',
' 来源: thiserror',
' 导入项: 1 个',
' Error | —',
'thiserror.error 16',
' 名称: AgentError',
' 说明: 读写代理时出的错。',
' 变体: 2 个',
' NotFound | 这个 id 没有代理 | agent {id} 不存在',
' Upstream | 调用上游失败 | 上游错误 ⟦透传来源|⟧',
'import 27',
' 来源: axum::routing',
' 导入项: 1 个',
' get | —',
'axum.route 29',
' 名称: get_agent',
' 说明: 按 id 取一个代理。',
' 异步: 是',
' 请求: GET /agents/{id}',
' 方法 | GET',
' 路径 | /agents/{id}',
' 路径参数 | id: Uuid',
' 返回: Result<Json<AgentRow>, StatusCode>',
'axum.route 35',
' 名称: health',
' 请求: GET /health',
' 方法 | GET',
' 路径 | /health',
' 返回: —',
'import 37',
' 来源: sqlx',
' 导入项: 1 个',
' query_as | —',
'sqlx.query 40',
' 说明: 按 id 取一行。',
' SQL: SELECT id, name FROM agents WHERE id = $1',
' 参数: $1 = id',
' 返回类型: AgentRow',
'import 42',
' 来源: tauri',
' 导入项: 1 个',
' State | —',
'tauri.command 44',
' 名称: rename_agent ⟦桌面命令|⟧',
' 说明: 让窗口把这一行改名。',
' 异步: 是',
' 参数: 2 个',
' state | State<App> | 窗口状态',
' name | String | 调用方传入',
' 返回: Result<String, String>',
])
})
it('没导入 serde 时,带 derive 的结构体仍是结构体', async () => {
const source = '#[derive(Serialize)]\npub struct AgentRow {\n name: String,\n}\n'
const [card] = await views(source)
expect(card?.kind).toBe('class')
})
it('改 axum 的路径,路径那一格标成改过', async () => {
const before = 'use axum::routing::get;\n#[get("/agents/{id}")]\nasync fn get_agent(Path(id): Path<Uuid>) -> Result<Json<AgentRow>, StatusCode> {}\n'
const after = before.replace('/agents/{id}', '/people/{id}')
const oldCard = (await views(before)).find(block => block.kind === 'axum.route')
const newCard = (await views(after)).find(block => block.kind === 'axum.route')
const marks = oldCard && newCard ? blockMarks(oldCard, newCard) : undefined
const row = newCard && 'properties' in newCard ? newCard.properties.findIndex(item => item.label === '请求') : -1
const parts = marks?.rows?.changed[row]?.items?.changed[1]?.cells?.[1] ?? []
const text = (change: 'add' | 'del') => parts
.filter(part => part.change === change)
.flatMap(part => part.segments)
.map(segment => 'text' in segment ? segment.text : '')
.join('')
expect(text('del')).toContain('/agents/{id}')
expect(text('add')).toContain('/people/{id}')
})
it('skip_serializing 也是不输出', async () => {
const source = 'use serde::Serialize;\n#[derive(Serialize)]\nstruct Row {\n #[serde(skip_serializing)]\n token: String,\n}\n'
const lines = viewLines(await views(source))
expect(lines).toContain(' token | String | 本模块 | 不输出')
})
})
+190
View File
@@ -0,0 +1,190 @@
import type { ClassBlock, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, PropertyItem, PropertyRow, TokenRole, TraitBlock } from '../contract'
import type { RustVisibility } from './rustTree'
import { bodyFunctions, docSentence, isMethod, matchArms, oneLine, returnText, signatureParams, structFields, variantPayload } from './rustTree'
/** 把类型、名字画成原文。翻译插件传入时,名字带源码下标。 */
export interface RustSay {
name: (node: import('../contract').Node, role: TokenRole) => Inline[]
source: (node: import('../contract').Node, role?: TokenRole) => Inline[]
}
const VISIBILITY: Record<RustVisibility, string> = {
public: '公开',
module: '本模块',
super: '父模块',
crate: '本 crate',
}
/** 块上的可见性:属性盖过 facts,没写是本模块。 */
export function visibilityWord(block: CodeBlock): string {
const overridden = block.attributes?.access
if (overridden && overridden in VISIBILITY)
return VISIBILITY[overridden as RustVisibility]
if ('facts' in block && 'access' in block.facts && block.facts.access === 'public')
return VISIBILITY.public
return VISIBILITY.module
}
export function importRows(block: ImportBlock, say: RustSay): PropertyRow[] {
const [source] = block.facts.sources
return [
{ label: '来源', value: source ? say.source(source, 'type') : [hint('—')] },
{
label: '导入项',
value: [hint(`${block.facts.items.length} 个`)],
columns: ['名字', '别名'],
items: block.facts.items.map((item) => {
if (item.kind === 'namespace')
return { cells: [[hint('*')], [hint('—')]] }
if (item.kind !== 'named')
return { cells: [[hint('—')], [hint('—')]] }
return {
cells: [say.name(item.name, 'type'), item.alias ? say.name(item.alias, 'type') : [hint('—')]],
defines: range(item.name),
}
}),
},
]
}
export function structRows(block: ClassBlock, say: RustSay): PropertyRow[] {
const fields = structFields(block.node)
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
visibilityRow(block),
...tableRow('字段', ['字段', '类型', '可见性'], fields.map(field => ({
cells: [
say.name(field.name, 'variable'),
say.source(field.type, 'type'),
[keyword(VISIBILITY[field.visibility])],
],
defines: range(field.name),
}))),
]
}
export function moduleRows(block: ModuleBlock, say: RustSay): PropertyRow[] {
return [
nameRow(block.facts.name, say, 'variable'),
...docRow(block.facts.doc),
visibilityRow(block),
]
}
export function enumRows(block: EnumBlock, say: RustSay): PropertyRow[] {
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
...tableRow('变体', ['变体', '携带的数据'], block.facts.members.map(member => ({
cells: [say.name(member.name, 'type'), [hint(variantPayload(member.value) ?? '—')]],
defines: range(member.name),
}))),
]
}
export function functionRows(block: FunctionBlock, say: RustSay): PropertyRow[] {
const arms = matchArms(block.node)
return [
nameRow(block.facts.name, say, 'function'),
...docRow(block.facts.doc),
...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []),
visibilityRow(block),
...tableRow(isMethod(block.node) ? '参数' : '输入参数', ['名称', '类型'], block.facts.parameters.map(param => ({
cells: [say.name(param.name, 'variable'), param.type ? say.source(param.type, 'type') : [hint('—')]],
defines: range(param.name),
}))),
{ label: '返回', value: [plain(returnText(block.node))] },
...tableRow('分情况', ['分支', '做什么'], arms.map(arm => ({
cells: [[plain(arm.pattern)], [hint(arm.does)]],
})), '支'),
]
}
export function traitRows(block: TraitBlock, say: RustSay): PropertyRow[] {
const methods = bodyFunctions(block.node)
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
...tableRow('方法', ['名称', '参数', '返回'], methods.map((method) => {
const name = method.childForFieldName('name')
return {
cells: [
name ? say.name(name, 'function') : [hint('匿名')],
[plain(signatureParams(method))],
[plain(returnText(method))],
],
...(name && { defines: range(name) }),
}
})),
]
}
export function implRows(block: ImplBlock, say: RustSay): PropertyRow[] {
const methods = block.children.filter((child): child is FunctionBlock => child.kind === 'function')
const title: Inline[] = block.facts.name
? [...say.name(block.facts.name, 'type'), plain(' 的方法')]
: [hint('实现')]
return [
{ label: '名称', value: title, ...(block.facts.name && { defines: range(block.facts.name) }) },
...docRow(block.facts.doc),
...tableRow('方法', ['名称', '参数', '返回'], methods.map((method) => {
const name = method.facts.name
return {
cells: [
name ? say.name(name, 'function') : [hint('匿名')],
[plain(signatureParams(method.node))],
[plain(returnText(method.node))],
],
...(name && { defines: range(name) }),
}
})),
]
}
function nameRow(name: import('../contract').Node | undefined, say: RustSay, role: TokenRole): PropertyRow {
return {
label: '名称',
value: name ? say.name(name, role) : [hint('匿名')],
...(name && { defines: range(name) }),
}
}
function docRow(doc: import('../contract').Node | undefined): PropertyRow[] {
const sentence = docSentence(doc)
return sentence ? [{ label: '说明', value: { markdown: sentence } }] : []
}
function visibilityRow(block: CodeBlock): PropertyRow {
return { label: '可见性', value: [keyword(visibilityWord(block))] }
}
/** 没有行就不占一行。countUnit 默认「个」,分情况用「支」。 */
function tableRow(label: string, columns: string[], items: PropertyItem[], countUnit = '个'): PropertyRow[] {
if (items.length === 0)
return []
return [{ label, value: [hint(`${items.length} ${countUnit}`)], columns, items }]
}
function hint(text: string): Inline {
return { text, role: 'hint' }
}
function plain(text: string): Inline {
return { text, role: 'plain' }
}
function keyword(text: string): Inline {
return { text, role: 'keyword' }
}
function range(node: import('../contract').Node): { startIndex: number, endIndex: number } {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
/** 名字、类型照抄源码,名字带上下标,点了能跳到定义。 */
export function plainSay(): RustSay {
return {
name: (node, role) => [{ text: node.text, role, offset: node.startIndex }],
source: (node, role = 'type') => [{ text: oneLine(node.text), role, offset: node.startIndex }],
}
}
+220
View File
@@ -0,0 +1,220 @@
import type { Access, BlockAttributes, Node } from '../contract'
/** 具名子节点,空的跳过。 */
export function named(node: Node | null | undefined): Node[] {
return node?.namedChildren.filter((child): child is Node => child !== null) ?? []
}
export type RustVisibility = 'public' | 'module' | 'super' | 'crate'
export function firstSentence(text: string): string {
const trimmed = text.trim()
const sentence = trimmed.match(/^[\s\S]*?[。!?.!?]/)
return (sentence ? sentence[0] : trimmed.split('\n')[0] ?? '').trim()
}
/** 文档注释去掉记号后的第一句;普通注释是整段去掉记号。 */
export function commentPlain(node: Node): string {
const doc = node.childForFieldName('doc')
const raw = doc ? doc.text : node.text.replace(/^\/\/[/!]?\s?/, '').replace(/^\/\*[*!]?\s?/, '').replace(/\s*\*\/$/, '')
return raw.replace(/\n/g, ' ').trim()
}
/** 说明行用的第一句。没有文字时没有。 */
export function docSentence(node: Node | undefined): string | undefined {
if (!node)
return undefined
const sentence = firstSentence(commentPlain(node))
return sentence || undefined
}
/** 空白收成一个空格,方便属性表里的类型、签名。 */
export function oneLine(text: string): string {
return text.replace(/\s+/g, ' ').trim()
}
/** 节点上的可见性。pub(in …) 不在这四档里,按本 crate 算(只本包能用)。 */
export function rustVisibility(node: Node): RustVisibility {
const vis = named(node).find(child => child.type === 'visibility_modifier')
if (!vis)
return 'module'
const words = new Set(vis.children.map(child => child?.type))
if (words.has('super'))
return 'super'
if (words.has('crate') || words.has('in'))
return 'crate'
return 'public'
}
/** 公开写在 facts.access 上;其余写在块的 access 属性上,盖过 facts。 */
export function visibilityFacts(node: Node): { access?: Access, attributes?: BlockAttributes } {
const visibility = rustVisibility(node)
return visibility === 'public' ? { access: 'public' } : { attributes: { access: visibility } }
}
/** 参数表括号里的原文,空白收成一行。 */
export function signatureParams(fn: Node): string {
const params = fn.childForFieldName('parameters')
if (!params)
return ''
return oneLine(params.text.replace(/^\(/, '').replace(/\)$/, ''))
}
/** 返回类型原文;没写是 ()。 */
export function returnText(fn: Node): string {
const returned = fn.childForFieldName('return_type')
return returned ? oneLine(returned.text) : '()'
}
/** 函数体里直接写的 match,每一支的模式和「做什么」。 */
export function matchArms(fn: Node): { pattern: string, does: string }[] {
const body = fn.childForFieldName('body')
if (!body)
return []
const arms: { pattern: string, does: string }[] = []
for (const statement of named(body)) {
const expr = statement.type === 'expression_statement' ? named(statement)[0] : statement
if (expr?.type !== 'match_expression')
continue
const block = expr.childForFieldName('body')
for (const arm of named(block).filter(child => child.type === 'match_arm')) {
const pattern = arm.childForFieldName('pattern')
if (pattern)
arms.push({ pattern: oneLine(pattern.text), does: armDoes(arm) })
}
}
return arms
}
/** 变体携带的数据:没带是空的,元组变体是类型,结构变体是字段原文。body 是变体的 body。 */
export function variantPayload(body: Node | undefined): string | undefined {
if (!body)
return undefined
if (body.type === 'ordered_field_declaration_list') {
const types = named(body).filter(child => child.type !== 'attribute_item').map(child => oneLine(child.text))
return types.join(', ') || undefined
}
return oneLine(body.text)
}
/** 写在这条前面、紧挨着的属性(#[…]),从外往里。 */
export function leadingAttributes(node: Node): Node[] {
const attrs: Node[] = []
let prev = node.previousNamedSibling
while (prev?.type === 'attribute_item') {
attrs.unshift(prev)
prev = prev.previousNamedSibling
}
return attrs
}
/** 属性的名字:get、serde、axum::post、tauri::command。 */
export function attributeName(attributeItem: Node): string | undefined {
const attribute = named(attributeItem).find(child => child.type === 'attribute')
const head = attribute ? named(attribute)[0] : undefined
return head?.text
}
/** 这条自己的属性,含写在它前面的。 */
export function itemAttributes(node: Node): Node[] {
return [...leadingAttributes(node), ...named(node).filter(child => child.type === 'attribute_item')]
}
/** derive(...) 里列出的名字。 */
export function deriveNames(node: Node): string[] {
const names: string[] = []
for (const item of itemAttributes(node)) {
if (attributeName(item) !== 'derive')
continue
const attribute = named(item).find(child => child.type === 'attribute')
const tree = attribute?.children.find(child => child?.type === 'token_tree')
if (!tree)
continue
for (const name of named(tree)) {
if (name.type === 'identifier')
names.push(name.text)
else if (name.type === 'scoped_identifier')
names.push(name.childForFieldName('name')?.text ?? name.text)
}
}
return names
}
/** 字符串字面量里的原文,不含引号。 */
export function stringContent(node: Node | undefined): string | undefined {
if (!node)
return undefined
if (node.type === 'string_content')
return node.text
const content = node.children.find(child => child?.type === 'string_content')
return content?.text
}
/** 宏调用的名字和括号里的具名实参。 */
export function macroInvocation(node: Node): { name: string, args: Node[] } | undefined {
if (node.type !== 'macro_invocation')
return undefined
const macro = node.childForFieldName('macro')
const args = node.children.find(child => child?.type === 'token_tree')
if (!macro || !args)
return undefined
return { name: macro.text, args: named(args) }
}
/** 函数写在 impl 或 trait 里面,参数表叫「参数」而不是「输入参数」。 */
export function isMethod(node: Node): boolean {
const parent = node.parent
return parent?.type === 'declaration_list' && (parent.parent?.type === 'impl_item' || parent.parent?.type === 'trait_item')
}
/** 结构体的字段:名字、类型、可见性。元组结构体没有名字,不进这张表。 */
export function structFields(node: Node): { name: Node, type: Node, visibility: RustVisibility }[] {
const body = node.childForFieldName('body')
if (body?.type !== 'field_declaration_list')
return []
return named(body).flatMap((field) => {
if (field.type !== 'field_declaration')
return []
const name = field.childForFieldName('name')
const type = field.childForFieldName('type')
return name && type ? [{ name, type, visibility: rustVisibility(field) }] : []
})
}
/** trait 或 impl 主体里的函数(含只写签名的)。 */
export function bodyFunctions(node: Node): Node[] {
const body = node.childForFieldName('body')
return named(body).filter(child => child.type === 'function_item' || child.type === 'function_signature_item')
}
function armDoes(arm: Node): string {
const comment = firstComment(arm)
if (comment)
return firstSentence(commentPlain(comment)) || '—'
const value = arm.childForFieldName('value')
if (!value)
return '—'
if (value.type === 'return_expression') {
const returned = named(value)[0]
return returned ? `返回 ${oneLine(returned.text)}` : '返回'
}
if (value.type === 'block') {
const inner = firstComment(value)
if (inner)
return firstSentence(commentPlain(inner)) || '—'
const statement = named(value)[0]
return statement ? oneLine(statement.text) : '—'
}
return oneLine(value.text)
}
function firstComment(node: Node): Node | undefined {
if (node.type === 'line_comment' || node.type === 'block_comment')
return node
for (const child of node.children) {
if (!child)
continue
const found = firstComment(child)
if (found)
return found
}
return undefined
}
+144
View File
@@ -0,0 +1,144 @@
import type { ClassBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor, TokenRole } from '../contract'
import type { rustVisibility } from './rustTree'
import { visibilityWord } from './rustRows'
import { attributeName, deriveNames, docSentence, itemAttributes, leadingAttributes, named, oneLine, stringContent, structFields } from './rustTree'
/** 和结构体同一个青,序列化只是名称后的标签,不另换边框色。 */
const COLOR: ThemeColor = { dark: '#4ec9b0', light: '#267f99' }
const BADGE: KindBadge = { label: '可序列化', color: COLOR, icon: 'braces' }
const MARKS = new Set(['Serialize', 'Deserialize'])
/**
* 带 Serialize / Deserialize 的结构体。字段多一列「序列化」。
* 文件里导入了 serde 才启用。
*/
export const serdeRule: LibraryRule = {
id: 'serde',
label: 'serde 序列化',
languageIds: ['rust'],
libraries: ['serde'],
claim: block => block.kind === 'class' && block.node.type === 'struct_item' && marked(block) ? card(block) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'serde.struct': '可序列化' } } }),
}
function marked(block: ClassBlock): boolean {
return deriveNames(block.node).some(name => MARKS.has(name))
}
function card(block: ClassBlock): RuleCard {
const fields = structFields(block.node)
const camel = renameAll(block.node) === 'camelCase'
const doc = docSentence(block.facts.doc)
const rows: PropertyRow[] = [
{
label: '名称',
value: block.facts.name ? [...copied(block.facts.name, 'type'), plain(' '), tag('可序列化')] : [tag('可序列化')],
...(block.facts.name && { defines: range(block.facts.name) }),
},
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
{ label: '可见性', value: [{ text: visibilityWord(block), role: 'keyword' as const }] },
...(camel ? [{ label: '选项', value: [keyword('字段名改驼峰')] }] : []),
...(fields.length > 0
? [{
label: '字段',
value: [hint(`${fields.length} 个`)],
columns: ['字段', '类型', '可见性', '序列化'],
items: fields.map(field => ({
cells: [
copied(field.name, 'variable'),
copied(field.type, 'type'),
[keyword(visibilityWordOf(field.visibility))],
[serdeCell(field.name)],
],
defines: range(field.name),
})),
}]
: []),
]
return {
kind: 'serde.struct',
badge: BADGE,
rows,
...(block.facts.name && { defines: range(block.facts.name) }),
absorbs: [],
}
}
function visibilityWordOf(visibility: ReturnType<typeof rustVisibility>): string {
switch (visibility) {
case 'public': return '公开'
case 'super': return '父模块'
case 'crate': return '本 crate'
default: return '本模块'
}
}
function serdeCell(fieldName: Node): Inline {
const field = fieldName.parent
if (!field)
return hint('—')
let rename: string | undefined
let skip = false
for (const attr of leadingAttributes(field)) {
if (attributeName(attr) !== 'serde')
continue
for (const piece of named(tokenTree(attr))) {
if (piece.type === 'identifier' && (piece.text === 'skip' || piece.text === 'skip_serializing'))
skip = true
if (piece.type === 'identifier' && piece.text === 'rename')
rename = stringContent(firstString(tokenTree(attr))) ?? rename
}
}
if (skip)
return hint('不输出')
if (rename)
return { text: `改名 ${rename}`, role: 'string' }
return hint('—')
}
function renameAll(node: Node): string | undefined {
for (const attr of itemAttributes(node)) {
if (attributeName(attr) !== 'serde')
continue
const names = named(tokenTree(attr))
if (!names.some(piece => piece.type === 'identifier' && piece.text === 'rename_all'))
continue
const value = stringContent(firstString(tokenTree(attr)))
if (value)
return value
}
return undefined
}
function tokenTree(attributeItem: Node): Node | undefined {
const attribute = named(attributeItem).find(child => child.type === 'attribute')
return attribute?.children.find(child => child?.type === 'token_tree') ?? undefined
}
function firstString(tree: Node | undefined): Node | undefined {
return tree?.children.find(child => child?.type === 'string_literal') ?? undefined
}
function copied(node: Node, role: TokenRole): Inline[] {
return [{ text: oneLine(node.text), role, offset: node.startIndex }]
}
function tag(label: string): SegmentGroup {
return { style: 'tag', label, segments: [], color: COLOR }
}
function plain(text: string): Inline {
return { text, role: 'plain' }
}
function keyword(text: string): Inline {
return { text, role: 'keyword' }
}
function hint(text: string): Inline {
return { text, role: 'hint' }
}
function range(node: Node): { startIndex: number, endIndex: number } {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
+80
View File
@@ -0,0 +1,80 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, ThemeColor } from '../contract'
import { commentPlain, firstSentence, macroInvocation, named, oneLine, stringContent } from './rustTree'
/** 查询卡用字符串那种橙。 */
const COLOR: ThemeColor = { dark: '#ce9178', light: '#a31515' }
const BADGE: KindBadge = { label: '查询', color: COLOR, icon: 'database' }
const MACROS = new Set(['query', 'query_as', 'query_scalar'])
/**
* sqlx::query! / query_as! 这类宏。一句说明、等宽 SQL、参数、返回类型。
* 文件里导入了 sqlx 才启用。
*/
export const sqlxRule: LibraryRule = {
id: 'sqlx',
label: 'sqlx 查询',
languageIds: ['rust'],
libraries: ['sqlx'],
claim: (block) => {
if (block.kind === 'comment')
return nextQuery(block.node) ? { omit: true } : undefined
const macro = block.kind === 'statement' ? findQuery(block.node) : undefined
return macro ? card(block, macro) : undefined
},
attributeNames: () => ({ attributes: {}, values: { kind: { 'sqlx.query': '查询' } } }),
}
function card(block: CodeBlock, macro: Node): RuleCard {
const call = macroInvocation(macro)
const args = call?.args ?? []
const typed = call?.name.split('::').pop() !== 'query'
const sqlNode = args[typed ? 1 : 0]
const sql = stringContent(sqlNode) ?? ''
const params = args.slice(typed ? 2 : 1)
const returned = typed ? args[0] : undefined
const note = precedingNote(block.node)
const rows: PropertyRow[] = [
...(note ? [{ label: '说明', value: { markdown: note } }] : []),
{ label: 'SQL', value: [{ text: sql, role: 'string', ...(sqlNode && { offset: sqlNode.startIndex }) }] },
...(params.length > 0
? [{ label: '参数', value: [{ text: params.map((param, index) => `$${index + 1} = ${oneLine(param.text)}`).join('、'), role: 'plain' as const }] }]
: []),
{ label: '返回类型', value: returned ? [{ text: oneLine(returned.text), role: 'type', offset: returned.startIndex }] : [hint('—')] },
]
return { kind: 'sqlx.query', badge: BADGE, rows, absorbs: [] }
}
function findQuery(node: Node): Node | undefined {
if (node.type === 'macro_invocation') {
const last = node.childForFieldName('macro')?.text.split('::').pop()
if (last && MACROS.has(last))
return node
}
for (const child of named(node)) {
const found = findQuery(child)
if (found)
return found
}
return undefined
}
function nextQuery(comment: Node): Node | undefined {
let next = comment.nextNamedSibling
while (next?.type === 'attribute_item')
next = next.nextNamedSibling
return next ? findQuery(next) : undefined
}
function precedingNote(node: Node): string | undefined {
let prev = node.previousNamedSibling
while (prev?.type === 'attribute_item')
prev = prev.previousNamedSibling
if (!prev || (prev.type !== 'line_comment' && prev.type !== 'block_comment'))
return undefined
const sentence = firstSentence(commentPlain(prev))
return sentence || undefined
}
function hint(text: string): Inline {
return { text, role: 'hint' }
}
+98
View File
@@ -0,0 +1,98 @@
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor } from '../contract'
import { attributeName, docSentence, oneLine, returnText } from './rustTree'
/** 桌面命令的边框用这个橙。 */
const COLOR: ThemeColor = { dark: '#e5c07b', light: '#9a6a00' }
const BADGE: KindBadge = { label: '桌面命令', color: COLOR, icon: 'square-function' }
/**
* #[tauri::command] 的函数。名称后标「桌面命令」。
* 参数多一列来源:State<…> 是窗口状态,其余是调用方传入。
* 文件里导入了 tauri 才启用。
*/
export const tauriRule: LibraryRule = {
id: 'tauri',
label: 'tauri 桌面命令',
languageIds: ['rust'],
libraries: ['tauri'],
claim: block => block.kind === 'function' && commanded(block) ? card(block) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'tauri.command': '桌面命令' } } }),
}
function commanded(block: FunctionBlock): boolean {
return block.facts.decorators.some(attr => attributeName(attr) === 'tauri::command')
}
function card(block: FunctionBlock): RuleCard {
const doc = docSentence(block.facts.doc)
const rows: PropertyRow[] = [
{
label: '名称',
value: block.facts.name
? [{ text: block.facts.name.text, role: 'function', offset: block.facts.name.startIndex }, plain(' '), tag('桌面命令')]
: [tag('桌面命令')],
...(block.facts.name && { defines: range(block.facts.name) }),
},
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []),
...(block.facts.parameters.length > 0
? [{
label: '参数',
value: [hint(`${block.facts.parameters.length} 个`)],
columns: ['名称', '类型', '来源'],
items: block.facts.parameters.map(param => ({
cells: [
[variable(param.name)],
[param.type ? typed(param.type) : hint('—')],
[hint(isWindowState(param.type) ? '窗口状态' : '调用方传入')],
],
defines: range(param.name),
})),
}]
: []),
{ label: '返回', value: [plain(returnText(block.node))] },
]
return {
kind: 'tauri.command',
badge: BADGE,
rows,
...(block.facts.name && { defines: range(block.facts.name) }),
absorbs: [],
}
}
function isWindowState(type: Node | undefined): boolean {
if (!type)
return false
if (type.type === 'generic_type')
return type.childForFieldName('type')?.text === 'State'
return type.text.startsWith('State<')
}
function variable(node: Node): Inline {
return { text: node.text, role: 'variable', offset: node.startIndex }
}
function typed(node: Node): Inline {
return { text: oneLine(node.text), role: 'type', offset: node.startIndex }
}
function tag(label: string): SegmentGroup {
return { style: 'tag', label, segments: [], color: COLOR }
}
function plain(text: string): Inline {
return { text, role: 'plain' }
}
function keyword(text: string): Inline {
return { text, role: 'keyword' }
}
function hint(text: string): Inline {
return { text, role: 'hint' }
}
function range(node: Node): { startIndex: number, endIndex: number } {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
+112
View File
@@ -0,0 +1,112 @@
import type { EnumBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor } from '../contract'
import { attributeName, commentPlain, deriveNames, docSentence, firstSentence, named, stringContent, variantPayload } from './rustTree'
/** 错误卡用异常那种红。 */
const COLOR: ThemeColor = { dark: '#f14c4c', light: '#cd3131' }
const BADGE: KindBadge = { label: '错误', color: COLOR, icon: 'triangle-alert' }
/**
* derive Error 的枚举。变体表是:变体、何时、展示。
* 文件里导入了 thiserror 才启用。
*/
export const thiserrorRule: LibraryRule = {
id: 'thiserror',
label: 'thiserror 错误',
languageIds: ['rust'],
libraries: ['thiserror'],
claim: block => block.kind === 'enum' && deriveNames(block.node).includes('Error') ? card(block) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'thiserror.error': '错误' } } }),
}
function card(block: EnumBlock): RuleCard {
const doc = docSentence(block.facts.doc)
const rows: PropertyRow[] = [
{
label: '名称',
value: [{ text: block.facts.name.text, role: 'type', offset: block.facts.name.startIndex }],
defines: range(block.facts.name),
},
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
{
label: '变体',
value: [hint(`${block.facts.members.length} 个`)],
columns: ['变体', '何时', '展示'],
items: block.facts.members.map((member) => {
const variant = member.name.parent?.type === 'enum_variant' ? member.name.parent : member.name
const display = errorText(variant)
const cells: Inline[][] = [
[{ text: member.name.text, role: 'type', offset: member.name.startIndex }],
[hint(whenText(variant) ?? '—')],
display
? [plain(display), ...(passesSource(variant) ? [plain(' '), tag('透传来源')] : [])]
: [hint(variantPayload(member.value) ?? '—')],
]
return { cells, defines: range(member.name) }
}),
},
]
return {
kind: 'thiserror.error',
badge: BADGE,
rows,
defines: range(block.facts.name),
absorbs: [],
}
}
/** 变体上面的文档注释,属性可以隔在中间。第一句是「何时」。 */
function whenText(variant: Node): string | undefined {
const docs: Node[] = []
let prev = variant.previousNamedSibling
while (prev?.type === 'attribute_item')
prev = prev.previousNamedSibling
while (prev && (prev.type === 'line_comment' || prev.type === 'block_comment')) {
docs.unshift(prev)
prev = prev.previousNamedSibling
}
if (docs.length === 0)
return undefined
const sentence = firstSentence(docs.map(commentPlain).join(' '))
return sentence || undefined
}
function errorText(variant: Node): string | undefined {
let prev = variant.previousNamedSibling
while (prev?.type === 'attribute_item') {
if (attributeName(prev) === 'error') {
const tree = tokenTree(prev)
const literal = tree?.children.find(child => child?.type === 'string_literal')
const text = stringContent(literal ?? undefined)
if (text)
return text
}
prev = prev.previousNamedSibling
}
return undefined
}
function passesSource(variant: Node): boolean {
const body = variant.childForFieldName('body')
return named(body).some(child => child.type === 'attribute_item' && (attributeName(child) === 'from' || attributeName(child) === 'source'))
}
function tokenTree(attributeItem: Node): Node | undefined {
const attribute = named(attributeItem).find(child => child.type === 'attribute')
return attribute?.children.find(child => child?.type === 'token_tree') ?? undefined
}
function tag(label: string): SegmentGroup {
return { style: 'tag', label, segments: [], color: COLOR }
}
function plain(text: string): Inline {
return { text, role: 'plain' }
}
function hint(text: string): Inline {
return { text, role: 'hint' }
}
function range(node: Node): { startIndex: number, endIndex: number } {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
+112
View File
@@ -0,0 +1,112 @@
import type { FactBlock, Node, PropertyRow, SourceDocument, Translator } from '../contract'
import { enumRows, functionRows, implRows, importRows, moduleRows, plainSay, structRows, traitRows } from '../rules/rustRows'
import { createZhTemplateTranslator, KIND_ZH, ZH_ATTRIBUTE_NAMES } from './zhTemplate'
const say = plainSay()
const template = createZhTemplateTranslator({
id: 'rust.zh-template',
label: '中文直译',
languageIds: ['rust'],
keywords: {
fn: '函数',
let: '令',
mut: '可变',
pub: '公开',
struct: '结构体',
enum: '枚举',
impl: '实现',
trait: '特质',
use: '导入',
mod: '模块',
match: '匹配',
if: '如果',
else: '否则',
for: '遍历',
while: '当',
loop: '循环',
return: '返回',
async: '异步',
await: '等待',
break: '跳出',
continue: '继续',
where: '其中',
type: '类型',
const: '常量',
static: '静态',
unsafe: '不安全',
as: '作为',
in: '在',
ref: '引用',
move: '移动',
dyn: '动态',
crate: '本 crate',
super: '父模块',
self: '自己',
Self: '本类型',
},
literals: { true: '真', false: '假' },
builtinTypes: {},
isTypePosition: () => false,
operators: {},
operatorParents: [],
emptyLiterals: {},
mapLiterals: {},
stringNodes: ['string_literal'],
commentText,
nodeTemplates: {},
})
/**
* Rust 的中文直译。属性表的用词按代码阅读的 Rust 原型:结构体、可见性、变体、分情况。
* 关键字和注释仍走中文直译那一套。常量和变量的行不另写一套,走模板里现成的那套。
*/
export const rustZhTranslator: Translator = {
...template,
attributeNames: {
attributes: { ...ZH_ATTRIBUTE_NAMES.attributes, access: '可见性' },
values: {
...ZH_ATTRIBUTE_NAMES.values,
kind: { ...KIND_ZH, class: '结构体' },
access: {
...ZH_ATTRIBUTE_NAMES.values.access,
public: '公开',
module: '本模块',
super: '父模块',
crate: '本 crate',
},
},
},
describe,
}
function describe(block: FactBlock, document: SourceDocument): PropertyRow[] {
switch (block.kind) {
case 'import':
return importRows(block, say)
case 'class':
return structRows(block, say)
case 'enum':
return enumRows(block, say)
case 'function':
return functionRows(block, say)
case 'trait':
return traitRows(block, say)
case 'impl':
return implRows(block, say)
case 'module':
return moduleRows(block, say)
case 'const':
case 'variable':
return template.describe(block, document)
default:
return []
}
}
function commentText(node: Node, text: string): string {
const doc = node.childForFieldName('doc')
if (doc)
return doc.text.trim()
return text.replace(/^\/\/[/!]?\s?/, '').replace(/^\/\*[*!]?\s?/, '').replace(/\s*\*\/$/, '').trim()
}
@@ -208,10 +208,13 @@ function slotChild(node: Node, slot: Exclude<TemplatePart, string>): Node | null
}
const ATTRIBUTE_ZH: Record<CommonAttribute, string> = { kind: '种类', access: '访问级别', async: '异步', abstract: '抽象', typeOnly: '仅类型' }
const KIND_ZH: Record<BlockKind, string> = {
export const KIND_ZH: Record<BlockKind, string> = {
import: '导入',
function: '函数',
class: '类',
trait: '特质',
impl: '实现',
module: '模块',
enum: '枚举',
exception: '异常',
type: '类型',
@@ -249,7 +252,7 @@ const ARGUMENT_COLUMNS = ['参数', '值']
const YES_NO: Record<'true' | 'false', string> = { true: '是', false: '否' }
/** 筛选属性和值的中文叫法;属性表里的访问级别、异步、抽象、仅类型这几行,属性名和值也用这一套。 */
const ZH_ATTRIBUTE_NAMES: AttributeNames = {
export const ZH_ATTRIBUTE_NAMES: AttributeNames = {
attributes: ATTRIBUTE_ZH,
values: { kind: KIND_ZH, access: ACCESS_ZH, async: YES_NO, abstract: YES_NO, typeOnly: YES_NO },
}
@@ -704,6 +707,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
case 'function':
return functionRows(block.facts, phrases)
case 'class':
case 'trait':
case 'impl':
case 'module':
return classRows(block.facts, phrases)
case 'enum':
return enumRows(block.facts, phrases)
+5 -1
View File
@@ -11,10 +11,11 @@ import { BlockLevel } from './BlockTree'
import { DiffBar, NewFileBanner } from './DiffBar'
import { changeStops, diffTree, foldUnchanged, openedByDiff } from './diffView'
import { acceptNames, DisplayNameContext, documentNames } from './displayNames'
import { coveredByEditing, startEditing } from './editing'
import { coveredByEditing, snippetRefs, startEditing } from './editing'
import { applyFilter, excludedBy, keptWhileEditing } from './filter'
import { FilterBar } from './FilterBar'
import { OverviewRuler } from './OverviewRuler'
import { SnippetEditContext } from './PropertyGrid'
import { asksOf, historyOf, PATH_ASK_ANCHOR } from './qa'
import { useChangeNav } from './useChangeNav'
import { useDefinitionLinks } from './useDefinitionLinks'
@@ -67,6 +68,7 @@ export function App(): ReactElement | null {
const collapsed = useMemo(() => collapsedIds(doc?.blocks ?? [], view.folds, openedByDiff(changes)), [doc, view.folds, changes])
const filter = useMemo(() => applyFilter(doc?.blocks ?? [], excluded, keep), [doc, excluded, keep])
const covered = useMemo(() => coveredByEditing(doc?.blocks ?? [], new Set(view.editing)), [doc, view.editing])
const snippetIds = useMemo(() => new Map(snippetRefs(doc?.blocks ?? []).map(ref => [ref.edit, ref.id])), [doc])
/** 「只看改动」开着(整个工作区共用,扩展端记着);本地不改,改了发给扩展端、等它发回来 */
const [onlyChanges, setOnlyChanges] = useState(false)
/** 「只看改动」时点开过的几段没变的块(按第一块的 id),筛选一变就重新折 */
@@ -176,6 +178,7 @@ export function App(): ReactElement | null {
return (
<HighlightContext value={highlight}>
<DisplayNameContext value={names?.names ?? NO_NAMES}>
<SnippetEditContext value={{ ids: snippetIds, editing: tree.editing, onToggle: tree.onToggleEdit, version: doc.version, focusId, onFocused }}>
<FilterBar
attributes={doc.attributes}
excluded={excluded}
@@ -236,6 +239,7 @@ export function App(): ReactElement | null {
? <BlockLevel blocks={doc.blocks} tree={tree} />
: <div className="text-[var(--vscode-descriptionForeground)]">所有块都被筛选隐藏了。</div>}
</div>
</SnippetEditContext>
</DisplayNameContext>
</HighlightContext>
)
@@ -270,7 +270,7 @@ function BlockCard({ block, tree }: { block: BlockView, tree: BlockTreeState }):
{editing
? (
<CodeEditor
block={block}
source={block}
version={tree.version}
autoFocus={tree.focusId === block.id}
onAutoFocused={tree.onFocused}
+21 -13
View File
@@ -1,5 +1,5 @@
import type { ChangeEvent, KeyboardEvent, ReactElement } from 'react'
import type { BlockView } from './types'
import type { Segment } from './types'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { recolor } from './segments'
import { SegmentText } from './SegmentText'
@@ -7,9 +7,17 @@ import { postMessage } from './vscode'
const EDIT_DEBOUNCE_MS = 200
/** 编辑框要的那一段源码:整块,或格子里单独打开的一段,字段一样。 */
export interface EditableSource {
startLine: number
endLine: number
code: string
codeTokens: Segment[]
}
interface CodeEditorProps {
block: BlockView
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一块的代码没变 */
source: EditableSource
/** 文档版本:每来一份新文档都要对一次输入框内容,哪怕这一段的代码没变 */
version: number
autoFocus: boolean
onAutoFocused: () => void
@@ -17,11 +25,11 @@ interface CodeEditorProps {
}
/** 一块源码的编辑状态:着色层显示,透明输入框接收输入,改动防抖后整行替换回文件。 */
export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
const [text, setText] = useState(block.code)
export function CodeEditor({ source, version, autoFocus, onAutoFocused, onExit }: CodeEditorProps): ReactElement {
const [text, setText] = useState(source.code)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const blockRef = useRef(block)
blockRef.current = block
const sourceRef = useRef(source)
sourceRef.current = source
const textRef = useRef(text)
textRef.current = text
/** 本地改过、还没在新文档里看到的文字;有它时新文档不能覆盖有焦点的输入框 */
@@ -37,7 +45,7 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }:
if (!unsent.current)
return
unsent.current = false
const { startLine, endLine } = blockRef.current
const { startLine, endLine } = sourceRef.current
const value = textRef.current
postMessage({
type: 'edit',
@@ -51,16 +59,16 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }:
useEffect(() => {
const el = textareaRef.current
const focused = el !== null && document.activeElement === el
if (focused && unconfirmed.current !== null && unconfirmed.current !== block.code)
if (focused && unconfirmed.current !== null && unconfirmed.current !== source.code)
return
unconfirmed.current = null
sentLineCount.current = null
if (block.code === textRef.current)
if (source.code === textRef.current)
return
if (focused)
restoreSelection.current = [el.selectionStart, el.selectionEnd]
setText(block.code)
}, [version, block.code])
setText(source.code)
}, [version, source.code])
useLayoutEffect(() => {
const el = textareaRef.current
@@ -121,7 +129,7 @@ export function CodeEditor({ block, version, autoFocus, onAutoFocused, onExit }:
onBlur={flush}
/>
<pre className="cr-code" aria-hidden="true">
<SegmentText segments={recolor(block.codeTokens, block.code, text)} />
<SegmentText segments={recolor(source.codeTokens, source.code, text)} />
{text === '' || text.endsWith('\n') ? ' ' : ''}
</pre>
</div>
@@ -1,7 +1,8 @@
import type { CSSProperties, ReactElement, ReactNode } from 'react'
import type { BlockView, Inline, InlinePart, ItemDiff, ItemsDiff, KindBadge, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { Fragment } from 'react'
import type { BlockView, CellEdit, Inline, InlinePart, ItemDiff, ItemsDiff, KindBadge, PropertyItem, PropertyRow, Removed, RowsDiff } from './types'
import { ChevronDown, ChevronRight, Eye, Pencil } from 'lucide-react'
import { createContext, Fragment, useContext } from 'react'
import { CodeEditor } from './CodeEditor'
import { DiffInline, DiffText } from './DiffText'
import { inlineText } from './segments'
import { SegmentText } from './SegmentText'
@@ -56,6 +57,83 @@ function CellText({ cell, parts }: { cell: Inline[], parts: InlinePart[] | undef
return parts ? <DiffInline parts={parts} /> : <SegmentText segments={cell} />
}
/** 格子里单独打开的那段源码:和块共用同一套铅笔、同一个编辑框。 */
export interface SnippetEditState {
ids: ReadonlyMap<CellEdit, string>
editing: ReadonlySet<string>
onToggle: (id: string) => void
version: number
focusId: string | null
onFocused: () => void
}
export const SnippetEditContext = createContext<SnippetEditState | null>(null)
function EditToggle({ editing, onToggle }: { editing: boolean, onToggle: () => void }): ReactElement {
const label = editing ? '预览' : '编辑'
const Icon = editing ? Eye : Pencil
return (
<button
type="button"
className="cr-tool inline-flex h-5 w-6 items-center justify-center rounded align-middle"
data-active={editing ? '' : undefined}
data-tooltip={label}
aria-label={label}
onClick={onToggle}
>
<Icon size={14} strokeWidth={1.8} aria-hidden />
</button>
)
}
function SnippetSpan({ edit, segments }: { edit: CellEdit, segments: Inline[] }): ReactElement {
const ctx = useContext(SnippetEditContext)
const id = ctx?.ids.get(edit)
if (!ctx || id === undefined)
return <SegmentText segments={segments} />
const editing = ctx.editing.has(id)
return (
<span className="inline-flex flex-wrap items-baseline gap-0.5">
{editing
? (
<span className="block w-full">
<CodeEditor
source={edit.snippet}
version={ctx.version}
autoFocus={ctx.focusId === id}
onAutoFocused={ctx.onFocused}
onExit={() => ctx.onToggle(id)}
/>
</span>
)
: <SegmentText segments={segments} />}
<EditToggle editing={editing} onToggle={() => ctx.onToggle(id)} />
</span>
)
}
function CellView({ cell, index, edits, parts }: {
cell: Inline[]
index: number
edits: CellEdit[] | undefined
parts: InlinePart[] | undefined
}): ReactElement {
const mine = (edits ?? []).filter(edit => edit.cell === index).sort((a, b) => a.from - b.from)
if (mine.length === 0)
return <CellText cell={cell} parts={parts} />
const chunks: ReactElement[] = []
let at = 0
mine.forEach((edit, n) => {
if (edit.from > at)
chunks.push(<SegmentText key={`t${n}`} segments={cell.slice(at, edit.from)} />)
chunks.push(<SnippetSpan key={`s${n}`} edit={edit} segments={cell.slice(edit.from, edit.to)} />)
at = edit.to
})
if (at < cell.length)
chunks.push(<SegmentText key="tail" segments={cell.slice(at)} />)
return <>{chunks}</>
}
/**
* 子行的第一列(字典的键):带下一层的子行前面是折叠箭头(图标、大小、颜色同属性行的),点箭头或这一列切换;
* 同一层里不带下一层的子行在同样的位置留空,第一列才对得齐。
@@ -115,9 +193,9 @@ function PropertyItems({ parentId, items, folding, marks }: { parentId: string,
{item.cells.map((cell, i) => i === 0 && nested
? <ItemHead key={i} id={id} cell={cell} foldable={children.length > 0 && change !== 'del'} folding={folds} />
: (
<p key={i} className="cr-code min-w-0">
<CellText cell={cell} parts={mark?.cells?.[i]} />
</p>
<div key={i} className="cr-code min-w-0">
<CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />
</div>
))}
</div>
{children.length > 0 && !folds.collapsed.has(id) && (change === 'del'
@@ -177,10 +255,10 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: {
{toggle && i === 0
? (
<button type="button" className="cr-item-toggle" aria-expanded={toggle.open} aria-label={`${toggle.open ? '收起' : '展开'}${inlineText(cell)}`} onClick={toggle.onToggle}>
<CellText cell={cell} parts={mark?.cells?.[i]} />
<CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />
</button>
)
: <CellText cell={cell} parts={mark?.cells?.[i]} />}
: <CellView cell={cell} index={i} edits={item.edits} parts={mark?.cells?.[i]} />}
</>
)
const tint = toggle?.open && toggle.badge
@@ -191,11 +269,11 @@ export function PropertyTable({ parentId, columns, items, marks, toggles }: {
{cells.map((cell, i) => item.table && i === cells.length - 1
? (
<div key={i} className="cr-table-cell flex min-w-0 flex-col gap-[3px]" role="cell">
<p className="cr-code">{text(cell, i)}</p>
<div className="cr-code">{text(cell, i)}</div>
<PropertyTable parentId={tableId(id)} columns={item.table.columns} items={item.table.items} marks={mark?.table} />
</div>
)
: <p key={i} className="cr-table-cell cr-code min-w-0" role="cell">{text(cell, i)}</p>)}
: <div key={i} className="cr-table-cell cr-code min-w-0" role="cell">{text(cell, i)}</div>)}
</div>
)
}
@@ -68,11 +68,13 @@ body.vscode-high-contrast-light {
:is([data-kind="import"], [data-kind="copy"]) { --kind: var(--cr-kind-import); }
[data-kind="function"] { --kind: var(--cr-kind-function); }
:is([data-kind="class"], [data-kind="type"]) { --kind: var(--cr-kind-type); }
[data-kind="trait"] { --kind: var(--cr-storage); }
[data-kind="impl"] { --kind: var(--cr-kind-function); }
[data-kind="enum"] { --kind: var(--cr-kind-enum); }
[data-kind="exception"] { --kind: var(--cr-kind-exception); }
:is([data-kind="field"], [data-kind="const"], [data-kind="variable"], [data-kind="label"]) { --kind: var(--cr-kind-data); }
:is([data-kind="if"], [data-kind="else"], [data-kind="case"]) { --kind: var(--cr-kind-branch); }
[data-kind="stage"] { --kind: var(--cr-kind-stage); }
:is([data-kind="stage"], [data-kind="module"]) { --kind: var(--cr-kind-stage); }
[data-kind="loop"] { --kind: var(--cr-kind-loop); }
:is([data-kind="try"], [data-kind="catch"], [data-kind="finally"], [data-kind="health"]) { --kind: var(--cr-kind-error); }
:is([data-kind="return"], [data-kind="start"]) { --kind: var(--cr-kind-return); }
@@ -63,4 +63,24 @@ describe('同一行不会同时在两处能改', () => {
const plain = revealPlan(BLOCKS, 15, 999)
expect(plain && uncoveredPlan(plain, covered)).toEqual(plain)
})
it('格子里单独打开的一段,和盖住它的块、跟它重叠的另一段不能同时编辑', () => {
const snippet = (startLine: number, endLine: number) => ({ startLine, endLine, code: 'x', codeTokens: [] })
const card = block('1', 'react.component', [0, 20])
card.properties = [{
label: '状态',
value: [],
items: [{
cells: [[{ text: 'a', role: 'plain' }], [{ text: 'b', role: 'plain' }]],
edits: [
{ cell: 0, from: 0, to: 1, snippet: snippet(4, 6) },
{ cell: 1, from: 0, to: 1, snippet: snippet(6, 7) },
],
}],
}]
const blocks = [card, block('2', 'function', [30, 32])]
expect(startEditing(blocks, ['1~0', '2'], '1')).toEqual(['2', '1'])
expect(startEditing(blocks, ['1', '2'], '1~0')).toEqual(['2', '1~0'])
expect(startEditing(blocks, ['1~0', '2'], '1~1')).toEqual(['2', '1~1'])
})
})
+46 -5
View File
@@ -1,9 +1,11 @@
import type { BlockView } from './types'
import type { BlockView, CellEdit, PropertyItem } from './types'
/**
* 同一时刻能编辑的几段源码不重叠,同一行不会同时在两处能改:
* ① 块的编辑范围是它的完整源码,子孙都在它的编辑框里:块编辑时它的子孙都不画;
* ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑。
* ② 点「编辑」时,编辑范围和这块重叠的别的块(祖先、子孙,`} else {` 这样共用一行的兄弟块)退出编辑;
* ③ 属性表格子里单独打开的那一段(见 CellEdit)行号落在组件块里面,和块同一条规矩:重叠就不能同时编辑。
* 组件正在编辑时预览不画,里面这段的编辑器也不画。
*/
type Lines = Pick<BlockView, 'startLine' | 'endLine'>
@@ -41,12 +43,51 @@ function* allBlocks(blocks: readonly BlockView[]): Generator<BlockView> {
}
/** 点了 id 这块的「编辑」之后正在编辑的块:加上它,编辑范围和它重叠的别的块退出编辑。 */
export interface SnippetRef {
id: string
edit: CellEdit
startLine: number
endLine: number
}
/** 各块属性表里能单独打开的源码,按画出来的顺序编号。id 是「块 id~序号」。 */
export function snippetRefs(blocks: readonly BlockView[]): SnippetRef[] {
const refs: SnippetRef[] = []
const take = (blockId: string, items: PropertyItem[] | undefined, n: number): number => {
for (const item of items ?? []) {
for (const edit of item.edits ?? []) {
refs.push({ id: `${blockId}~${n}`, edit, startLine: edit.snippet.startLine, endLine: edit.snippet.endLine })
n += 1
}
n = take(blockId, item.items, n)
n = take(blockId, item.table?.items, n)
}
return n
}
for (const block of allBlocks(blocks)) {
let n = 0
if ('properties' in block) {
for (const row of block.properties)
n = take(block.id, row.items, n)
}
else if (block.table) {
take(block.id, block.table.items, 0)
}
}
return refs
}
/** 点了 id 这段的「编辑」之后正在编辑的几段:加上它,行重叠的别的段(块,或格子里的一段源码)退出编辑。 */
export function startEditing(blocks: readonly BlockView[], editing: readonly string[], id: string): string[] {
const byId = new Map([...allBlocks(blocks)].map(block => [block.id, block]))
const byId = new Map<string, Lines>()
for (const block of allBlocks(blocks))
byId.set(block.id, block)
for (const ref of snippetRefs(blocks))
byId.set(ref.id, ref)
const target = byId.get(id)
const others = editing.filter((other) => {
const block = byId.get(other)
return other !== id && !(target && block && overlaps(target, block))
const span = byId.get(other)
return other !== id && !(target && span && overlaps(target, span))
})
return [...others, id]
}
@@ -21,6 +21,9 @@ describe('块的种类图标', () => {
import: 'import',
function: 'square-function',
class: 'box',
trait: 'network',
impl: 'square-function',
module: 'layers',
type: 'braces',
enum: 'list-ordered',
exception: 'triangle-alert',
+20
View File
@@ -4,6 +4,7 @@
export type BlockKind
= | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable'
| 'trait' | 'impl' | 'module'
| 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment'
| 'interpreter' | 'options' | 'case' | 'command' | 'pipeline'
| 'directive' | 'stage' | 'run' | 'copy' | 'setting' | 'label' | 'health' | 'start'
@@ -65,11 +66,30 @@ export interface SourceRange { startIndex: number, endIndex: number }
*/
export interface Defines { defines?: SourceRange }
/** 能单独打开编辑的一段源码:整行切好、着色好,和块的 code / codeTokens 同一套。网页不自己切 */
export interface SourceSnippet {
/** 起止行,0 起,含头含尾 */
startLine: number
endLine: number
code: string
codeTokens: Segment[]
}
/** 属性表一个子行的某一格里,能单独打开的一段源码。cell 是第几格,from / to 是这一格文字里这一处(含头不含尾) */
export interface CellEdit {
cell: number
from: number
to: number
snippet: SourceSnippet
}
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值 */
export interface PropertyItem extends Defines {
cells: Inline[][]
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项),一层层可以一直往下套 */
items?: PropertyItem[]
/** 这一行里能单独打开的几段源码。打开后只出现那一段。没有能打开的就不写 */
edits?: CellEdit[]
/** 排在这一行最后一格里、那格文字下面的表(实参的值里跨几行写的调用的实参表),表里的行又可以带 table;总是展开 */
table?: PreviewTable
}