✨ feat(vscode): 代码阅读把说明里的 @param / @return / @throws 拆到参数表、输出、异常表

This commit is contained in:
cruldra committed 2026-10-08 07:17:26 +08:00
1 parent c2982537d7
commit 0958506681
43 files changed
+1300 -323

No files matched your search

+1 -1
View File
@@ -296,7 +296,7 @@ export function toBlockViews(
if (!layerTranslator)
return { translation: [] }
const { text, table } = layerTranslator.translate(block, layerDocument)
const doc = block.doc && layerTranslator.doc(block.doc, layerDocument)
const doc = block.doc && layerTranslator.doc(block.doc, layerDocument)?.body
return { translation: text, ...(table && { table }), ...(doc && { doc }) }
}
/** 块执行的嵌着的代码分成的几步:只有一步时是它的子块,几步时是各步,交给那种语言转(见 TextBlock.embedded)。 */
+44 -8
View File
@@ -579,8 +579,9 @@ export type TokenRole
* refers:这段讲的是源码里别处的一个名字(那个名字的范围),语言扩展不认得这段自己的位置:写在字符串里的名字
* (FastAPI 路径 `"/{user_id}"` 里的 user_id 讲的是函数的参数 user_id)。预览把这段当那个名字:⌘ / Ctrl + 点击跳到它的定义,
* 单击高亮它的各处出现,那个名字的某处出现盖住 refers 时这段也跟着高亮。offset 照旧是这段照抄的位置。
* tooltip:悬停这段时看的字(说明里写了、参数表里没有的参数名:「没有这个参数」)。
*/
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange }
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor, refers?: SourceRange, tooltip?: string }
/**
* 框的样式,决定框头的图标和框的颜色:
@@ -612,10 +613,37 @@ export type Inline = Segment | SegmentGroup
/**
* 一段 Markdown 说明文字:注释、JSDoc、docstring 去掉注释符号、引号和共同缩进后的原文,不做别的转换
* (reStructuredText 的 `:func:`、JSDoc 的 `@param` 都照抄)。预览按 Markdown 渲染成注释色的段落、列表、代码块,源码里的 HTML 当文字显示。
* (reStructuredText 的 `:func:` 照抄)。预览按 Markdown 渲染成注释色的段落、列表、代码块,源码里的 HTML 当文字显示;
* KDoc 写法的不带路径的引用(`[名字]`、`[类.成员]`)由预览向宿主问它指的定义,问到了能点。
*/
export interface Markdown { markdown: string }
/** 说明里讲的一个参数(`@param 名字 说明`):名字照写(`*args`、`...rest` 带着前缀),说明是 Markdown。 */
export interface DocParam { name: string, text: Markdown }
/**
* 说明里讲的一种异常(`@throws 异常 什么时候抛`)。异常写成 Markdown:只写了名字的(`ValueError`、JSDoc 的 `{ValueError}`)
* 写成不带路径的引用 `[ValueError]`,`[Foo]`、`[说法](路径#Foo)` 照写;触发条件是 Markdown。
*/
export interface DocThrows { exception: Markdown, when: Markdown }
/**
* 一段说明(函数、类这些定义的说明,注释,当说明用的字符串):原文(同 Markdown)按 KDoc 拆成正文和块标签。
* 一行以 `@param`、`@return`(`@returns` 同义)、`@throws`(`@exception` 同义)开头算一条标签,后面接着的行(不是空行、不以 `@` 开头)
* 续在这条后面,空行结束这条;JSDoc 的 `@param {类型} 名字 - 说明`、`@param [名字] 说明` 也认(类型、横杠去掉)。
* 别的标签(`@see`、`@since`)、围栏代码块里的行留在正文里。
*/
export interface Doc {
/** 摘要和正文:拿掉这几种标签以后的原文;只写了标签时没有。 */
body?: Markdown
/** 照写的顺序。 */
params: DocParam[]
/** `@return` 的说明;写了几条时连成一段。 */
returns?: Markdown
/** 照写的顺序。 */
throws: DocThrows[]
}
/** 预览里显示的一段文字:着色的词(一行译文、属性值),或整段 Markdown 说明(注释块、函数和类的说明)。 */
export type PreviewText = Inline[] | Markdown
@@ -625,9 +653,12 @@ export type PreviewText = Inline[] | Markdown
*/
export interface Defines { defines?: SourceRange }
/** 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。 */
/**
* 属性表里展开的一个子行:几列对齐的格子,例如参数的名称 / 类型 / 默认值、字典一项的键 / 值。
* 格子是着色的词,或一段 Markdown(参数的说明、异常的触发条件)。
*/
export interface PropertyItem extends Defines {
cells: Inline[][]
cells: PreviewText[]
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */
items?: PropertyItem[]
/**
@@ -644,6 +675,8 @@ export interface PropertyItem extends Defines {
export interface PropertyRow extends Defines {
label: string
value: PreviewText
/** 跟在值后面的一段说明(Markdown,注释色):函数输出参数那一行,返回类型后面是说明里的 `@return`。 */
note?: Markdown
items?: PropertyItem[]
columns?: string[]
}
@@ -765,8 +798,11 @@ export interface Translator {
describe: (block: FactBlock, document: SourceDocument) => PropertyRow[]
/** 一个表达式或类型节点整个翻成一行译文,译法同属性表里的值:库规则翻默认值、类型时借它。 */
expression: (node: Node, document: SourceDocument) => Inline[]
/** 一段说明文字(注释、docstring、当说明用的字符串)去掉注释符号、引号和共同缩进后的 Markdown 原文,同属性表的「说明」;没有文字时没有。 */
doc: (node: Node, document: SourceDocument) => Markdown | undefined
/**
* 一段说明文字(注释、docstring、当说明用的字符串)读成说明(见 Doc):去掉注释符号、引号和共同缩进后的 Markdown 原文,
* 拆出块标签;正文同属性表的「说明」。几条行文档注释连成一段的(Rust 的 `///`),node 是第一条,翻译插件自己往下连。没有文字时没有。
*/
doc: (node: Node, document: SourceDocument) => Doc | undefined
/** 筛选属性和值叫什么,和属性表里的属性名、值用同一套叫法;不给就都显示 id。 */
attributeNames?: AttributeNames
}
@@ -957,8 +993,8 @@ export interface RuleContext {
displayLanguage?: string
/** 用当前翻译插件把一个表达式或类型节点翻成一行译文(Translator.expression):名字带源码下标,能跳到定义、高亮各处出现;没有翻译插件时照抄源码。 */
translate: (node: Node) => Inline[]
/** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */
doc: (node: Node) => Markdown | undefined
/** 用当前翻译插件读一段说明文字(Translator.doc):正文和块标签;没有翻译插件、没有文字时没有。 */
doc: (node: Node) => Doc | undefined
/**
* 源码里 name 这个名字(例如 raise 后面的异常类名)指的定义在 index 里认出的那一项,规则从定义处读出的值在 IndexEntry.value 里:
* 定义由语言扩展查(同 ⌘ + 点击),落在 index 认出的某一项的名字上才算;同一份源码里同名的名字按一处查。
+23 -16
View File
@@ -33,7 +33,7 @@ export interface ItemDiff {
/** 整行是新加的(它下面各层也都是) */
added?: true
/** 按列:这一格改成了什么样 */
cells?: Record<number, InlinePart[]>
cells?: Record<number, TextDiff>
items?: ItemsDiff
table?: ItemsDiff
}
@@ -49,6 +49,8 @@ export interface RowDiff {
/** 整行是新加的(它下面的子行也都是) */
added?: true
value?: TextDiff
/** 跟在值后面的说明(PropertyRow.note) */
note?: TextDiff
items?: ItemsDiff
}
@@ -79,7 +81,7 @@ function textWithoutSource(text: PreviewText): PreviewText {
function itemWithoutSource(item: PropertyItem): PropertyItem {
return {
cells: item.cells.map(withoutSource),
cells: item.cells.map(textWithoutSource),
...(item.items && { items: item.items.map(itemWithoutSource) }),
...(item.table && { table: tableWithoutSource(item.table) }),
}
@@ -93,6 +95,7 @@ function rowWithoutSource(row: PropertyRow): PropertyRow {
return {
label: row.label,
value: textWithoutSource(row.value),
...(row.note && { note: row.note }),
...(row.items && { items: row.items.map(itemWithoutSource) }),
...(row.columns && { columns: row.columns }),
}
@@ -175,14 +178,18 @@ export function markdownDiff(before: string, after: string): MarkdownPart[] | un
})
}
/** 一段预览文字的字:着色的词拼起来,Markdown 取原文。 */
function plainText(text: PreviewText): string {
return 'markdown' in text ? text.markdown : inlineText(text)
}
/** 一段预览文字前后两版比:都是着色的词逐截比,都是 Markdown 逐词比;一种换成了另一种时按字逐词比。一样时是 undefined。 */
export function textDiff(before: PreviewText, after: PreviewText): TextDiff | undefined {
if (!('markdown' in before) && !('markdown' in after)) {
const inline = inlineDiff(before, after)
return inline && { inline }
}
const plain = (text: PreviewText): string => 'markdown' in text ? text.markdown : inlineText(text)
const markdown = markdownDiff(plain(before), plain(after))
const markdown = markdownDiff(plainText(before), plainText(after))
return markdown && { markdown }
}
@@ -232,13 +239,13 @@ function addedItem(item: PropertyItem): ItemDiff {
return { added: true, ...(items && { items }), ...(table && { table }) }
}
/** 配上的两个子行比:各格逐截比,下一层、套着的表逐行比。一样时是 undefined。 */
/** 配上的两个子行比:各格逐截 / 逐词比,下一层、套着的表逐行比。一样时是 undefined。 */
function itemDiff(before: PropertyItem, after: PropertyItem): ItemDiff | undefined {
const cells: Record<number, InlinePart[]> = {}
const cells: Record<number, TextDiff> = {}
after.cells.forEach((cell, index) => {
const parts = inlineDiff(before.cells[index] ?? [], cell)
if (parts)
cells[index] = parts
const diff = textDiff(before.cells[index] ?? [], cell)
if (diff)
cells[index] = diff
})
const items = itemsDiff(before.items, after.items)
const table = itemsDiff(before.table?.items, after.table?.items)
@@ -257,7 +264,7 @@ function itemDiff(before: PropertyItem, after: PropertyItem): ItemDiff | undefin
export function itemsDiff(before: readonly PropertyItem[] | undefined, after: readonly PropertyItem[] | undefined): ItemsDiff | undefined {
const old = before ?? []
const now = after ?? []
const { toOld, toNew } = pairByKey(old, now, item => inlineText(item.cells[0] ?? []))
const { toOld, toNew } = pairByKey(old, now, item => plainText(item.cells[0] ?? []))
const changed: Record<number, ItemDiff> = {}
now.forEach((item, index) => {
const paired = toOld[index]
@@ -270,15 +277,15 @@ export function itemsDiff(before: readonly PropertyItem[] | undefined, after: re
}
/**
* 配上的两行比:下面的子行逐行比,值逐截 / 逐词比。子行改了时只标子行:带子行的行,值是子行的汇总(「3 个」「字典 2 项」),
* 跟着子行变,不另标。一样时是 undefined。
* 配上的两行比:下面的子行逐行比,值逐截 / 逐词比,跟在值后面的说明逐词比。子行改了时不标值:带子行的行,值是子行的汇总
* (「3 个」「字典 2 项」),跟着子行变,不另标。一样时是 undefined。
*/
function rowDiff(before: PropertyRow, after: PropertyRow): RowDiff | undefined {
const items = itemsDiff(before.items, after.items)
if (items)
return { items }
const value = textDiff(before.value, after.value)
return value && { value }
const note = textDiff(before.note ?? { markdown: '' }, after.note ?? { markdown: '' })
const value = items ? undefined : textDiff(before.value, after.value)
const diff: RowDiff = { ...(items && { items }), ...(value && { value }), ...(note && { note }) }
return Object.keys(diff).length > 0 ? diff : undefined
}
/** 属性表前后两版比:按属性名配对,配上的比值和子行,多出来的行算新加的,没了的行算删掉的、排回原来的位置。一样时是 undefined。 */
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { readDoc } from './docTags'
describe('说明拆成正文和块标签(KDoc)', () => {
it('没有标签时原样是正文', () => {
expect(readDoc('第一句。\n\n第二段,`code` 和 [Name]。')).toEqual({
body: { markdown: '第一句。\n\n第二段,`code` 和 [Name]。' },
params: [],
throws: [],
})
})
it('一行以标签开头算一条,后面接着的行续上,空行结束这条;标签前后的正文都留着', () => {
const doc = readDoc([
'摘要。',
'',
'@param messages 对话历史',
' 接着写的一行',
'@param summary 摘要',
'',
'标签后面的正文。',
'@return 转写',
].join('\n'))
expect(doc.body).toEqual({ markdown: '摘要。\n\n标签后面的正文。' })
expect(doc.params).toEqual([
{ name: 'messages', text: { markdown: '对话历史\n接着写的一行' } },
{ name: 'summary', text: { markdown: '摘要' } },
])
expect(doc.returns).toEqual({ markdown: '转写' })
})
it('`@returns` 同 `@return`,`@exception` 同 `@throws`', () => {
const doc = readDoc('@returns 结果\n@exception ValueError 参数不对')
expect(doc.body).toBeUndefined()
expect(doc.returns).toEqual({ markdown: '结果' })
expect(doc.throws).toEqual([{ exception: { markdown: '[ValueError]' }, when: { markdown: '参数不对' } }])
})
it('认 JSDoc 写法:`{类型}` 和名字后面的横杠去掉,`[名字=默认值]` 取名字', () => {
const doc = readDoc([
'@param {string} path - 文件路径',
'@param {{ a: number }} [options={}] 选项',
'@param [retries] - 重试次数',
'@returns {Promise<Config>} 配置',
'@throws {TypeError} 路径不是字符串',
].join('\n'))
expect(doc.params).toEqual([
{ name: 'path', text: { markdown: '文件路径' } },
{ name: 'options', text: { markdown: '选项' } },
{ name: 'retries', text: { markdown: '重试次数' } },
])
expect(doc.returns).toEqual({ markdown: '配置' })
expect(doc.throws).toEqual([{ exception: { markdown: '[TypeError]' }, when: { markdown: '路径不是字符串' } }])
})
it('异常写成引用:单写名字的加方括号,`[Foo]`、完整链接照写', () => {
const doc = readDoc([
'@throws [DesktopOnly](backend/src/visibility.py#DesktopOnly) 请求不是桌面端发来的',
'@throws [LocalRuntimeDisabled] 总开关关着',
'@throws errors.NotFound 没有这个人',
].join('\n'))
expect(doc.throws).toEqual([
{ exception: { markdown: '[DesktopOnly](backend/src/visibility.py#DesktopOnly)' }, when: { markdown: '请求不是桌面端发来的' } },
{ exception: { markdown: '[LocalRuntimeDisabled]' }, when: { markdown: '总开关关着' } },
{ exception: { markdown: '[errors.NotFound]' }, when: { markdown: '没有这个人' } },
])
})
it('别的标签、围栏代码块里的行留在正文;以 @ 开头的行结束上一条标签', () => {
const doc = readDoc([
'正文。',
'@param a 甲',
'@see other',
'```',
'@param inside 代码里的',
'```',
].join('\n'))
expect(doc.params).toEqual([{ name: 'a', text: { markdown: '甲' } }])
expect(doc.body).toEqual({ markdown: '正文。\n@see other\n```\n@param inside 代码里的\n```' })
})
it('`*args`、`**kwargs` 照写名字;几条 `@return` 连成一段', () => {
const doc = readDoc('@param *args 位置参数\n@param **kwargs 关键字参数\n@return 一\n@return 二')
expect(doc.params.map(param => param.name)).toEqual(['*args', '**kwargs'])
expect(doc.returns).toEqual({ markdown: '一\n\n二' })
})
})
+137
View File
@@ -0,0 +1,137 @@
import type { Doc, DocParam, DocThrows, Markdown } from './contract'
type TagKind = 'param' | 'return' | 'throws'
/** 认的块标签:`@returns` 同 `@return`,`@exception` 同 `@throws`。 */
const TAGS: Record<string, TagKind> = { param: 'param', return: 'return', returns: 'return', throws: 'throws', exception: 'throws' }
/** 一行以块标签开头:标签名,和它后面的文字。 */
const TAG_LINE = /^\s*@(\w+)(?:\s(.*))?$/
/** 围栏代码块的开头(三个以上的反引号或波浪线,前面最多三个空格):里面的行照原样留在正文里。 */
const FENCE_OPEN = /^\s{0,3}(`{3,}|~{3,})/
/** 一个名字或点分的几个名字(`ValueError`、`errors.NotFound`):写成不带路径的引用。 */
const NAME_PATH = /^[\p{L}_$][\p{L}\p{N}_$]*(?:\.[\p{L}_$][\p{L}\p{N}_$]*)*$/u
interface Tag {
kind: TagKind
/** 标签名后面那一行的文字,和续在后面的各行 */
lines: string[]
}
function blank(line: string): boolean {
return line.trim() === ''
}
/** 围栏代码块到这一行结束:和开头同一种字符、不少于开头的个数,后面只有空白。 */
function closesFence(line: string, fence: string): boolean {
const match = /^\s{0,3}(`{3,}|~{3,})\s*$/.exec(line)
return !!match && match[1][0] === fence[0] && match[1].length >= fence.length
}
/** text 开头 open 那个括号到配对的 close 为止(含两头),不配对时没有。 */
function bracketed(text: string, open: string, close: string): string | undefined {
if (text[0] !== open)
return undefined
let depth = 0
for (let i = 0; i < text.length; i++) {
if (text[i] === open)
depth++
else if (text[i] === close && --depth === 0)
return text.slice(0, i + 1)
}
return undefined
}
/** JSDoc 写在开头的 `{类型}`:拿掉它,交出类型和剩下的。 */
function takeType(text: string): { type?: string, rest: string } {
const type = bracketed(text, '{', '}')
return type === undefined ? { rest: text } : { type: type.slice(1, -1).trim(), rest: text.slice(type.length).trimStart() }
}
/** 名字后面 JSDoc 写的 `- `:拿掉。 */
function withoutDash(text: string): string {
return text.replace(/^-(?:\s+|$)/, '')
}
/** 标签的说明:名字后面剩下的那一段接上续写的各行,空的不要。 */
function joinText(first: string, more: readonly string[]): Markdown {
return { markdown: [first, ...more].filter(line => line !== '').join('\n') }
}
/** `@param 名字 说明`、`@param {类型} 名字 - 说明`、`@param [名字=默认值] 说明`。 */
function paramOf([first, ...more]: string[]): DocParam | undefined {
const { rest } = takeType(first)
const optional = bracketed(rest, '[', ']')
const name = optional === undefined ? /^\S*/.exec(rest)![0] : optional.slice(1, -1).split('=')[0].trim()
const text = joinText(withoutDash(rest.slice(optional?.length ?? name.length).trimStart()), more)
return name === '' && text.markdown === '' ? undefined : { name, text }
}
/** `@return 说明`、`@returns {类型} 说明`。 */
function returnOf([first, ...more]: string[]): Markdown {
return joinText(withoutDash(takeType(first).rest), more)
}
/** 异常名写成 Markdown:只是名字的写成不带路径的引用 `[名字]`,别的(`[Foo]`、`[说法](路径#Foo)`、反引号)照写。 */
function exceptionMarkdown(text: string): Markdown {
return { markdown: NAME_PATH.test(text) ? `[${text}]` : text }
}
/** `@throws 异常 什么时候抛`:异常是一个名字、`[Foo]`、`[说法](路径#Foo)`,JSDoc 是 `{Foo}`。 */
function throwsOf([first, ...more]: string[]): DocThrows | undefined {
const { type, rest } = takeType(first)
let exception = type
let after = rest
if (exception === undefined) {
const label = bracketed(rest, '[', ']')
const target = label === undefined ? undefined : bracketed(rest.slice(label.length), '(', ')')
exception = label === undefined ? /^\S*/.exec(rest)![0] : label + (target ?? '')
after = rest.slice(exception.length).trimStart()
}
const when = joinText(withoutDash(after), more)
return exception === '' && when.markdown === '' ? undefined : { exception: exceptionMarkdown(exception), when }
}
/**
* 说明的 Markdown 原文按 KDoc 拆成正文和块标签(规则见 contract 的 Doc)。正文去掉首尾空行,标签拿掉后连着的几个空行并成一个。
*/
export function readDoc(markdown: string): Doc {
const body: string[] = []
const tags: Tag[] = []
let current: Tag | undefined
let fence: string | undefined
for (const line of markdown.split('\n')) {
if (fence !== undefined) {
body.push(line)
if (closesFence(line, fence))
fence = undefined
continue
}
const tag = TAG_LINE.exec(line)
const kind = tag && TAGS[tag[1]]
if (kind) {
current = { kind, lines: [(tag[2] ?? '').trim()] }
tags.push(current)
continue
}
if (current && !blank(line) && !line.trimStart().startsWith('@')) {
current.lines.push(line.trim())
continue
}
current = undefined
fence = FENCE_OPEN.exec(line)?.[1]
if (!(blank(line) && (body.length === 0 || blank(body.at(-1)!))))
body.push(line)
}
while (body.length > 0 && blank(body.at(-1)!))
body.pop()
const returns = tags.filter(tag => tag.kind === 'return').map(tag => returnOf(tag.lines)).filter(text => text.markdown !== '')
return {
...(body.length > 0 && { body: { markdown: body.join('\n') } }),
params: tags.flatMap(tag => tag.kind === 'param' ? paramOf(tag.lines) ?? [] : []),
...(returns.length > 0 && { returns: { markdown: returns.map(text => text.markdown).join('\n\n') } }),
throws: tags.flatMap(tag => tag.kind === 'throws' ? throwsOf(tag.lines) ?? [] : []),
}
}
@@ -0,0 +1,51 @@
"""把对话历史转写成一段文字, 交给不共享历史、每次都从零开始的子代理.
更早的对话已经被压缩成摘要 (`context_compaction["summary"]`, 可能为空),
`messages` 只剩近期窗口, 所以转写拼两段: 前情摘要 + 近期消息 (近期过长时只留尾部).
只转写用户与助手说过的文字: 工具往来与系统消息是执行细节, 子代理要看代码自己读.
平台注入的消息也不转写: 带 `UI_HIDDEN_KEY` 的运行通知 (任务日志 / 批准握手) 不是
谁说过的话, 握手里的「不要再讨论计划」还会和规划任务冲突; 带 `PLATFORM_ERROR_KEY`
的错误回复不是模型说的, 任何模型输入都不能带它.
"""
from __future__ import annotations
from collections.abc import Collection, Sequence
from typing import Any
from agent_compiler_deepagents.middleware import PLATFORM_ERROR_KEY, UI_HIDDEN_KEY
BRIEF_MAX_LINES = 30
USER = "用户"
ASSISTANT = "助手"
_SPEAKER = {"HumanMessage": USER, "AIMessage": ASSISTANT}
def render_conversation_brief(
messages: Sequence[Any],
summary: str | None,
*,
speakers: Collection[str] = (USER, ASSISTANT),
max_lines: int = BRIEF_MAX_LINES,
) -> str:
"""把对话转写成「前情摘要 + 近期消息」一段文字; 什么都没有时返回空串.
@param messages 对话历史 (`messages` 状态).
@param summary 更早对话的压缩摘要, 没有就传 `None`.
@param speakers 转写谁说的话: [USER] / [ASSISTANT]; 只要用户原话时只传 [USER].
@param max_lines 近期消息最多留几行, 多了从头丢弃并注明.
@return 以 `[会话进展]` 开头的转写.
"""
lines = [t for m in messages if (t := _line_of(m, speakers))]
truncated = len(lines) > max_lines
if truncated:
lines = lines[-max_lines:]
summary_text = (summary or "").strip()
if not summary_text and not lines:
return ""
parts = ["[会话进展]" + ("\n(更早对话已省略)" if truncated else "")]
if summary_text:
parts += ["[前情摘要]", summary_text]
parts.extend(lines)
return "\n".join(parts)
@@ -0,0 +1,26 @@
"""只放行从[桌面端](backend/src/luffy_agent/sandbox/local_runtime/visibility.py#require_desktop_client)发来、且[「我的电脑」总开关](backend/src/luffy_agent/config/services.py#get_local_runtime_enabled)开着的请求,因为网页端没有本机可用,这个功能也还没对所有人开放。"""
from __future__ import annotations
from fastapi import Request
from luffy_agent.config.services import get_local_runtime_enabled
from luffy_agent.core.exceptions import AppException
from luffy_agent.sandbox.local_runtime.visibility import require_desktop_client
class LocalRuntimeDisabled(AppException):
status_code = 403
error_code = "LOCAL_RUNTIME_DISABLED"
message = "「我的电脑」运行时暂未开放"
async def require_local_runtime_open(request: Request) -> None:
"""只放行桌面端发来、且 [「我的电脑」总开关](backend/src/luffy_agent/config/services.py#get_local_runtime_enabled) 开着的请求,因为只有桌面端能在用户自己的电脑上跑应用,总开关让平台能把这个功能整体关掉。
@throws [DesktopOnly](backend/src/luffy_agent/sandbox/local_runtime/visibility.py#DesktopOnly) 请求不是桌面端发来的
@throws [LocalRuntimeDisabled] 总开关关着
"""
require_desktop_client(request)
if not await get_local_runtime_enabled():
raise LocalRuntimeDisabled()
@@ -8,7 +8,7 @@ import { celeryRule } from '../rules/celery'
import { dataclassRule } from '../rules/dataclass'
import { enumRule } from '../rules/enum'
import { sqlalchemyRule } from '../rules/sqlalchemy'
import { flatSegments, outline, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, outline, propertyLines, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { createPythonAdapter } from './python'
@@ -55,7 +55,7 @@ function lines(list: readonly BlockView[], name: string): string[] {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
+40 -1
View File
@@ -4,7 +4,7 @@ import { toBlockViews } from '../blockView'
import { blockMarks } from '../diff/viewDiff'
import { rustImplRule } from '../rules/rustImpl'
import { isCrateRoot } from '../rules/rustTree'
import { readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { propertyLines, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { rustZhTranslator } from '../translators/rustZh'
import { createRustAdapter } from './rust'
@@ -249,6 +249,45 @@ describe('rust 注释', () => {
expect(viewLines([list[2]!])).toContain(' 说明: 读出这个账号的凭证。')
})
it('连着几行的 /// 是一段说明,块标签拆开放:@param 进参数表、@return 跟在返回后面、@throws 成异常表;结构体的 @param 对到字段', async () => {
const source = [
'/// 读出这个账号的凭证。',
'///',
'/// @param account 账号',
'/// @param ttl 写错的名字',
'/// @return 没存过时是 None',
'/// @throws [StoreError] 存储读不了',
'pub fn load(account: &str) -> Option<String> { None }',
'',
'/// 一个凭证。',
'/// @param token 访问令牌',
'pub struct Credential { pub token: String, scope: u8 }',
].join('\n')
const list = await views(source)
const fn = card(list, 'function')
const struct = card(list, 'class')
expect('properties' in fn && propertyLines(fn.properties)).toEqual([
'名称: load',
'说明: 读出这个账号的凭证。',
'对不上的参数: 1 个',
' ttl | 写错的名字',
'可见性: 公开',
'输入参数: 1 个',
' account | &str | 账号',
'返回: Option<String> — 没存过时是 None',
'异常: 1 个',
' [StoreError] | 存储读不了',
])
expect('properties' in struct && propertyLines(struct.properties)).toEqual([
'名称: Credential',
'说明: 一个凭证。',
'可见性: 公开',
'字段: 2 个',
' token | String | 公开 | 访问令牌',
' scope | u8 | 本模块 | ',
])
})
it('在 mod、impl 身体里同一套', async () => {
const source = [
'mod store {',
+1 -1
View File
@@ -116,7 +116,7 @@ function referencesInText(pieces: readonly Inline[]): Segment[] {
}
function referencesInItems(items: readonly PropertyItem[] | undefined): Segment[] {
return (items ?? []).flatMap(item => [...item.cells.flatMap(referencesInText), ...referencesInItems(item.items)])
return (items ?? []).flatMap(item => [...item.cells.flatMap(cell => 'markdown' in cell ? [] : referencesInText(cell)), ...referencesInItems(item.items)])
}
/** 属性表里(值、各层子行的格子)带引用的段。 */
+1 -1
View File
@@ -32,7 +32,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map<number, string>
}
const items = (list: readonly PropertyItem[] | undefined): void => {
for (const item of list ?? []) {
item.cells.forEach(inline)
item.cells.forEach(text)
items(item.items)
items(item.table?.items)
}
+24 -12
View File
@@ -1,5 +1,6 @@
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, ThemeColor } from '../contract'
import { attributeName, docSentence, named, oneLine, returnText, stringContent } from './rustTree'
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import { returnNote, throwsRows, writtenParamRows } from './docRows'
import { attributeName, docSummary, named, oneLine, returnText, stringContent } from './rustTree'
/** 和 FastAPI 接口同一个蓝。 */
const COLOR: ThemeColor = { dark: '#61afef', light: '#1a6fb5' }
@@ -16,14 +17,14 @@ export const axumRule: LibraryRule = {
label: 'axum 接口',
languageIds: ['rust'],
libraries: ['axum'],
claim: (block) => {
claim: (block, context) => {
if (block.kind === 'function') {
const route = attributeRoute(block)
return route ? card(block, route) : undefined
return route ? card(block, route, context) : 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 route ? routerCard(block.node, route, context) : undefined
}
return undefined
},
@@ -48,15 +49,18 @@ function attributeRoute(block: FunctionBlock): Route | undefined {
return undefined
}
function card(block: FunctionBlock, route: Route): RuleCard {
const doc = docSentence(block.facts.doc)
function card(block: FunctionBlock, route: Route, context: RuleContext): RuleCard {
const doc = block.facts.doc && context.doc(block.facts.doc)
const summary = docSummary(doc)
const params = pathParams(route.path, block.node)
const rows: PropertyRow[] = [
nameRow(block.facts.name),
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
...(summary ? [{ label: '说明', value: { markdown: summary } }] : []),
...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []),
requestRow(route, params),
{ label: '返回', value: [plain(returnText(block.node))] },
...writtenParamRows(doc?.params ?? [], '参数'),
{ label: '返回', value: [plain(returnText(block.node))], ...returnNote(doc) },
...throwsRows(doc),
]
return {
kind: 'axum.route',
@@ -80,10 +84,11 @@ function handled(node: Node, handler: string | undefined): boolean {
return false
}
function routerCard(node: Node, route: Route & { handlerNode?: Node }): RuleCard {
function routerCard(node: Node, route: Route & { handlerNode?: Node }, context: RuleContext): RuleCard {
const fn = route.handler ? functionNamed(node, route.handler) : undefined
const params = fn ? pathParams(route.path, fn) : []
const doc = fn ? docSentence(leadingDoc(fn)) : undefined
const comment = fn && leadingDoc(fn)
const doc = docSummary(comment && context.doc(comment))
return {
kind: 'axum.route',
badge: BADGE,
@@ -184,11 +189,18 @@ 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
if (!prev || (prev.type !== 'line_comment' && prev.type !== 'block_comment'))
return undefined
let first = prev
for (let before = first.previousNamedSibling; first.type === 'line_comment' && before?.type === 'line_comment' && before.startPosition.row === first.startPosition.row - 1; before = before.previousNamedSibling)
first = before
return first
}
function attributeString(attributeItem: Node): string | undefined {
+2 -2
View File
@@ -3,7 +3,7 @@ import type { Inline, PropertyRow, Segment } from '../contract'
import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { WorkspaceIndexes } from '../workspaceIndex'
import { celeryRule } from './celery'
@@ -38,7 +38,7 @@ async function beatLines(entries: string, header = 'from celery import Celery\nf
function segmentsOf(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces)
}
+3 -3
View File
@@ -454,7 +454,7 @@ function optionsCell(options: Option[], env: Env): Inline[] {
/** 注释的原文(翻译插件去掉 #),几条连成一段。 */
function commentText(comments: Node[], env: Env): Inline[] {
const text = comments.map(comment => env.context.doc(comment)?.markdown).filter(Boolean).join(' ')
const text = comments.map(comment => env.context.doc(comment)?.body?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : []
}
@@ -490,7 +490,7 @@ function beatBlock(app: Node, schedule: Node, settings: [string, Node][], doc: N
const entries = entriesOf(schedule)
const spreads = entries.filter(entry => entry.spread).length
const withOptions = entries.some(entry => extraOptions(entry).length > 0)
const markdown = doc && context.doc(doc)
const markdown = doc && context.doc(doc)?.body
const rows: PropertyRow[] = [
{ label: labels.app, value: context.translate(app) },
...(markdown ? [{ label: labels.doc, value: markdown }] : []),
@@ -515,7 +515,7 @@ function appBlock(target: Node, call: Call, doc: Node | undefined, env: Env): Ru
call.positional.forEach((arg, i) => parameters.set(APP_PARAMETERS[i] ?? String(i), arg))
for (const [key, value] of call.keywords)
parameters.set(key, value)
const markdown = doc && context.doc(doc)
const markdown = doc && context.doc(doc)?.body
const named = new Set<string>(APP_NAMED)
const rows: PropertyRow[] = [
{ label: labels.name, value: target.type === 'identifier' ? [name(target, 'variable')] : context.translate(target) },
+32 -2
View File
@@ -5,7 +5,7 @@ import { fileAttributes } from '../attributes'
import { toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { celeryRule } from './celery'
import { dataclassRule } from './dataclass'
@@ -39,7 +39,7 @@ function named(list: BlockView[], name: string): BlockView {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
@@ -260,4 +260,34 @@ describe('dataclasses 库规则(Python)', () => {
const list = await views('@dataclass\nclass A:\n x: int\n')
expect(list.map(view => view.kind)).toEqual(['class'])
})
it('类说明里的 @param 补上字段的说明(字段自己有注释的不换),对不上的排在说明下面', async () => {
const source = [
'from dataclasses import dataclass',
'',
'@dataclass',
'class Point:',
' """平面上的点。',
'',
' @param x 横坐标',
' @param y 不会盖过字段自己的注释',
' @param z 写错的名字',
' """',
' x: int',
' # 纵坐标',
' y: int = 0',
].join('\n')
const view = named(await views(source), 'Point')
expect(propertyLines(properties(view))).toEqual([
'名称: Point',
'说明: 平面上的点。',
'对不上的参数: 1 个',
' z | 写错的名字',
'访问级别: 公共',
'字段: 2 个',
' x | 整数 | | 横坐标',
' y | 整数 | 0 | 纵坐标',
])
expect(properties(view).find(row => row.label === '字段')?.items?.[0].cells[3]).toEqual({ markdown: '横坐标' })
})
})
+19 -8
View File
@@ -1,6 +1,8 @@
import type { Access, BlockConcept, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import type { Access, BlockConcept, ClassBlock, CodeBlock, Inline, KindIconName, LibraryRule, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { annotatedAssignments, memberDocs, withLeadingComments } from './children'
import { docBodyRows, paramDocs, throwsRows, unmatchedParamRows } from './docRows'
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
@@ -209,16 +211,22 @@ function defaultCell(field: Field, call: Call | undefined, env: Env): Inline[] {
return joined(parts, hint(' · '))
}
/** 说明一格:`field(doc="…")` 的字符串,没写时是讲它的注释、属性 docstring 的原文(用空格连起来);InitVar 的字段前面写「仅初始化时使用」。 */
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, initOnly: boolean, env: Env): Inline[] {
/**
* 说明一格:`field(doc="…")` 的字符串,没写时是讲它的注释、属性 docstring 的原文(用空格连起来),都没有时是类的说明里讲它的 `@param`
* (字段就是构造参数);InitVar 的字段前面写「仅初始化时使用」。
*/
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, initOnly: boolean, docs: ParamDocs, env: Env): PreviewText {
const explicit = stringValue(call?.keywords.get('doc'))
const text = explicit ?? memberDocs(children, field.block).map(node => env.context.doc(node)?.markdown).filter(Boolean).join(' ')
const text = explicit ?? memberDocs(children, field.block).map(node => env.context.doc(node)?.body?.markdown).filter(Boolean).join(' ')
const written = text ? undefined : docs.of(field.name.text)
if (written)
return initOnly ? { markdown: `${env.words.initOnly} · ${written.markdown}` } : written
const doc: Inline[] = text ? [{ text, role: 'comment' }] : []
return initOnly ? joined([[word(env.words.initOnly)], ...(text ? [doc] : [])], hint(' · ')) : doc
}
/** 字段表的一行:字段 | 类型 | 默认值 | 说明。讲的定义是字段名。 */
function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): PropertyItem {
function fieldItem(field: Field, children: readonly CodeBlock[], docs: ParamDocs, env: Env): PropertyItem {
const call = fieldCall(field.value, env)
const initOnly = annotationName(field.annotation, env) === 'InitVar'
const type = initOnly ? genericOf(field.annotation)?.args[0] : undefined
@@ -227,7 +235,7 @@ function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): Prop
[name(field.name, 'variable')],
env.context.translate(type ?? field.annotation),
defaultCell(field, call, env),
docCell(field, children, call, initOnly, env),
docCell(field, children, call, initOnly, docs, env),
],
defines: range(field.name),
}
@@ -246,11 +254,13 @@ function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefine
const { facts, children } = block
const { fields, markers } = fieldsOf(children, env)
const doc = facts.doc && context.doc(facts.doc)
const docs = paramDocs(doc, fields.map(field => field.name.text))
const decorators = facts.decorators.filter(other => other !== decorator)
const options = optionsText(call, env)
const rows: PropertyRow[] = [
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []),
...(doc ? [{ label: labels.doc, value: doc }] : []),
...docBodyRows(doc, labels.doc),
...unmatchedParamRows(docs.unmatched),
...(facts.access ? [{ label: labels.access, value: [{ text: words.access[facts.access], role: 'keyword' as const }] }] : []),
...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []),
...(decorators.length > 0 ? [{ label: labels.decorators, value: joined(decorators.map(other => context.translate(other)), plain('\n')) }] : []),
@@ -259,8 +269,9 @@ function dataclassCard(block: ClassBlock, decorator: Node, call: Call | undefine
label: labels.fields,
value: [hint(words.count(fields.length))],
columns: words.heads,
items: fields.map(field => fieldItem(field, children, env)),
items: fields.map(field => fieldItem(field, children, docs, env)),
},
...throwsRows(doc),
]
const concepts = frozenConcept(call, words)
return {
+95
View File
@@ -0,0 +1,95 @@
import type { Doc, DocParam, Markdown, PreviewText, PropertyRow } from '../contract'
import { hint } from './segments'
/**
* 卡片里讲说明(Doc)的几行,语言默认的卡片(中文直译)和库规则的卡片共用:说明的正文、说明里写了参数表里却没有的参数、
* 参数表里的「说明」一格、输出那一行后面的 `@return`、「异常」一行。用词是中文。
*/
/** 参数名去掉前缀、后缀再对:`*args`、`**kwargs`、`...rest` 的名字是 args、kwargs、rest,可省略参数 `x?` 是 x。 */
export function paramKey(name: string): string {
return name.replace(/^(?:\*{1,2}|\.{3})/, '').replace(/\?$/, '')
}
/** 说明里的参数按名字对到参数表:of 取一个参数的说明,described 是表里有参数对上了说明,unmatched 是表里没有的那几个。 */
export interface ParamDocs {
of: (name: string) => Markdown | undefined
described: boolean
unmatched: DocParam[]
}
/** names 是参数表里的参数名(照写,`*args` 带着星号);同一个参数写了几条时说明连起来。 */
export function paramDocs(doc: Doc | undefined, names: readonly string[]): ParamDocs {
const keys = new Set(names.map(paramKey))
const texts = new Map<string, string>()
const unmatched: DocParam[] = []
for (const param of doc?.params ?? []) {
const key = paramKey(param.name)
if (!keys.has(key))
unmatched.push(param)
else if (param.text.markdown !== '')
texts.set(key, [texts.get(key), param.text.markdown].filter(Boolean).join('\n\n'))
}
return {
of: (name) => {
const text = texts.get(paramKey(name))
return text === undefined ? undefined : { markdown: text }
},
described: texts.size > 0,
unmatched,
}
}
/** 参数表的「说明」一格:对上了的说明,没有时留空。 */
export function paramDocCell(docs: ParamDocs, name: string): PreviewText {
return docs.of(name) ?? []
}
/** 「说明」一行:说明的正文;只写了块标签、没有正文时没有。 */
export function docBodyRows(doc: Doc | undefined, label = '说明'): PropertyRow[] {
return doc?.body ? [{ label, value: doc.body }] : []
}
/** 排在「说明」下面:说明里写了、参数表里没有的参数(名字写错了),名称是提示色、悬停「没有这个参数」,后面是它的说明。 */
export function unmatchedParamRows(unmatched: readonly DocParam[]): PropertyRow[] {
if (unmatched.length === 0)
return []
return [{
label: '对不上的参数',
value: [hint(`${unmatched.length} 个`)],
columns: ['名称', '说明'],
items: unmatched.map(param => ({ cells: [[{ text: param.name, role: 'hint', tooltip: '没有这个参数' }], param.text] })),
}]
}
/**
* 卡片上没有参数表可对(类只有说明、没写构造函数)时,说明里写的参数照写成一张「名称 | 说明」表;一个都没写时没有。
*/
export function writtenParamRows(params: readonly DocParam[], label: string): PropertyRow[] {
if (params.length === 0)
return []
return [{
label,
value: [hint(`${params.length} 个`)],
columns: ['名称', '说明'],
items: params.map(param => ({ cells: [[{ text: param.name, role: 'variable' }], param.text] })),
}]
}
/** 输出那一行跟在值后面的说明(`@return`);没写时没有。 */
export function returnNote(doc: Doc | undefined): Pick<PropertyRow, 'note'> {
return doc?.returns ? { note: doc.returns } : {}
}
/** 「异常」一行:说明里的 `@throws`,下面一张「异常 | 触发条件」表,一条一行(异常名能点);没写时没有。 */
export function throwsRows(doc: Doc | undefined): PropertyRow[] {
const throws = doc?.throws ?? []
if (throws.length === 0)
return []
return [{
label: '异常',
value: [hint(`${throws.length} 个`)],
columns: ['异常', '触发条件'],
items: throws.map(item => ({ cells: [item.exception, item.when] })),
}]
}
+3 -3
View File
@@ -6,7 +6,7 @@ import { toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { celeryRule } from './celery'
@@ -45,7 +45,7 @@ function named(list: BlockView[], name: string): BlockView {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
@@ -224,7 +224,7 @@ describe('枚举:TypeScript 和 Python 同一张卡片', () => {
attributes: view.attributes,
lines: propertyLines(rows),
columns: rows.map(row => row.columns),
roles: rows.at(-1)?.items?.map(item => item.cells.map(cell => flatSegments(cell).map(segment => segment.role))),
roles: rows.at(-1)?.items?.map(item => item.cells.map(cell => flatSegments(inlineOf(cell)).map(segment => segment.role))),
}
}
expect(shape(tsEnum)).toEqual(shape(pyEnum))
+51 -8
View File
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest'
import { toBlockViews } from '../blockView'
import { createPythonAdapter } from '../languages/python'
import { DocumentLookups } from '../ruleLookups'
import { flatSegments, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { WorkspaceIndexes } from '../workspaceIndex'
import { fastapiRule } from './fastapi'
@@ -47,7 +47,7 @@ async function route(source: string, fn: string, lookup?: RuleContext['lookup'])
function segmentsOf(row: PropertyRow | undefined): Segment[] {
const pieces: Inline[] = [
...(!row || 'markdown' in row.value ? [] : row.value),
...(row?.items ?? []).flatMap(item => item.cells.flat()),
...(row?.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
]
return flatSegments(pieces)
}
@@ -97,20 +97,20 @@ describe('fastapi 库规则:接口卡片', () => {
const rows = properties(await route(ROUTES, 'update_user', known({ Forbidden: '403' })))
const request = rows.find(row => row.label === '请求')
const [url, method] = request?.items ?? []
expect(flatSegments(url.cells[1]).map(piece => [piece.text, piece.role])).toEqual([['/users', 'string'], ['/', 'string'], ['{', 'variable'], ['user_id', 'variable'], ['}', 'variable']])
for (const piece of flatSegments(url.cells[1]))
expect(flatSegments(inlineOf(url.cells[1])).map(piece => [piece.text, piece.role])).toEqual([['/users', 'string'], ['/', 'string'], ['{', 'variable'], ['user_id', 'variable'], ['}', 'variable']])
for (const piece of flatSegments(inlineOf(url.cells[1])))
expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text)
// 路径里的 user_id 讲的是 update_user 的参数 user_id:高亮参数的各处出现时跟着亮,⌘ + 点击跳到参数
const param = ROUTES.indexOf('user_id: Int4Path', ROUTES.indexOf('def update_user'))
expect(flatSegments(url.cells[1]).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: param, endIndex: param + 'user_id'.length })
expect(flatSegments(inlineOf(url.cells[1])).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: param, endIndex: param + 'user_id'.length })
expect(method.cells[1]).toEqual([{ text: 'PUT', role: 'keyword', color: { dark: '#fca130', light: '#b35f00' } }])
const [ok, forbidden, validation] = rows.find(row => row.label === '响应')?.items ?? []
expect(ok.cells[0]).toEqual([{ text: '200', role: 'number', color: { dark: '#2ea043', light: '#1a7f37' } }])
const model = ROUTES.indexOf('response_model=UserDetailResponse', ROUTES.indexOf('@router.put')) + 'response_model='.length
expect(ok.cells[1]).toEqual([{ text: 'UserDetailResponse', role: 'type', offset: model }])
expect(forbidden.cells[0]).toEqual([{ text: '403', role: 'string' }])
expect(flatSegments(forbidden.cells[1])).toEqual([{ text: 'Forbidden', role: 'type', offset: ROUTES.indexOf('Forbidden("无权更新') }])
expect(flatSegments([...validation.cells[1], ...validation.cells[2]]).map(piece => piece.role)).toEqual(['hint', 'hint'])
expect(flatSegments(inlineOf(forbidden.cells[1]))).toEqual([{ text: 'Forbidden', role: 'type', offset: ROUTES.indexOf('Forbidden("无权更新') }])
expect(flatSegments([...inlineOf(validation.cells[1]), ...inlineOf(validation.cells[2])]).map(piece => piece.role)).toEqual(['hint', 'hint'])
// 名字都带下标、指回源码原文
for (const piece of rows.flatMap(segmentsOf).filter(piece => piece.offset !== undefined && piece.role !== 'string' && piece.role !== 'variable'))
expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text)
@@ -316,6 +316,49 @@ async function routerCard(source: string, name: string): Promise<BlockView> {
return found
}
describe('fastapi 库规则:说明里的块标签', () => {
const SOURCE = [
'from fastapi import APIRouter, Depends, Query',
'',
'router = APIRouter(prefix="/apps")',
'',
'@router.get("/{app_id}")',
'async def get_app(app_id: int, q: str | None = Query(None), db = Depends(get_db)) -> AppOut:',
' """取一个应用。',
'',
' @param app_id 应用 id',
' @param q 按名字搜',
' @param db 数据库会话',
' @param qq 写错的名字',
' @return 应用详情',
' @throws [AppNotFound] 没有这个应用',
' """',
].join('\n')
it('@param 对到请求表(一个参数一行,多一列说明),依赖参数不算对不上;@return 跟在响应后面;@throws 成异常表', async () => {
const lines = propertyLines(properties(await route(SOURCE, 'get_app')))
expect(lines).toEqual([
'名称: get_app',
'说明: 取一个应用。',
'对不上的参数: 1 个',
' qq | 写错的名字',
'路由器: router',
'异步: 是',
'依赖: get_db(db)',
'请求: GET /apps/{app_id}',
' URL | /apps/{app_id} | ',
' 方法 | GET | ',
' 路径参数 | app_id: 整数 | 应用 id',
' 查询参数 | q: 字符串 | 空 | 按名字搜',
'响应: 200 / 422 — 应用详情',
' 200 | AppOut | 成功',
' 422 | 参数校验失败 | FastAPI 自动返回',
'异常: 1 个',
' [AppNotFound] | 没有这个应用',
])
})
})
describe('fastapi 库规则:路由器卡片', () => {
it('luffy 的 router:名称 / 前缀 / 标签,接口表列这份文件里挂在它上面的接口(方法 / 完整路径 / 函数 / docstring 第一行)', async () => {
const card = await routerCard(ROUTES, 'router')
@@ -339,7 +382,7 @@ describe('fastapi 库规则:路由器卡片', () => {
expect(getUser.cells[2]).toEqual([{ text: 'get_user', role: 'function', offset: ROUTES.indexOf('get_user(') }])
const paramOf = (fn: string): number => ROUTES.indexOf('user_id: Int4Path', ROUTES.indexOf(`def ${fn}`))
for (const [row, fn] of [[getUser, 'get_user'], [updateUser, 'update_user']] as const)
expect(flatSegments(row.cells[1]).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: paramOf(fn), endIndex: paramOf(fn) + 'user_id'.length })
expect(flatSegments(inlineOf(row.cells[1])).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: paramOf(fn), endIndex: paramOf(fn) + 'user_id'.length })
expect(updateUser.cells[0]).toEqual([{ text: 'PUT', role: 'keyword', color: { dark: '#fca130', light: '#b35f00' } }])
})
+49 -19
View File
@@ -1,5 +1,7 @@
import type { FunctionBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, ParameterFacts, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, VariableBlock, WorkspaceIndex } from '../contract'
import type { FunctionBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, ParameterFacts, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, VariableBlock, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { docBodyRows, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { callOf, genericOf, lastName, libraryName, stringContent, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, range, word } from './segments'
@@ -74,6 +76,8 @@ interface Words {
no: string
count: (n: number) => string
requestHeads: string[]
/** 请求表里有参数写了说明(`@param`)时多出的那一列 */
requestDoc: string
responseHeads: string[]
/** 路由器卡片的接口表:方法 | 路径 | 函数 | 说明 */
routeHeads: string[]
@@ -113,6 +117,7 @@ const ZH: Words = {
no: '否',
count: n => `${n} 个`,
requestHeads: ['项', '值'],
requestDoc: '说明',
responseHeads: ['状态码', '内容', '说明'],
routeHeads: ['方法', '路径', '函数', '说明'],
items: { url: 'URL', method: '方法', path: '路径参数', query: '查询参数', header: '请求头', cookie: 'Cookie', body: '请求体' },
@@ -325,11 +330,11 @@ function parametersOf(definition: Node): Map<string, Node> {
return new Map(names.flatMap(param => param?.type === 'identifier' ? [[param.text, param] as const] : []))
}
/** 函数 docstring 的第一行(翻译插件取的说明原文),没有 docstring 时没有。 */
/** 函数 docstring 正文的第一行(翻译插件读出的说明,块标签已经拿掉),没有 docstring 时没有。 */
function docFirstLine(definition: Node, env: Env): string | undefined {
const [first] = syntaxChildren(definition.childForFieldName('body'))
const [doc] = first?.type === 'expression_statement' ? syntaxChildren(first) : []
const markdown = doc?.type === 'string' ? env.context.doc(doc)?.markdown : undefined
const markdown = doc?.type === 'string' ? env.context.doc(doc)?.body?.markdown : undefined
return markdown?.split('\n').map(line => line.trim()).find(line => line !== '')
}
@@ -624,9 +629,37 @@ function methodText(method: string): Inline[] {
return [{ text: method, role: 'keyword', ...(METHOD_COLORS[method] && { color: METHOD_COLORS[method] }) }]
}
/** 请求表的一行:项 | 值。 */
function requestItem(label: string, value: Inline[]): PropertyItem {
return { cells: [[hint(label)], value] }
/** 请求表的一行:项 | 值,表里有说明一列时再加说明(note,没有说明的是空的)。 */
function requestItem(label: string, value: Inline[], note?: PreviewText): PropertyItem {
return { cells: [[hint(label)], value, ...(note ? [note] : [])] }
}
/**
* 请求表的各项:URL、方法,然后路径参数、查询参数、请求头、Cookie、请求体,有才列。说明里有请求参数对上了 `@param`(docs)时
* described:表多一列说明,这几种参数一个参数一行、各带它的说明;没有时同一种参数逗号隔开写在一行。
*/
function requestItems(url: Inline[], method: string, placed: readonly Placed[], docs: ParamDocs, env: Env): { items: PropertyItem[], described: boolean } {
const { words, context } = env
const described = placed.some(item => item.place !== 'dependency' && docs.of(item.param.text))
const item = (label: string, value: Inline[], note?: PreviewText): PropertyItem => requestItem(label, value, described ? note ?? [] : undefined)
const at = (place: Place): Placed[] => placed.filter(one => one.place === place)
const listed = (place: Exclude<Place, 'dependency'>): PropertyItem[] => {
const found = at(place)
if (described)
return found.map(one => item(words.items[place], paramsText([one], env), docs.of(one.param.text)))
return found.length > 0 ? [item(words.items[place], paramsText(found, env))] : []
}
const body = at('body')
const [only] = body
return {
items: [
item(words.items.url, url),
item(words.items.method, methodText(method)),
...(['path', 'query', 'header', 'cookie'] as const).flatMap(listed),
...(body.length === 1 && only?.type ? [item(words.items.body, context.translate(only.type), docs.of(only.param.text))] : listed('body')),
],
described,
}
}
function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
@@ -639,16 +672,12 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
const inPath = pathNames([router.prefix, route.path])
const placed = facts.parameters.flatMap(parameter => placeOf(parameter, inPath, env) ?? [])
const at = (place: Place): Placed[] => placed.filter(item => item.place === place)
const body = at('body')
const params = new Map(facts.parameters.flatMap(({ name: param }) => param.type === 'identifier' ? [[param.text, param] as const] : []))
const url = [...pathSegments(router.prefix, params, env), ...pathSegments(route.path, params, env)]
const request: PropertyItem[] = [
requestItem(words.items.url, url),
requestItem(words.items.method, methodText(route.method)),
...(['path', 'query', 'header', 'cookie'] as const).flatMap(place => at(place).length > 0 ? [requestItem(words.items[place], paramsText(at(place), env))] : []),
...(body.length > 0 ? [requestItem(words.items.body, body.length === 1 && body[0].type ? context.translate(body[0].type) : paramsText(body, env))] : []),
]
const doc = facts.doc && context.doc(facts.doc)
const docs = paramDocs(doc, facts.parameters.map(parameter => parameter.name.text))
const request = requestItems(url, route.method, placed, docs, env)
const statuses = new Statuses()
const success = call.keywords.get('status_code')
@@ -668,7 +697,6 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
if (placed.some(item => item.place !== 'dependency'))
statuses.add(numericStatus('422'), [hint(words.validation)], [hint(words.automatic)])
const doc = facts.doc && context.doc(facts.doc)
const tags = [...router.tags, ...listItems(call.keywords.get('tags'))].map(tag => tagText(tag, env))
const decorators = facts.decorators.filter(other => other !== route.decorator)
const dependencies = [
@@ -680,7 +708,8 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
]
const rows: PropertyRow[] = [
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'function')] }] : []),
...(doc ? [{ label: labels.doc, value: doc }] : []),
...docBodyRows(doc, labels.doc),
...unmatchedParamRows(docs.unmatched),
{ label: labels.router, value: [name(router.target, 'variable')] },
...(tags.length > 0 ? [{ label: labels.tags, value: joined(tags, separator) }] : []),
{ label: labels.async, value: [word(facts.async ? words.yes : words.no)] },
@@ -689,10 +718,11 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
{
label: labels.request,
value: [hint(`${route.method} ${textOf(url)}`)],
columns: words.requestHeads,
items: request,
columns: request.described ? [...words.requestHeads, words.requestDoc] : words.requestHeads,
items: request.items,
},
responsesRow(labels.responses, statuses, env),
{ ...responsesRow(labels.responses, statuses, env), ...returnNote(doc) },
...throwsRows(doc),
]
return {
kind: KIND,
@@ -712,7 +742,7 @@ function routerCard(block: VariableBlock, router: Router, routes: readonly Route
const { context, words } = env
const { labels } = words
const separator = hint(words.listSeparator)
const doc = block.facts.doc && context.doc(block.facts.doc)
const doc = block.facts.doc && context.doc(block.facts.doc)?.body
const tags = router.tags.map(tag => tagText(tag, env))
const dependencies = listedDependencies(router.dependencies, env)
const statuses = new Statuses()
+4 -3
View File
@@ -5,7 +5,7 @@ import { fileAttributes } from '../attributes'
import { toBlockViews } from '../blockView'
import { diffBlockTrees } from '../diff/blockDiff'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { celeryRule } from './celery'
import { dataclassRule } from './dataclass'
@@ -46,7 +46,7 @@ function named(list: BlockView[], name: string): BlockView {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
@@ -260,7 +260,8 @@ describe('pydantic 库规则(Python)', () => {
expect(Object.keys(added ?? {}).filter(index => added?.[Number(index)].added)).toEqual(['7'])
const traceFields = properties(trace).findIndex(row => row.label === '字段')
const cell = traceChange.marks?.rows?.changed[traceFields].items?.changed[2].cells?.[2]
const marked = traceChange.marks?.rows?.changed[traceFields].items?.changed[2].cells?.[2]
const cell = marked && 'inline' in marked ? marked.inline : undefined
expect(cell?.map(part => part.change)).toEqual(['del', 'add'])
expect(flatSegments(cell?.[0].segments ?? []).map(segment => segment.text).join('')).toBe('空')
expect(flatSegments(cell?.[1].segments ?? []).map(segment => segment.text).join('')).toBe('""')
+14 -9
View File
@@ -1,6 +1,8 @@
import type { Access, ClassBlock, CodeBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, WorkspaceIndex } from '../contract'
import type { Access, ClassBlock, CodeBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, ThemeColor, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { annotatedAssignments, memberDocs, withLeadingComments } from './children'
import { docBodyRows, paramDocs, throwsRows, unmatchedParamRows } from './docRows'
import { aliasesOf, callOf, canonicalName, genericOf, libraryName, libraryNames, stringValue, syntaxChildren, unwrapType } from './pythonTree'
import { hint, joined, name, plain, range, word } from './segments'
@@ -328,26 +330,26 @@ function defaultCell(field: Field, call: Call | undefined, env: Env): Inline[] {
return joined(parts, hint(' · '))
}
/** 说明:`Field(description=...)` 优先;没有就用字段上方注释、行尾注释、属性 docstring。 */
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, env: Env): Inline[] {
/** 说明:`Field(description=...)` 优先;没有就用字段上方注释、行尾注释、属性 docstring;都没有时是类的说明里讲它的 `@param`。 */
function docCell(field: Field, children: readonly CodeBlock[], call: Call | undefined, docs: ParamDocs, env: Env): PreviewText {
const description = call?.keywords.get('description')
const literal = stringValue(description)
if (literal)
return [{ text: literal, role: 'comment' }]
if (description)
return env.context.translate(description)
const text = memberDocs(children, field.block).map(node => env.context.doc(node)?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : []
const text = memberDocs(children, field.block).map(node => env.context.doc(node)?.body?.markdown).filter(Boolean).join(' ')
return text ? [{ text, role: 'comment' }] : docs.of(field.name.text) ?? []
}
function fieldItem(field: Field, children: readonly CodeBlock[], env: Env): PropertyItem {
function fieldItem(field: Field, children: readonly CodeBlock[], docs: ParamDocs, env: Env): PropertyItem {
const call = namedCall(field.value, 'Field', env.names)
return {
cells: [
[name(field.name, 'variable')],
env.context.translate(field.annotation),
defaultCell(field, call, env),
docCell(field, children, call, env),
docCell(field, children, call, docs, env),
],
defines: range(field.name),
}
@@ -360,10 +362,12 @@ function modelCard(block: ClassBlock, env: Env): RuleCard {
const config = configOf(children)
const fields = fieldsOf(children, env)
const doc = facts.doc && context.doc(facts.doc)
const docs = paramDocs(doc, fields.map(field => field.name.text))
const options = optionsText(config?.value, env)
const rows: PropertyRow[] = [
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'type')] }] : []),
...(doc ? [{ label: labels.doc, value: doc }] : []),
...docBodyRows(doc, labels.doc),
...unmatchedParamRows(docs.unmatched),
...(facts.access ? [{ label: labels.access, value: [{ text: words.access[facts.access], role: 'keyword' as const }] }] : []),
...(facts.bases.length > 0 ? [{ label: labels.bases, value: joined(facts.bases.map(base => context.translate(base.value)), plain(', ')) }] : []),
...(options.length > 0 ? [{ label: labels.options, value: options }] : []),
@@ -372,9 +376,10 @@ function modelCard(block: ClassBlock, env: Env): RuleCard {
label: labels.fields,
value: [hint(words.count(fields.length))],
columns: words.heads,
items: fields.map(field => fieldItem(field, children, env)),
items: fields.map(field => fieldItem(field, children, docs, env)),
}]
: []),
...throwsRows(doc),
]
return {
kind: KIND,
+72 -7
View File
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { diffBlockTrees } from '../diff/blockDiff'
import { createTypeScriptAdapter } from '../languages/typescript'
import { propertyLines, readFixture, segmentText, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { inlineOf, previewText, propertyLines, readFixture, viewLines, WASM_DIR, withBlocks } from '../testSupport'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { reactRule } from './react'
@@ -186,6 +186,71 @@ describe('react 库规则:构造参数', () => {
})
})
describe('react 库规则:说明里的块标签', () => {
const SOURCE = [
'interface BadgeProps {',
' /** 显示的字 */',
' label: string',
' tone?: string',
'}',
'',
'/**',
' * 小徽标。',
' *',
' * @param props 徽标的属性',
' * @param props.tone 颜色',
' * @param label 不会盖过字段自己的注释',
' * @param size 写错的名字',
' * @throws [RangeError] 颜色不认识',
' */',
'export function Badge({ label, tone = \'gray\' }: BadgeProps) {',
' /**',
' * 点了复制。',
' * @param event 点击事件',
' * @return 复制成没成',
' */',
' function onCopy(event: MouseEvent) {',
' return true',
' }',
' return <span onClick={onCopy}>{label}</span>',
'}',
].join('\n')
it('组件:字段没注释时说明格子用 @param(props.tone 也算),讲整个参数的跟在构造参数后面,对不上的列在说明下面,@throws 成异常表', async () => {
const view = await card(SOURCE, 'Badge')
const rows = properties(view)
expect(lines(view)).toEqual([
'名称: Badge ⟦具名导出|⟧',
'说明: 小徽标。',
'对不上的参数: 1 个',
' size | 写错的名字',
'构造参数: 2 个 · 类型 BadgeProps — 徽标的属性',
' label | 字符串 | — | 是 | 显示的字',
' tone | 字符串 | \'gray\' | 否 | 颜色',
'异常: 1 个',
' [RangeError] | 颜色不认识',
])
const props = rows.find(row => row.label === '构造参数')
expect(props?.note).toEqual({ markdown: '徽标的属性' })
expect(props?.items?.map(item => item.cells[4])).toEqual([[{ text: '显示的字', role: 'comment' }], { markdown: '颜色' }])
expect(rows.find(row => row.label === '对不上的参数')?.items?.[0].cells[0]).toEqual([{ text: 'size', role: 'hint', tooltip: '没有这个参数' }])
expect(rows.find(row => row.label === '异常')?.items?.[0].cells).toEqual([{ markdown: '[RangeError]' }, { markdown: '颜色不认识' }])
expect(lines(view).join('\n')).not.toMatch(/@param|@throws/)
})
it('事件处理函数:说明、@param 时参数排成带说明的表、@return 一行', async () => {
const handler = flatten(await views(SOURCE)).find(view => view.kind === 'react.handler')
expect(lines(handler)).toEqual([
'名称: onCopy',
'说明: 点了复制。',
'参数: 1 个',
' event | MouseEvent | — | 点击事件',
'返回: — 复制成没成',
'绑定事件: <span> 的 onClick',
])
})
})
describe('react 库规则:组件体一块一张卡片(luffy 的 PlanChecklist,原型「对照」)', () => {
it('组件卡片的行只有名称 / 说明 / 构造参数,组件体的语句照源码顺序是它的子块', async () => {
const view = await card(PLAN, 'PlanChecklist')
@@ -499,10 +564,10 @@ describe('react 库规则:渲染大纲', () => {
' ⟦|当 platform 全等 "windows" 时⟧ WindowControls',
])
const [div] = outlineItems(view)
const [tagSegment, , chip] = div.cells[0]
const [tagSegment, , chip] = inlineOf(div.cells[0] ?? [])
expect(tagSegment).toEqual({ text: 'div', role: 'plain', color: { dark: '#808080', light: '#6f6f6f' } })
expect(chip).toMatchObject({ style: 'classes', detail: 'flex h-10 shrink-0 select-none items-center border-b border-border bg-background' })
const controls = div.items?.[2].cells[0].find(piece => 'role' in piece && piece.text === 'WindowControls')
const controls = inlineOf(div.items?.[2].cells[0] ?? []).find(piece => 'role' in piece && piece.text === 'WindowControls')
expect(controls).toMatchObject({ role: 'type', color: { dark: '#61dafb', light: '#0a7ea4' } })
expect(controls && 'role' in controls && TITLEBAR.slice(controls.offset, (controls.offset ?? 0) + 14)).toBe('WindowControls')
})
@@ -528,8 +593,8 @@ describe('react 库规则:渲染大纲', () => {
' ReactMarkdown remarkPlugins=[remarkGfm] components=对象:h2、a {markdown}',
' ⟦|否⟧ p ⟦样式 2 个类|⟧ {DOC_TITLE[doc]}内容尚未配置, 请在管理后台“站点配置”中填写。',
])
const link = findItem(outlineItems(view), item => segmentText(item.cells[0]).startsWith('Link'))
const chip = link?.cells[0].find(piece => !('role' in piece) && piece.style === 'classes')
const link = findItem(outlineItems(view), item => previewText(item.cells[0] ?? []).startsWith('Link'))
const chip = inlineOf(link?.cells[0] ?? []).find(piece => !('role' in piece) && piece.style === 'classes')
expect(chip && !('role' in chip) && chip.detail).toBe([
'flex items-center gap-1.5 px-4 md:px-6 py-1.5 rounded-full text-[13px] md:text-[14px] font-semibold transition-all duration-300',
'doc 全等 "terms" 时:bg-surface text-foreground shadow-sm',
@@ -581,8 +646,8 @@ describe('react 库规则:渲染大纲', () => {
])
expect(text).toContain(' SettingsRow label="规格" value=deployment?.instance_type_code ?? "—" note="重新发布时可换档"')
expect(text.join('\n')).not.toContain('text-primary underline')
const anchor = findItem(outlineItems(view), item => item.cells[0].some(piece => 'role' in piece && piece.text === 'a'))
const chip = anchor?.cells[0].find(piece => !('role' in piece) && piece.style === 'classes')
const anchor = findItem(outlineItems(view), item => inlineOf(item.cells[0] ?? []).some(piece => 'role' in piece && piece.text === 'a'))
const chip = inlineOf(anchor?.cells[0] ?? []).find(piece => !('role' in piece) && piece.style === 'classes')
expect(chip && !('role' in chip) && chip.detail).toBe('text-primary underline underline-offset-2')
})
+90 -26
View File
@@ -1,5 +1,7 @@
import type { CodeBlock, Inline, KindIconName, LibraryRule, Node, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, SourceRange, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { CodeBlock, Doc, Inline, KindIconName, LibraryRule, Markdown, Node, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, SegmentGroup, SourceRange, ThemeColor, TokenRole, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { BindingFacts, BodyFacts, ClassFacts, ComponentFacts, DerivedFacts, EffectFacts, FieldFacts, FieldText, HandlerFacts, OutlineNode, Place, PropsFacts, ReadFacts, RefFacts, StateFacts } from './reactTree'
import { docBodyRows, paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { argumentsOf, attributeName, attributeValue, bodyOf, clientDirective, commentText, componentAt, componentOf, componentOfFunction, componentsIn, declaredTypes, enclosingComponent, enclosingFunction, isComponentTag, isFunction, isJsx, named, outlineOf, parametersOf, placesOf, propsOf, sameNode, statementsOf, stringContent, unwrap } from './reactTree'
import { hint, joined, name, plain, range } from './segments'
@@ -63,7 +65,9 @@ const LOOKS: Record<CardKind, { color: ThemeColor, icon: KindIconName }> = {
interface Words {
/** 各种卡片的名字,筛选栏里也叫这个 */
kinds: Record<CardKind, string>
labels: Record<'name' | 'doc' | 'props' | 'initial' | 'updates' | 'uses' | 'variable' | 'hook' | 'meaning' | 'expression' | 'deps' | 'trigger' | 'cleanup' | 'parameters' | 'events' | 'outline', string>
labels: Record<'name' | 'doc' | 'props' | 'initial' | 'updates' | 'uses' | 'variable' | 'hook' | 'meaning' | 'expression' | 'deps' | 'trigger' | 'cleanup' | 'parameters' | 'returns' | 'events' | 'outline', string>
/** 事件处理函数的说明里有参数对上了 `@param` 时,参数排成表:名称 | 类型 | 默认值 | 说明 */
parameterHeads: string[]
/** 触发时机:a、b 变化时,仅挂载时,每次渲染后;useLayoutEffect 在浏览器绘制前跑 */
changed: string
mount: string
@@ -160,9 +164,11 @@ const ZH: Words = {
trigger: '触发时机',
cleanup: '清理函数',
parameters: '参数',
returns: '返回',
events: '绑定事件',
outline: 'JSX 结构大纲',
},
parameterHeads: ['名称', '类型', '默认值', '说明'],
changed: '变化时',
mount: '仅挂载时',
afterRender: '每次渲染后',
@@ -290,10 +296,21 @@ function componentName(component: ComponentFacts, { words }: Env): PropertyRow {
}
}
/** 说明:组件上面的 JSDoc。 */
function docRows(component: ComponentFacts, { context, words }: Env): PropertyRow[] {
const doc = component.doc && context.doc(component.doc)
return doc ? [{ label: words.labels.doc, value: doc }] : []
/**
* 组件说明里的 `@param` 对到构造参数的各项:属性名对上的进说明一格,写成 `props.title`(属性参数名.属性名)的也算;
* 只写属性参数名的(`@param props`)讲的是整个参数,跟在构造参数那一行后面(whole)。属性参数是解构写的、没有名字时,
* 说明里点号前面写的那个名字(`@param props.title` 的 props)当它的名字。
*/
function propDocs(doc: Doc | undefined, props: PropsFacts, entries: readonly PropEntry[]): { docs: ParamDocs, whole?: Markdown } {
const written = doc?.params ?? []
const dotted = written.flatMap(param => param.name.includes('.') ? [param.name.slice(0, param.name.indexOf('.'))] : [])
const holder = props.parameter?.type === 'identifier' ? props.parameter.text : dotted[0]
const prefix = holder && `${holder}.`
const whole = written.find(param => param.name === holder)?.text
const params = written
.filter(param => param.name !== holder)
.map(param => prefix && param.name.startsWith(prefix) ? { ...param, name: param.name.slice(prefix.length) } : param)
return { docs: paramDocs(doc && { ...doc, params }, entries.map(entry => entry.key)), ...(whole?.markdown && { whole }) }
}
// ─── 组件:构造参数 ───────────────────────────────────────────────────────────
@@ -393,19 +410,20 @@ function propRequired(entry: PropEntry, words: Words): Inline[] {
return [hint(words.nothing)]
}
/** 说明一格:字段上面的 JSDoc / 注释。 */
function propDoc(entry: PropEntry): Inline[] {
/** 说明一格:字段上面的 JSDoc / 注释;字段没写时是组件说明里讲它的 `@param`。 */
function propDoc(entry: PropEntry, docs: ParamDocs): PreviewText {
const text = entry.field ? commentText(entry.field.docs) : entry.text?.doc
return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : []
return text ? [{ text: text.replace(/\s*\n\s*/g, ' '), role: 'comment' }] : docs.of(entry.key) ?? []
}
/** 构造参数:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「构造参数」后面写出它。 */
function propsRow(component: ComponentFacts, env: Env): PropertyRow {
/**
* 构造参数:解构的每个属性一行(名称 / 类型 / 默认值 / 必填,有属性带说明时多一列说明);属性类型是一个名字时「构造参数」后面写出它,
* 组件说明里讲整个属性参数的 `@param` 跟在这一行的值后面。
*/
function propsRow(props: PropsFacts, entries: readonly PropEntry[], written: { docs: ParamDocs, whole?: Markdown }, env: Env): PropertyRow {
const { context, words } = env
const props = propsOf(component)
const entries = propEntries(props, env)
const docs = entries.map(propDoc)
const described = docs.some(doc => doc.length > 0)
const docs = entries.map(entry => propDoc(entry, written.docs))
const described = docs.some(doc => 'markdown' in doc || doc.length > 0)
const items = entries.map((entry, index): PropertyItem => {
const defines = entry.binding?.name ?? entry.field?.name
return {
@@ -422,13 +440,28 @@ function propsRow(component: ComponentFacts, env: Env): PropertyRow {
return {
label: words.labels.props,
value: [hint(words.count(items.length)), ...(props.from ? [hint(words.typed), name(props.from, 'type')] : [])],
...(written.whole && { note: written.whole }),
...(items.length > 0 && { items, columns: described ? [...words.propHeads, words.propDoc] : words.propHeads }),
}
}
/** 组件卡片:名称 / 说明 / 构造参数。组件体的语句照常是子块;直接返回 JSX 的箭头函数没有子块,它渲染的大纲写在卡片里。 */
/**
* 组件卡片:名称 / 说明 / 对不上的参数 / 构造参数 / 异常(说明里的块标签拆开放,见 propDocs)。组件体的语句照常是子块;
* 直接返回 JSX 的箭头函数没有子块,它渲染的大纲写在卡片里。
*/
function componentCard(component: ComponentFacts, env: Env): RuleCard {
const rows = [componentName(component, env), ...docRows(component, env), propsRow(component, env)]
const { context, words } = env
const doc = component.doc && context.doc(component.doc)
const props = propsOf(component)
const entries = propEntries(props, env)
const written = propDocs(doc, props, entries)
const rows = [
componentName(component, env),
...docBodyRows(doc, words.labels.doc),
...unmatchedParamRows(written.docs.unmatched),
propsRow(props, entries, written, env),
...throwsRows(doc),
]
if (component.body.type !== 'statement_block')
rows.push(outlineRow(component.body, env))
return card('component', rows, env, { defines: range(component.name) })
@@ -725,17 +758,48 @@ function eventsText(handler: HandlerFacts, analysis: Analysis, env: Env): Inline
return [hint(words.nothing)]
}
/** 事件处理函数:名称 / 参数 / 绑定事件。函数里的语句照常是这张卡片的子块。 */
function handlerCard(handler: HandlerFacts, analysis: Analysis, env: Env): RuleCard {
/**
* 事件处理函数:名称 / 说明 / 对不上的参数 / 参数 / 返回(说明里写了 `@return` 才有)/ 异常 / 绑定事件。参数逗号隔开写在一行,
* 说明里有参数对上了 `@param` 时排成一张表,多一列说明。函数里的语句照常是这张卡片的子块。
*/
function handlerCard(block: CodeBlock, handler: HandlerFacts, analysis: Analysis, env: Env): RuleCard {
const { context, words } = env
const parameters = parametersOf(handler.fn).map(parameter => [
...binding(parameter.name, 'variable', env),
...(parameter.type ? [plain(': '), ...context.translate(parameter.type)] : []),
...(parameter.default ? [plain(' = '), ...context.translate(parameter.default)] : []),
])
const doc = block.kind === 'function' && block.facts.doc ? context.doc(block.facts.doc) : undefined
const parameters = parametersOf(handler.fn)
const docs = paramDocs(doc, parameters.map(parameter => parameter.name.text))
const described = parameters.some(parameter => docs.of(parameter.name.text))
const parameterRow: PropertyRow = described
? {
label: words.labels.parameters,
value: [hint(words.count(parameters.length))],
columns: words.parameterHeads,
items: parameters.map(parameter => ({
cells: [
binding(parameter.name, 'variable', env),
parameter.type ? context.translate(parameter.type) : [hint(words.unmarked)],
parameter.default ? context.translate(parameter.default) : [hint(words.nothing)],
paramDocCell(docs, parameter.name.text),
],
defines: range(parameter.name),
})),
}
: {
label: words.labels.parameters,
value: parameters.length > 0
? joined(parameters.map(parameter => [
...binding(parameter.name, 'variable', env),
...(parameter.type ? [plain(': '), ...context.translate(parameter.type)] : []),
...(parameter.default ? [plain(' = '), ...context.translate(parameter.default)] : []),
]), plain(', '))
: [hint(words.none)],
}
return card('handler', [
{ label: words.labels.name, value: [name(handler.name, 'function')] },
{ label: words.labels.parameters, value: parameters.length > 0 ? joined(parameters, plain(', ')) : [hint(words.none)] },
...docBodyRows(doc, words.labels.doc),
...unmatchedParamRows(docs.unmatched),
parameterRow,
...(doc?.returns ? [{ label: words.labels.returns, value: [], ...returnNote(doc) }] : []),
...throwsRows(doc),
{ label: words.labels.events, value: eventsText(handler, analysis, env) },
], env, { defines: range(handler.name) })
}
@@ -976,7 +1040,7 @@ function bodyCard(block: CodeBlock, env: Env): RuleCard | undefined {
if (effect)
return effectCard(block, effect, env)
const handler = body.handlers.find(at)
return handler && handlerCard(handler, analysis, env)
return handler && handlerCard(block, handler, analysis, env)
}
/**
+4 -4
View File
@@ -1,4 +1,4 @@
import type { ImplBlock, KindBadge, LibraryRule, RuleCard, ThemeColor } from '../contract'
import type { ImplBlock, KindBadge, LibraryRule, RuleCard, RuleContext, ThemeColor } from '../contract'
import { implRows, plainSay } from './rustRows'
/** 和函数卡同一个黄,实现块看起来仍是函数那一挂。 */
@@ -14,15 +14,15 @@ export const rustImplRule: LibraryRule = {
label: 'Rust 实现',
languageIds: ['rust'],
libraries: [],
claim: block => block.kind === 'impl' ? implCard(block) : undefined,
claim: (block, context) => block.kind === 'impl' ? implCard(block, context) : undefined,
attributeNames: () => ({
attributes: {},
values: { kind: { 'rust.impl': '实现' } },
}),
}
function implCard(block: ImplBlock): RuleCard {
const rows = implRows(block, plainSay())
function implCard(block: ImplBlock, context: RuleContext): RuleCard {
const rows = implRows(block, plainSay(context.doc))
const row = rows.findIndex(item => item.label === '方法')
const methods = block.children.filter(child => child.kind === 'function')
return {
+2 -1
View File
@@ -108,7 +108,8 @@ describe('serde 与 thiserror', () => {
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 cell = marks?.rows?.changed[row]?.items?.changed[1]?.cells?.[1]
const parts = cell && 'inline' in cell ? cell.inline : []
const text = (change: 'add' | 'del') => parts
.filter(part => part.change === change)
.flatMap(part => part.segments)
+39 -20
View File
@@ -1,11 +1,14 @@
import type { AttributeBlock, AttributeFacts, AttributeItem, CfgPredicate, ClassBlock, CodeBlock, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, PropertyItem, PropertyRow, SourceSpan, TokenRole, TraitBlock } from '../contract'
import type { AttributeBlock, AttributeFacts, AttributeItem, CfgPredicate, ClassBlock, CodeBlock, Doc, EnumBlock, FunctionBlock, ImplBlock, ImportBlock, Inline, ModuleBlock, Node, PropertyItem, PropertyRow, SourceSpan, TokenRole, TraitBlock } from '../contract'
import type { RustVisibility } from './rustTree'
import { bodyFunctions, docSentence, isMethod, matchArms, oneLine, returnText, signatureParams, structFields, variantPayload } from './rustTree'
import { paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { bodyFunctions, docSummary, 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[]
name: (node: Node, role: TokenRole) => Inline[]
source: (node: Node, role?: TokenRole) => Inline[]
/** 读一项的文档注释(连着几行的 `///` 是一段),见 Translator.doc;没有时没有。 */
doc: (node: Node) => Doc | undefined
}
const VISIBILITY: Record<RustVisibility, string> = {
@@ -49,15 +52,20 @@ export function importRows(block: ImportBlock, say: RustSay): PropertyRow[] {
export function structRows(block: ClassBlock, say: RustSay): PropertyRow[] {
const fields = structFields(block.node)
const doc = block.facts.doc && say.doc(block.facts.doc)
const docs = paramDocs(doc, fields.map(field => field.name.text))
const described = fields.some(field => docs.of(field.name.text))
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
...docRow(doc),
...unmatchedParamRows(docs.unmatched),
visibilityRow(block),
...tableRow('字段', ['字段', '类型', '可见性'], fields.map(field => ({
...tableRow('字段', ['字段', '类型', '可见性', ...(described ? ['说明'] : [])], fields.map(field => ({
cells: [
say.name(field.name, 'variable'),
say.source(field.type, 'type'),
[keyword(VISIBILITY[field.visibility])],
...(described ? [paramDocCell(docs, field.name.text)] : []),
],
defines: range(field.name),
}))),
@@ -67,7 +75,7 @@ export function structRows(block: ClassBlock, say: RustSay): PropertyRow[] {
export function moduleRows(block: ModuleBlock, say: RustSay): PropertyRow[] {
return [
nameRow(block.facts.name, say, 'variable'),
...docRow(block.facts.doc),
...docRow(block.facts.doc && say.doc(block.facts.doc)),
visibilityRow(block),
]
}
@@ -166,7 +174,7 @@ function cfgText(predicate: CfgPredicate, nested: boolean): Inline[] {
export function enumRows(block: EnumBlock, say: RustSay): PropertyRow[] {
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
...docRow(block.facts.doc && say.doc(block.facts.doc)),
...tableRow('变体', ['变体', '携带的数据'], block.facts.members.map(member => ({
cells: [say.name(member.name, 'type'), [hint(variantPayload(member.value) ?? '—')]],
defines: range(member.name),
@@ -176,16 +184,26 @@ export function enumRows(block: EnumBlock, say: RustSay): PropertyRow[] {
export function functionRows(block: FunctionBlock, say: RustSay): PropertyRow[] {
const arms = matchArms(block.node)
const doc = block.facts.doc && say.doc(block.facts.doc)
const { parameters } = block.facts
const docs = paramDocs(doc, parameters.map(param => param.name.text))
const described = parameters.some(param => docs.of(param.name.text))
return [
nameRow(block.facts.name, say, 'function'),
...docRow(block.facts.doc),
...docRow(doc),
...unmatchedParamRows(docs.unmatched),
...(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('—')]],
...tableRow(isMethod(block.node) ? '参数' : '输入参数', ['名称', '类型', ...(described ? ['说明'] : [])], parameters.map(param => ({
cells: [
say.name(param.name, 'variable'),
param.type ? say.source(param.type, 'type') : [hint('—')],
...(described ? [paramDocCell(docs, param.name.text)] : []),
],
defines: range(param.name),
}))),
{ label: '返回', value: [plain(returnText(block.node))] },
{ label: '返回', value: [plain(returnText(block.node))], ...returnNote(doc) },
...throwsRows(doc),
...tableRow('分情况', ['分支', '做什么'], arms.map(arm => ({
cells: [[plain(arm.pattern)], [hint(arm.does)]],
})), '支'),
@@ -196,7 +214,7 @@ export function traitRows(block: TraitBlock, say: RustSay): PropertyRow[] {
const methods = bodyFunctions(block.node)
return [
nameRow(block.facts.name, say, 'type'),
...docRow(block.facts.doc),
...docRow(block.facts.doc && say.doc(block.facts.doc)),
...tableRow('方法', ['名称', '参数', '返回'], methods.map((method) => {
const name = method.childForFieldName('name')
return {
@@ -218,7 +236,7 @@ export function implRows(block: ImplBlock, say: RustSay): PropertyRow[] {
: [hint('实现')]
return [
{ label: '名称', value: title, ...(block.facts.name && { defines: range(block.facts.name) }) },
...docRow(block.facts.doc),
...docRow(block.facts.doc && say.doc(block.facts.doc)),
...tableRow('方法', ['名称', '参数', '返回'], methods.map((method) => {
const name = method.facts.name
return {
@@ -233,7 +251,7 @@ export function implRows(block: ImplBlock, say: RustSay): PropertyRow[] {
]
}
function nameRow(name: import('../contract').Node | undefined, say: RustSay, role: TokenRole): PropertyRow {
function nameRow(name: Node | undefined, say: RustSay, role: TokenRole): PropertyRow {
return {
label: '名称',
value: name ? say.name(name, role) : [hint('匿名')],
@@ -241,8 +259,8 @@ function nameRow(name: import('../contract').Node | undefined, say: RustSay, rol
}
}
function docRow(doc: import('../contract').Node | undefined): PropertyRow[] {
const sentence = docSentence(doc)
function docRow(doc: Doc | undefined): PropertyRow[] {
const sentence = docSummary(doc)
return sentence ? [{ label: '说明', value: { markdown: sentence } }] : []
}
@@ -277,13 +295,14 @@ function note(text: string): Inline {
return { text, role: 'comment' }
}
function range(node: import('../contract').Node): { startIndex: number, endIndex: number } {
function range(node: Node): { startIndex: number, endIndex: number } {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
/** 名字、类型照抄源码,名字带上下标,点了能跳到定义。 */
export function plainSay(): RustSay {
export function plainSay(doc: RustSay['doc']): RustSay {
return {
name: (node, role) => [{ text: node.text, role, offset: node.startIndex }],
source: (node, role = 'type') => [{ text: oneLine(node.text), role, offset: node.startIndex }],
doc,
}
}
+5 -6
View File
@@ -1,4 +1,4 @@
import type { Access, BlockAttributes, Node } from '../contract'
import type { Access, BlockAttributes, Doc, Node } from '../contract'
/** 具名子节点,空的跳过。 */
export function named(node: Node | null | undefined): Node[] {
@@ -19,11 +19,10 @@ export function commentPlain(node: Node): string {
return raw.replace(/\n/g, ' ').trim()
}
/** 说明行用的第一句。没有文字时没有。 */
export function docSentence(node: Node | undefined): string | undefined {
if (!node)
return undefined
const sentence = firstSentence(commentPlain(node))
/** 说明行用的第一句:说明(翻译插件读出来的,块标签已经拿掉)正文的第一句,换行当空格。没有正文时没有。 */
export function docSummary(doc: Doc | undefined): string | undefined {
const text = doc?.body?.markdown.replace(/\s*\n\s*/g, ' ').trim()
const sentence = text && firstSentence(text)
return sentence || undefined
}
+13 -7
View File
@@ -1,7 +1,8 @@
import type { ClassBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor, TokenRole } from '../contract'
import type { ClassBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor, TokenRole } from '../contract'
import type { rustVisibility } from './rustTree'
import { paramDocCell, paramDocs, unmatchedParamRows } from './docRows'
import { visibilityWord } from './rustRows'
import { attributeName, deriveNames, docSentence, itemAttributes, leadingAttributes, named, oneLine, stringContent, structFields } from './rustTree'
import { attributeName, deriveNames, docSummary, itemAttributes, leadingAttributes, named, oneLine, stringContent, structFields } from './rustTree'
/** 和结构体同一个青,序列化只是名称后的标签,不另换边框色。 */
const COLOR: ThemeColor = { dark: '#4ec9b0', light: '#267f99' }
@@ -17,7 +18,7 @@ export const serdeRule: LibraryRule = {
label: 'serde 序列化',
languageIds: ['rust'],
libraries: ['serde'],
claim: block => block.kind === 'class' && block.node.type === 'struct_item' && marked(block) ? card(block) : undefined,
claim: (block, context) => block.kind === 'class' && block.node.type === 'struct_item' && marked(block) ? card(block, context) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'serde.struct': '可序列化' } } }),
}
@@ -25,30 +26,35 @@ function marked(block: ClassBlock): boolean {
return deriveNames(block.node).some(name => MARKS.has(name))
}
function card(block: ClassBlock): RuleCard {
function card(block: ClassBlock, context: RuleContext): RuleCard {
const fields = structFields(block.node)
const camel = renameAll(block.node) === 'camelCase'
const doc = docSentence(block.facts.doc)
const doc = block.facts.doc && context.doc(block.facts.doc)
const summary = docSummary(doc)
const docs = paramDocs(doc, fields.map(field => field.name.text))
const described = fields.some(field => docs.of(field.name.text))
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 } }] : []),
...(summary ? [{ label: '说明', value: { markdown: summary } }] : []),
...unmatchedParamRows(docs.unmatched),
{ label: '可见性', value: [{ text: visibilityWord(block), role: 'keyword' as const }] },
...(camel ? [{ label: '选项', value: [keyword('字段名改驼峰')] }] : []),
...(fields.length > 0
? [{
label: '字段',
value: [hint(`${fields.length} 个`)],
columns: ['字段', '类型', '可见性', '序列化'],
columns: ['字段', '类型', '可见性', '序列化', ...(described ? ['说明'] : [])],
items: fields.map(field => ({
cells: [
copied(field.name, 'variable'),
copied(field.type, 'type'),
[keyword(visibilityWordOf(field.visibility))],
[serdeCell(field.name)],
...(described ? [paramDocCell(docs, field.name.text)] : []),
],
defines: range(field.name),
})),
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest'
import { toBlockViews } from '../blockView'
import { replaceLines } from '../editText'
import { createPythonAdapter } from '../languages/python'
import { flatSegments, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from '../translators/pythonZh'
import { sqlalchemyRule } from './sqlalchemy'
@@ -38,7 +38,7 @@ async function model(source: string, name: string): Promise<BlockView> {
function linkedSegments(rows: PropertyRow[]): Segment[] {
const pieces: Inline[] = rows.flatMap(row => [
...('markdown' in row.value ? [] : row.value),
...(row.items ?? []).flatMap(item => item.cells.flat()),
...(row.items ?? []).flatMap(item => item.cells.flatMap(inlineOf)),
])
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
}
+2 -2
View File
@@ -352,7 +352,7 @@ function columnItem({ member, call }: Column, env: Env): PropertyItem {
for (const foreign of calls.filter((found): found is Call => found?.name === 'ForeignKey'))
constraints.push(foreignKey(foreign, env))
const comment = keywords.get('comment')
const doc = member.doc && context.doc(member.doc)
const doc = member.doc && context.doc(member.doc)?.body
return {
cells: [
[name(member.name, 'variable')],
@@ -409,7 +409,7 @@ function tableBlock(block: ClassBlock, model: Model, env: Env): RuleCard {
const { labels } = words
const { options, items } = tableArgs(model.args?.value)
const comment = dictValue(options, 'comment')
const doc = (comment && context.doc(comment)) ?? (facts.doc && context.doc(facts.doc))
const doc = (comment && context.doc(comment)?.body) ?? (facts.doc && context.doc(facts.doc)?.body)
const indexes: PropertyItem[] = []
const uniques: PropertyItem[] = []
for (const item of items) {
+8 -9
View File
@@ -1,5 +1,5 @@
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, ThemeColor } from '../contract'
import { commentPlain, firstSentence, macroInvocation, named, oneLine, stringContent } from './rustTree'
import type { CodeBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, RuleContext, ThemeColor } from '../contract'
import { docSummary, macroInvocation, named, oneLine, stringContent } from './rustTree'
/** 查询卡用字符串那种橙。 */
const COLOR: ThemeColor = { dark: '#ce9178', light: '#a31515' }
@@ -15,16 +15,16 @@ export const sqlxRule: LibraryRule = {
label: 'sqlx 查询',
languageIds: ['rust'],
libraries: ['sqlx'],
claim: (block) => {
claim: (block, context) => {
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
return macro ? card(block, macro, context) : undefined
},
attributeNames: () => ({ attributes: {}, values: { kind: { 'sqlx.query': '查询' } } }),
}
function card(block: CodeBlock, macro: Node): RuleCard {
function card(block: CodeBlock, macro: Node, context: RuleContext): RuleCard {
const call = macroInvocation(macro)
const args = call?.args ?? []
const typed = call?.name.split('::').pop() !== 'query'
@@ -32,7 +32,7 @@ function card(block: CodeBlock, macro: Node): RuleCard {
const sql = stringContent(sqlNode) ?? ''
const params = args.slice(typed ? 2 : 1)
const returned = typed ? args[0] : undefined
const note = precedingNote(block.node)
const note = precedingNote(block.node, context)
const rows: PropertyRow[] = [
...(note ? [{ label: '说明', value: { markdown: note } }] : []),
{ label: 'SQL', value: [{ text: sql, role: 'string', ...(sqlNode && { offset: sqlNode.startIndex }) }] },
@@ -65,14 +65,13 @@ function nextQuery(comment: Node): Node | undefined {
return next ? findQuery(next) : undefined
}
function precedingNote(node: Node): string | undefined {
function precedingNote(node: Node, context: RuleContext): 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
return docSummary(context.doc(prev))
}
function hint(text: string): Inline {
+19 -11
View File
@@ -1,5 +1,6 @@
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor } from '../contract'
import { attributeName, docSentence, oneLine, returnText } from './rustTree'
import type { FunctionBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor } from '../contract'
import { paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
import { attributeName, docSummary, oneLine, returnText } from './rustTree'
/** 桌面命令的边框用这个橙。 */
const COLOR: ThemeColor = { dark: '#e5c07b', light: '#9a6a00' }
@@ -15,7 +16,7 @@ export const tauriRule: LibraryRule = {
label: 'tauri 桌面命令',
languageIds: ['rust'],
libraries: ['tauri'],
claim: block => block.kind === 'function' && commanded(block) ? card(block) : undefined,
claim: (block, context) => block.kind === 'function' && commanded(block) ? card(block, context) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'tauri.command': '桌面命令' } } }),
}
@@ -23,8 +24,12 @@ 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)
function card(block: FunctionBlock, context: RuleContext): RuleCard {
const doc = block.facts.doc && context.doc(block.facts.doc)
const summary = docSummary(doc)
const { parameters } = block.facts
const docs = paramDocs(doc, parameters.map(param => param.name.text))
const described = parameters.some(param => docs.of(param.name.text))
const rows: PropertyRow[] = [
{
label: '名称',
@@ -33,24 +38,27 @@ function card(block: FunctionBlock): RuleCard {
: [tag('桌面命令')],
...(block.facts.name && { defines: range(block.facts.name) }),
},
...(doc ? [{ label: '说明', value: { markdown: doc } }] : []),
...(summary ? [{ label: '说明', value: { markdown: summary } }] : []),
...unmatchedParamRows(docs.unmatched),
...(block.facts.async ? [{ label: '异步', value: [keyword('是')] }] : []),
...(block.facts.parameters.length > 0
...(parameters.length > 0
? [{
label: '参数',
value: [hint(`${block.facts.parameters.length} 个`)],
columns: ['名称', '类型', '来源'],
items: block.facts.parameters.map(param => ({
value: [hint(`${parameters.length} 个`)],
columns: ['名称', '类型', '来源', ...(described ? ['说明'] : [])],
items: parameters.map(param => ({
cells: [
[variable(param.name)],
[param.type ? typed(param.type) : hint('—')],
[hint(isWindowState(param.type) ? '窗口状态' : '调用方传入')],
...(described ? [paramDocCell(docs, param.name.text)] : []),
],
defines: range(param.name),
})),
}]
: []),
{ label: '返回', value: [plain(returnText(block.node))] },
{ label: '返回', value: [plain(returnText(block.node))], ...returnNote(doc) },
...throwsRows(doc),
]
return {
kind: 'tauri.command',
+6 -6
View File
@@ -1,5 +1,5 @@
import type { EnumBlock, Inline, KindBadge, LibraryRule, Node, PropertyRow, RuleCard, SegmentGroup, ThemeColor } from '../contract'
import { attributeName, commentPlain, deriveNames, docSentence, firstSentence, named, stringContent, variantPayload } from './rustTree'
import type { EnumBlock, Inline, KindBadge, LibraryRule, Node, PreviewText, PropertyRow, RuleCard, RuleContext, SegmentGroup, ThemeColor } from '../contract'
import { attributeName, commentPlain, deriveNames, docSummary, firstSentence, named, stringContent, variantPayload } from './rustTree'
/** 错误卡用异常那种红。 */
const COLOR: ThemeColor = { dark: '#f14c4c', light: '#cd3131' }
@@ -14,12 +14,12 @@ export const thiserrorRule: LibraryRule = {
label: 'thiserror 错误',
languageIds: ['rust'],
libraries: ['thiserror'],
claim: block => block.kind === 'enum' && deriveNames(block.node).includes('Error') ? card(block) : undefined,
claim: (block, context) => block.kind === 'enum' && deriveNames(block.node).includes('Error') ? card(block, context) : undefined,
attributeNames: () => ({ attributes: {}, values: { kind: { 'thiserror.error': '错误' } } }),
}
function card(block: EnumBlock): RuleCard {
const doc = docSentence(block.facts.doc)
function card(block: EnumBlock, context: RuleContext): RuleCard {
const doc = docSummary(block.facts.doc && context.doc(block.facts.doc))
const rows: PropertyRow[] = [
{
label: '名称',
@@ -34,7 +34,7 @@ function card(block: EnumBlock): RuleCard {
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[][] = [
const cells: PreviewText[] = [
[{ text: member.name.text, role: 'type', offset: member.name.startIndex }],
[hint(whenText(variant) ?? '—')],
display
+9 -4
View File
@@ -66,18 +66,23 @@ export function previewText(text: PreviewText): string {
return 'markdown' in text ? text.markdown : segmentText(text)
}
/** 预览文字里着色的词;Markdown 没有。 */
export function inlineOf(text: PreviewText): Inline[] {
return 'markdown' in text ? [] : text
}
/** 子行写成文字:各列用 ` | ` 隔开,下一层子行、排在最后一格里的表(PropertyItem.table)的各行比这一层多缩进两格。 */
function itemLines(items: PropertyItem[] | undefined, indent: string): string[] {
return (items ?? []).flatMap(item => [
`${indent}${item.cells.map(segmentText).join(' | ')}`,
`${indent}${item.cells.map(previewText).join(' | ')}`,
...itemLines(item.items, `${indent} `),
...itemLines(item.table?.items, `${indent} `),
])
}
/** 属性表写成文字:每行 `名称: 值`,展开的子行比上一层多缩进两格(见 itemLines)。 */
/** 属性表写成文字:每行 `名称: 值`(跟着说明的写成 `值 — 说明`),展开的子行比上一层多缩进两格(见 itemLines)。 */
export function propertyLines(rows: PropertyRow[]): string[] {
return rows.flatMap(row => [`${row.label}: ${previewText(row.value)}`, ...itemLines(row.items, ' ')])
return rows.flatMap(row => [`${row.label}: ${previewText(row.value)}${row.note ? ` — ${row.note.markdown}` : ''}`, ...itemLines(row.items, ' ')])
}
/** 一行译文下面那张表写成文字,同 propertyLines 的子行,第一层不缩进。 */
@@ -93,7 +98,7 @@ export function tableLines(table: PreviewTable | undefined): string[] {
export function viewLines(blocks: readonly BlockView[], indent = ''): string[] {
const value = (text: PreviewText): string => 'markdown' in text ? text.markdown.split('\n')[0] : segmentText(text)
const items = (list: PropertyItem[] | undefined, pad: string): string[] => (list ?? []).flatMap(item => [
pad + item.cells.map(segmentText).join(' | ').replace(/\n/g, ' ⏎ '),
pad + item.cells.map(previewText).join(' | ').replace(/\n/g, ' ⏎ '),
...items(item.items, `${pad} `),
...items(item.table?.items, `${pad} `),
])
+13 -2
View File
@@ -3,8 +3,6 @@ import { attributeRows, enumRows, functionRows, implRows, importRows, moduleRows
import { isCrateRoot } from '../rules/rustTree'
import { createZhTemplateTranslator, KIND_ZH, ZH_ATTRIBUTE_NAMES } from './zhTemplate'
const say = plainSay()
const template = createZhTemplateTranslator({
id: 'rust.zh-template',
label: '中文直译',
@@ -55,6 +53,7 @@ const template = createZhTemplateTranslator({
mapLiterals: {},
stringNodes: ['string_literal'],
commentText,
docNodes,
nodeTemplates: {},
})
@@ -82,6 +81,7 @@ export const rustZhTranslator: Translator = {
}
function describe(block: FactBlock, document: SourceDocument): PropertyRow[] {
const say = plainSay(node => template.doc(node, document))
switch (block.kind) {
case 'import':
return importRows(block, say)
@@ -130,6 +130,17 @@ function original(node: Node, span: SourceSpan, { source, language }: SourceDocu
return out
}
/**
* 一项的文档注释:`///` 一行一个节点,从第一条起往下连着写的(下一行紧跟着另一条 `///`)都算这一段;块注释本来就是一个节点。
*/
function docNodes(first: Node): Node[] {
const outer = (node: Node): boolean => node.type === 'line_comment' && node.namedChildren.some(child => child?.type === 'outer_doc_comment_marker')
const nodes = [first]
for (let next = first.nextSibling; outer(first) && next && outer(next) && next.startPosition.row === nodes.at(-1)!.startPosition.row + 1; next = next.nextSibling)
nodes.push(next)
return nodes
}
/** 行注释去掉 `//`(文档注释连 `/`、`!`)和后面一个空格,缩进留给 Markdown;块注释去掉开头结尾的注释符号。 */
function commentText(node: Node, text: string): string {
if (node.type === 'line_comment')
@@ -1,9 +1,9 @@
import type { BlockKind, CallFacts, Inline, LanguageAdapter, PreviewTable, PropertyItem, PropertyRow, Segment, SourceDocument, TokenRole, Translator } from '../contract'
import type { BlockKind, CallFacts, Inline, LanguageAdapter, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment, SourceDocument, TokenRole, Translator } from '../contract'
import type { ZhTable } from './zhTemplate'
import { describe, expect, it } from 'vitest'
import { createPythonAdapter } from '../languages/python'
import { createTypeScriptAdapter } from '../languages/typescript'
import { flatSegments, propertyLines, readFixture, segmentText, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport'
import { flatSegments, inlineOf, previewText, propertyLines, readFixture, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport'
import { pythonZhTranslator } from './pythonZh'
import { typescriptZhTranslator } from './typescriptZh'
import { createZhTemplateTranslator } from './zhTemplate'
@@ -58,14 +58,14 @@ function valueOf(rows: PropertyRow[] | undefined, label: string): Inline[] {
return value
}
/** 译文写成文字,框写成 `⟦框头|框里的文字⟧`。 */
function text(segments: Inline[] | undefined): string {
return segmentText(segments ?? [])
/** 译文写成文字,框写成 `⟦框头|框里的文字⟧`;Markdown 取原文。 */
function text(segments: PreviewText | undefined): string {
return previewText(segments ?? [])
}
/** 带下标的段(框里的也算),按出现顺序写成 [文字, 下标]。 */
function linked(segments: Inline[] | undefined): [string, number][] {
return flatSegments(segments ?? []).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]])
function linked(segments: PreviewText | undefined): [string, number][] {
return flatSegments(inlineOf(segments ?? [])).flatMap(segment => segment.offset === undefined ? [] : [[segment.text, segment.offset] as [string, number]])
}
function texts(map: Map<number, Translated>): Record<number, string> {
@@ -578,7 +578,7 @@ describe('属性表', () => {
])
})
it('说明:紧跟名称,去掉注释符号、引号、共同缩进和首尾空行,保留换行和 @ 标签行;没有文字不显示', async () => {
it('说明:紧跟名称,去掉注释符号、引号、共同缩进和首尾空行,保留换行;块标签拆到参数表、输出参数后面;没有文字不显示', async () => {
const tsSource = [
'class K {',
' /**',
@@ -595,7 +595,8 @@ describe('属性表', () => {
'}',
].join('\n')
const tsRows = rowTexts(await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource))
expect(tsRows[2].slice(0, 3)).toEqual(['名称: load', '说明: 读配置。\n\n例子:\n load(\'a\')\n@param path 文件路径\n@returns 配置', '访问级别: 公共'])
expect(tsRows[2].slice(0, 3)).toEqual(['名称: load', '说明: 读配置。\n\n例子:\n load(\'a\')', '访问级别: 公共'])
expect(tsRows[2].slice(-3)).toEqual(['输入参数: 1 个', ' path | 字符串 | | 文件路径', '输出参数: 未标注 — 配置'])
expect(tsRows[11].slice(0, 2)).toEqual(['名称: empty', '访问级别: 公共'])
const pySource = [
@@ -615,7 +616,7 @@ describe('属性表', () => {
expect(pyRows[8].slice(0, 2)).toEqual(['名称: g', '访问级别: 公共'])
})
it('注释块和函数的说明是 Markdown 原文:只去注释符号、引号和共同缩进,reST 角色、双反引号、JSDoc 标签、HTML 照抄', async () => {
it('注释块和函数的说明是 Markdown 原文:只去注释符号、引号和共同缩进,reST 角色、双反引号、{@link}、HTML 照抄,块标签不进正文', async () => {
const pySource = [
'"""手机号规范化 / 脱敏工具.',
'',
@@ -662,7 +663,7 @@ describe('属性表', () => {
expect(tsText[3]).toBe('单行块注释')
const tsRows = await describeAll(ts, 'typescript', typescriptZhTranslator, tsSource)
expect(tsRows.get(4)?.find(row => row.label === '说明')?.value).toEqual({
markdown: '读配置,见 {@link load}。\n\n```ts\nload(\'a\')\n```\n@param path 文件路径',
markdown: '读配置,见 {@link load}。\n\n```ts\nload(\'a\')\n```',
})
})
@@ -854,12 +855,12 @@ describe('属性表', () => {
})
describe('源码下标(跳到定义用)', () => {
function itemCells(rows: PropertyRow[] | undefined, label: string): Inline[][] {
function itemCells(rows: PropertyRow[] | undefined, label: string): PreviewText[] {
return rows?.find(row => row.label === label)?.items?.flatMap(item => item.cells) ?? []
}
function propertySegments(rows: PropertyRow[]): Segment[] {
const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...flatSegments(item.cells.flat()), ...itemSegments(item.items)])
const itemSegments = (items: PropertyItem[] | undefined): Segment[] => (items ?? []).flatMap(item => [...flatSegments(item.cells.flatMap(inlineOf)), ...itemSegments(item.items)])
return rows.flatMap(row => [...('markdown' in row.value ? [] : flatSegments(row.value)), ...itemSegments(row.items)])
}
@@ -1033,6 +1034,82 @@ describe('源码下标(跳到定义用)', () => {
})
})
describe('说明里的块标签', () => {
it('render_conversation_brief:@param 进输入参数表的说明列,@return 跟在输出参数后面,说明里只剩正文', async () => {
const rows = await describeAll(py, 'python', pythonZhTranslator, readFixture('conversationBrief.py'))
const lines = rowTexts(rows)[25]
expect(lines.find(line => line.startsWith('说明'))).toBe('说明: 把对话转写成「前情摘要 + 近期消息」一段文字; 什么都没有时返回空串.')
expect(lines.join('\n')).not.toMatch(/@param|@return/)
const params = rows.get(25)?.find(row => row.label === '输入参数')
expect(params?.columns).toEqual(['名称', '类型', '默认值', '说明'])
expect(params?.items?.map(item => [text(item.cells[0]), text(item.cells[3])])).toEqual([
['messages', '对话历史 (`messages` 状态).'],
['summary', '更早对话的压缩摘要, 没有就传 `None`.'],
['speakers', '转写谁说的话: [USER] / [ASSISTANT]; 只要用户原话时只传 [USER].'],
['max_lines', '近期消息最多留几行, 多了从头丢弃并注明.'],
])
expect(params?.items?.[3].cells[3]).toEqual({ markdown: '近期消息最多留几行, 多了从头丢弃并注明.' })
const output = rows.get(25)?.find(row => row.label === '输出参数')
expect(output?.value).toEqual([{ text: '字符串', role: 'type' }])
expect(output?.note).toEqual({ markdown: '以 `[会话进展]` 开头的转写.' })
expect(lines.some(line => line.startsWith('对不上的参数'))).toBe(false)
})
it('gate.py:@throws 成「异常」表,第一行带完整链接,第二行是不带路径的引用', async () => {
const rows = await describeAll(py, 'python', pythonZhTranslator, readFixture('localRuntimeGate.py'))
const lines = rowTexts(rows)[18]
expect(lines.join('\n')).not.toMatch(/@throws/)
expect(lines.slice(-3)).toEqual([
'异常: 2 个',
' [DesktopOnly](backend/src/luffy_agent/sandbox/local_runtime/visibility.py#DesktopOnly) | 请求不是桌面端发来的',
' [LocalRuntimeDisabled] | 总开关关着',
])
const throws = rows.get(18)?.find(row => row.label === '异常')
expect(throws?.columns).toEqual(['异常', '触发条件'])
expect(throws?.items?.[1].cells).toEqual([{ markdown: '[LocalRuntimeDisabled]' }, { markdown: '总开关关着' }])
})
it('名字对不上的 @param 排在说明下面,提示色,悬停「没有这个参数」;没有对上的参数时参数表不加说明列', async () => {
const source = [
'/**',
' * 读文件。',
' * @param pth 路径',
' * @throws Error 读不到',
' */',
'export function read(path: string): string { return \'\' }',
].join('\n')
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
const lines = rowTexts(rows)[1]
expect(lines.slice(0, 4)).toEqual(['名称: read', '说明: 读文件。', '对不上的参数: 1 个', ' pth | 路径'])
const unmatched = rows.get(1)?.find(row => row.label === '对不上的参数')
expect(unmatched?.items?.[0].cells[0]).toEqual([{ text: 'pth', role: 'hint', tooltip: '没有这个参数' }])
expect(rows.get(1)?.find(row => row.label === '输入参数')?.columns).toEqual(['名称', '类型', '默认值'])
expect(lines.slice(-2)).toEqual(['异常: 1 个', ' [Error] | 读不到'])
})
it('类:没有构造参数表可对,@param 照写成「构造参数」表;异常类的 @param 对到构造参数表,*args 去掉星号对名字', async () => {
const source = [
'class K:',
' """类。',
'',
' @param a 甲',
' """',
'',
'class E(Exception):',
' """出错了。',
'',
' @param code 错误码',
' @param args 其余参数',
' """',
' def __init__(self, code: int, *args):',
' pass',
].join('\n')
const lines = rowTexts(await describeAll(py, 'python', pythonZhTranslator, source))
expect(lines[1]).toEqual(['名称: K', '说明: 类。', '访问级别: 公共', '构造参数: 1 个', ' a | 甲'])
expect(lines[7].slice(-3)).toEqual(['构造参数: 2 个', ' code | 整数 | | 错误码', ' *args | 未标注 | | 其余参数'])
})
})
describe('跨几行写的调用', () => {
const routes = readFixture('routes.py')
const calls = readFixture('calls.ts.txt')
@@ -1189,10 +1266,10 @@ describe('跨几行写的调用', () => {
expect(asked).toEqual(expect.arrayContaining(['PaymentCapabilityDiagnostic', 'CredentialPresence']))
const nested = line?.table?.items[6]
expect(nested?.table?.columns).toEqual(['参数', '值'])
expect(nested?.cells[1].find(piece => 'role' in piece && piece.text === 'CredentialPresence')).toEqual({ text: 'CredentialPresence', role: 'type', offset: payment.indexOf('CredentialPresence(', payment.indexOf('credentials_present=')) })
expect(inlineOf(nested?.cells[1] ?? []).find(piece => 'role' in piece && piece.text === 'CredentialPresence')).toEqual({ text: 'CredentialPresence', role: 'type', offset: payment.indexOf('CredentialPresence(', payment.indexOf('credentials_present=')) })
expect(nested?.table?.items[0].cells[0]).toEqual([{ text: 'mp_app_secret', role: 'variable', offset: payment.indexOf('mp_app_secret=') }])
const unresolved = (await translateAll(py, 'python', pythonZhTranslator, payment)).get(64)
expect(segmentText(unresolved?.table?.items[6].cells[1] ?? [])).toBe('调用 CredentialPresence · 4 个参数')
expect(text(unresolved?.table?.items[6].cells[1])).toBe('调用 CredentialPresence · 4 个参数')
})
it('嵌套一层层往下套;await 的调用写「等待」', async () => {
+64 -34
View File
@@ -1,5 +1,8 @@
import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract'
import type { Access, AttributeNames, BaseFacts, BlockKind, CallArgument, CallFacts, ClassFacts, CommandContext, CommandFacts, CommandForm, CommandReading, CommandRule, CommandWord, CommonAttribute, DeclaratorFacts, Defines, DirectiveFacts, Doc, EmbeddedReading, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, HealthFacts, ImportFacts, ImportItem, Inline, InstructionFlag, InterpreterFacts, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, OptionsFacts, ParameterFacts, PositionalParameter, PreviewTable, PropertyItem, PropertyRow, Redirect, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, StageFacts, StartFacts, TextBlock, TextTranslation, TokenRole, Translator, VariableBlock } from '../contract'
import type { ParamDocs } from '../rules/docRows'
import { readDoc } from '../docTags'
import { commonIndent, splitLines } from '../editText'
import { docBodyRows, paramDocCell, paramDocs, returnNote, throwsRows, unmatchedParamRows, writtenParamRows } from '../rules/docRows'
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
export interface ContextualWord {
@@ -46,6 +49,11 @@ export interface ZhTable {
stringNodes: string[]
/** 注释(或 docstring)节点去掉注释符号后的原文,可以多行;text 是节点的源码去掉代码阅读的记号(SourceDocument.markers)以后剩下的。 */
commentText: (node: Node, text: string) => string
/**
* 说明是连着几条行注释写的(Rust 的 `///`)时,从第一条(facts.doc)起这一段的各条注释节点,照源码顺序;
* 不填就是那一个节点(JSDoc、docstring 本来就是一个节点)。
*/
docNodes?: (first: Node) => Node[]
/**
* 节点模板:节点类型 → 按中文语序重排的译文。命中的节点只输出模板里的译词和槽位,节点自己的关键字、标点不再输出;
* 槽位里的子节点照常递归翻译。模板引用的子节点缺了,或节点没整个落在要翻的这一段里,就不重排、照常翻。
@@ -661,9 +669,9 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
return joinTokens(tokensIn(node, nodeSpan(node), document))
}
function doc(node: Node, document: SourceDocument): Markdown | undefined {
const markdown = docText([commentOf(node, document)])
return markdown ? { markdown } : undefined
function doc(node: Node, document: SourceDocument): Doc | undefined {
const markdown = docText((table.docNodes?.(node) ?? [node]).map(part => commentOf(part, document)))
return markdown ? readDoc(markdown) : undefined
}
function describe(block: FactBlock, document: SourceDocument): PropertyRow[] {
@@ -817,8 +825,8 @@ interface Phrases {
module: (node: Node) => Segment[]
/** 源码里的一个名字原样照抄、按 role 着色(函数、类的名称)。 */
name: (node: Node, role: TokenRole) => Segment[]
/** 函数、类的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文;没有文字时没有。 */
doc: (node: Node) => Markdown | undefined
/** 函数、类的说明(JSDoc、docstring):去掉注释符号、引号和共同缩进的 Markdown 原文拆成正文和块标签;没有文字时没有。 */
doc: (node: Node) => Doc | undefined
/** 一条要执行的命令:数组写法交给命令规则读,shell 写法是宿主读好的嵌着的代码(SourceDocument.embedded),可以带一张选项表。 */
command: (form: CommandForm) => { text: Inline[], table?: PreviewTable }
}
@@ -907,38 +915,50 @@ function aliased(item: Inline[], alias: Node | undefined, say: Phrases): Inline[
}
/**
* 函数:名称 / 说明 / 访问级别 / 异步(只有分这个的语言才有)/ 装饰器 / 输入参数 / 输出参数。
* 输入参数是一张表,列名是名称 / 类型 / 默认值,每个参数一行;没有默认值的格子留空。
* 函数:名称 / 说明 / 对不上的参数 / 访问级别 / 异步(只有分这个的语言才有)/ 装饰器 / 输入参数 / 输出参数 / 异常。
* 输入参数是一张表,列名是名称 / 类型 / 默认值,每个参数一行;没有默认值的格子留空。说明里的块标签拆开放:
* `@param` 进输入参数表多出的「说明」列(有参数对上才有这一列),名字对不上的排在说明下面;`@return` 跟在输出参数的类型后面;
* `@throws` 是「异常」一行。
*/
function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] {
const doc = facts.doc && say.doc(facts.doc)
const names = facts.positional ? facts.positional.flatMap(param => param.name ? [param.name.text] : []) : facts.parameters.map(param => param.name.text)
const docs = paramDocs(doc, names)
return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'function') : [hint('匿名')] },
...docRows(facts.doc, say),
...docBodyRows(doc),
...unmatchedParamRows(docs.unmatched),
...accessRows(facts.access),
...flagRows('async', facts.async),
...decoratorRows(facts.decorators, say),
...(facts.positional
? positionalRows(facts, facts.positional, say)
? positionalRows(facts, facts.positional, say, docs, doc)
: [
parameterRow('输入参数', facts.parameters, say),
{ label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')] },
parameterRow('输入参数', facts.parameters, say, docs),
{ label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')], ...returnNote(doc) },
]),
...throwsRows(doc),
]
}
/**
* 按位置取参数的函数(shell):输入参数写「N 个,按位置」,下面一张表「位置 | 函数里叫」(没起名的写 $N);
* 按位置取参数的函数(shell):输入参数写「N 个,按位置」,下面一张表「位置 | 函数里叫」(没起名的写 $N;`@param` 按起的名字对上时多一列说明);
* 输出是它打印的那几句(调用处用 $(函数 …) 拿去用时注明);返回是写了的 return 值,没写 return、也不打印时是最后一句成不成功。
*/
function positionalRows(facts: FunctionFacts, positional: PositionalParameter[], say: Phrases): PropertyRow[] {
function positionalRows(facts: FunctionFacts, positional: PositionalParameter[], say: Phrases, docs: ParamDocs, doc: Doc | undefined): PropertyRow[] {
const described = positional.some(param => param.name && docs.of(param.name.text))
const inputs: PropertyRow = positional.length === 0
? { label: '输入参数', value: [hint('无')] }
: {
label: '输入参数',
value: [hint(`${positional.length} 个,按位置`)],
columns: ['位置', '函数里叫'],
columns: described ? ['位置', '函数里叫', '说明'] : ['位置', '函数里叫'],
items: positional.map(param => ({
cells: [[hint(`第 ${param.position} 个`)], param.name ? say.name(param.name, 'variable') : [segment(`$${param.position}`, 'variable', undefined)]],
cells: [
[hint(`第 ${param.position} 个`)],
param.name ? say.name(param.name, 'variable') : [segment(`$${param.position}`, 'variable', undefined)],
...(described ? [param.name ? paramDocCell(docs, param.name.text) : []] : []),
],
...defining(param.name),
})),
}
@@ -950,7 +970,9 @@ function positionalRows(facts: FunctionFacts, positional: PositionalParameter[],
const result: PropertyRow[] = values.length > 0
? [{ label: '返回', value: joined(values, '、') }]
: prints.length === 0 ? [{ label: '返回', value: [hint('最后一句成不成功')] }] : []
return [inputs, ...output, ...result]
// `@return` 跟在返回那一行后面;只有打印、没写 return 时跟在输出那一行后面
const [noted] = [...result, ...output]
return [inputs, ...[...output, ...result].map(row => row === noted ? { ...row, ...returnNote(doc) } : row)]
}
/** 解释器:叫什么,怎么找到它(按 PATH 找,或写死的路径)。 */
@@ -991,7 +1013,7 @@ function flagValue(flag: InstructionFlag): Inline[] {
* 接着前面某个阶段建的写「阶段 X」)/ 平台(写了 --platform 才有)。
*/
function stageRows(facts: StageFacts, say: Phrases): PropertyRow[] {
const doc = (facts.doc ?? []).map(node => say.doc(node)?.markdown).filter(Boolean).join('\n')
const doc = (facts.doc ?? []).map(node => say.doc(node)?.body?.markdown).filter(Boolean).join('\n')
const platform = facts.flags.find(flag => flag.name === 'platform')
const image: Inline[] = facts.stage
? [segment('阶段', 'keyword', undefined), segment(' ', 'plain', undefined), ...say.name(facts.image, 'variable')]
@@ -1078,17 +1100,22 @@ function scopedVariableRows(declarators: DeclaratorFacts[], scope: 'build' | 'en
}]
}
/** 参数表:列名是名称 / 类型 / 默认值,每个参数一行,讲的定义是参数名;没有默认值的格子留空。 */
function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases): PropertyRow {
/**
* 参数表:列名是名称 / 类型 / 默认值,每个参数一行,讲的定义是参数名;没有默认值的格子留空。
* 说明里有参数对上了(docs)时多一列「说明」,没对上的格子留空。
*/
function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases, docs: ParamDocs): PropertyRow {
const described = parameters.some(param => docs.of(param.name.text))
return {
label,
value: count(parameters.length),
columns: ['名称', '类型', '默认值'],
columns: described ? ['名称', '类型', '默认值', '说明'] : ['名称', '类型', '默认值'],
items: parameters.map(param => ({
cells: [
param.optional ? [...say.translated(param.name), { text: '?', role: 'plain' }] : say.translated(param.name),
param.type ? say.translated(param.type) : [hint('未标注')],
param.default ? say.translated(param.default) : [],
...(described ? [paramDocCell(docs, param.name.text)] : []),
],
...defining(param.name),
})),
@@ -1098,19 +1125,23 @@ function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases)
/**
* 类:名称 / 说明 / 访问级别 / 抽象(只有分这个的语言才有)/ 装饰器 / 类型参数 / 父类 / 实现的接口,
* 说明及以后各行有才显示;类型参数、父类、实现的接口各是一行,多项用逗号隔开。类型参数那一行讲的是这几个泛型参数的定义。
* 类没有构造参数表可对,说明里的 `@param` 照写成「构造参数」一张「名称 | 说明」表;`@throws` 是「异常」一行。
*/
function classRows(facts: ClassFacts, say: Phrases): PropertyRow[] {
const listRow = (label: string, parts: Inline[][], defines: Defines = {}): PropertyRow[] =>
parts.length > 0 ? [{ label, value: joined(parts, ', '), ...defines }] : []
const doc = facts.doc && say.doc(facts.doc)
return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] },
...docRows(facts.doc, say),
...docBodyRows(doc),
...accessRows(facts.access),
...flagRows('abstract', facts.abstract),
...decoratorRows(facts.decorators, say),
...listRow('类型参数', facts.typeParameters.map(say.translated), defining(...facts.typeParameters)),
...listRow('父类', facts.bases.map(base => baseText(base, say))),
...listRow('实现的接口', facts.implements.map(say.translated)),
...writtenParamRows(doc?.params ?? [], '构造参数'),
...throwsRows(doc),
]
}
@@ -1129,7 +1160,7 @@ function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] {
const described = docs.some(Boolean)
return [
{ label: '名称', value: say.name(facts.name, 'type') },
...docRows(facts.doc, say),
...docBodyRows(facts.doc && say.doc(facts.doc)),
...accessRows(facts.access),
...decoratorRows(facts.decorators, say),
...baseRows(facts.bases, say),
@@ -1153,6 +1184,7 @@ function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] {
* 异常(Python、TypeScript 同一张表):名称 / 说明 / 访问级别 / 装饰器 / 继承(照写的父类),说明及以后有才显示;
* 类属性收成一张表:属性 | 类型 | 值 | 说明,一个属性都没写类型时没有「类型」列、都没有说明时没有「说明」列,没写的格子留空;
* 构造参数同函数的输入参数表。没有类属性、没写构造函数时没有这两行。
* 说明里的 `@param` 对到构造参数表(同函数的输入参数,对不上的排在说明下面;没写构造函数时同类,照写成一张表),`@throws` 是「异常」一行。
*/
function exceptionRows(facts: ExceptionFacts, say: Phrases): PropertyRow[] {
const docs = facts.fields.map(field => docsText(field.docs, say))
@@ -1172,14 +1204,18 @@ function exceptionRows(facts: ExceptionFacts, say: Phrases): PropertyRow[] {
...defining(field.name),
})),
}
const doc = facts.doc && say.doc(facts.doc)
const params = facts.parameters && paramDocs(doc, facts.parameters.map(param => param.name.text))
return [
{ label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] },
...docRows(facts.doc, say),
...docBodyRows(doc),
...unmatchedParamRows(params?.unmatched ?? []),
...accessRows(facts.access),
...decoratorRows(facts.decorators, say),
...baseRows(facts.bases, say),
...(facts.fields.length > 0 ? [fields] : []),
...(facts.parameters ? [parameterRow('构造参数', facts.parameters, say)] : []),
...(facts.parameters && params ? [parameterRow('构造参数', facts.parameters, say, params)] : writtenParamRows(doc?.params ?? [], '构造参数')),
...throwsRows(doc),
]
}
@@ -1188,21 +1224,15 @@ function baseRows(bases: BaseFacts[], say: Phrases): PropertyRow[] {
return bases.length > 0 ? [{ label: '继承', value: joined(bases.map(base => baseText(base, say)), ', ') }] : []
}
/** 表格里一格说明:几段说明文字的原文用空格连起来。 */
/** 表格里一格说明:几段说明文字的正文用空格连起来。 */
function docsText(nodes: Node[], say: Phrases): string {
return nodes.map(node => say.doc(node)?.markdown).filter(Boolean).join(' ')
return nodes.map(node => say.doc(node)?.body?.markdown).filter(Boolean).join(' ')
}
function docCell(text: string): Inline[] {
return text ? [segment(text, 'comment', undefined)] : []
}
/** 函数、类、常量、变量的说明:有文字才有这一行。 */
function docRows(doc: Node | undefined, say: Phrases): PropertyRow[] {
const markdown = doc && say.doc(doc)
return markdown ? [{ label: '说明', value: markdown }] : []
}
/** 装饰器:源码原样照抄,多个换行。 */
function decoratorRows(decorators: Node[], say: Phrases): PropertyRow[] {
return decorators.length > 0 ? [{ label: '装饰器', value: joined(decorators.map(say.verbatim), '\n') }] : []
@@ -1238,7 +1268,7 @@ function variableRows(block: VariableBlock, say: Phrases): PropertyRow[] {
const valueLabel = block.kind === 'const' ? '值' : '初始值'
return block.facts.declarators.flatMap((declarator, i) => [
{ label: '名称', value: say.translated(declarator.name), ...defining(declarator.name) },
...(i === 0 ? docRows(block.facts.doc, say) : []),
...(i === 0 ? docBodyRows(block.facts.doc && say.doc(block.facts.doc)) : []),
...accessRows(block.facts.access),
...(declarator.type ? [{ label: '类型', value: say.translated(declarator.type) }] : []),
{ label: valueLabel, ...(declarator.value ? declaredValue(declarator.value, declarator.entries, say) : { value: [hint('无')] }) },