✨ feat(vscode): 代码阅读加异常卡片,Python、TypeScript 的异常类画成同一张表(属性、构造参数),方法照常是子块
This commit is contained in:
1 parent
d5a092dd6f
commit
0afd012c94
21 files changed
+1118
-66
No files matched your search
@@ -0,0 +1,339 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { Inline, LanguageAdapter, PropertyRow, Segment, Translator } from '../contract'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { fileAttributes } from '../attributes'
|
||||
import { readBlockViews, toBlockViews } from '../blockView'
|
||||
import { replaceLines } from '../editText'
|
||||
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 { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { typescriptZhTranslator } from '../translators/typescriptZh'
|
||||
import { createPythonAdapter } from './python'
|
||||
import { createTypeScriptAdapter } from './typescript'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
const ts = createTypeScriptAdapter(WASM_DIR)
|
||||
const APP = readFixture('appExceptions.py')
|
||||
const COMMERCE = readFixture('commerceExceptions.py')
|
||||
const TS = readFixture('exceptions.ts.txt')
|
||||
/** 和扩展里注册的顺序一样。 */
|
||||
const RULES = [sqlalchemyRule, celeryRule, enumRule, dataclassRule]
|
||||
|
||||
function views(adapter: LanguageAdapter, languageId: string, translator: Translator, source: string): Promise<BlockView[]> {
|
||||
return withBlocks(adapter, languageId, source, blocks =>
|
||||
toBlockViews(blocks, { source, language: adapter, markers: [] }, translator, { rules: RULES, report: () => {} }))
|
||||
}
|
||||
|
||||
const pyViews = (source: string): Promise<BlockView[]> => views(py, 'python', pythonZhTranslator, source)
|
||||
const tsViews = (source: string): Promise<BlockView[]> => views(ts, 'typescript', typescriptZhTranslator, source)
|
||||
|
||||
function flatten(list: readonly BlockView[]): BlockView[] {
|
||||
return list.flatMap(view => [view, ...flatten(view.children)])
|
||||
}
|
||||
|
||||
function properties(view: BlockView | undefined): PropertyRow[] {
|
||||
if (!view || !('properties' in view))
|
||||
throw new Error(`块 ${view?.id} 不是属性表`)
|
||||
return view.properties
|
||||
}
|
||||
|
||||
function named(list: readonly BlockView[], name: string): BlockView {
|
||||
const found = flatten(list).find(view => 'properties' in view && propertyLines(view.properties)[0] === `名称: ${name}`)
|
||||
if (!found)
|
||||
throw new Error(`没有 ${name}`)
|
||||
return found
|
||||
}
|
||||
|
||||
function lines(list: readonly BlockView[], name: string): string[] {
|
||||
return propertyLines(properties(named(list, name)))
|
||||
}
|
||||
|
||||
/** 属性表里所有带下标的词(连表格里的格子)。 */
|
||||
function linkedSegments(rows: PropertyRow[]): Segment[] {
|
||||
const pieces: Inline[] = rows.flatMap(row => [
|
||||
...('markdown' in row.value ? [] : row.value),
|
||||
...(row.items ?? []).flatMap(item => item.cells.flat()),
|
||||
])
|
||||
return flatSegments(pieces).filter(segment => segment.offset !== undefined)
|
||||
}
|
||||
|
||||
const PY_CASES = [
|
||||
'from dataclasses import dataclass',
|
||||
'import typing',
|
||||
'',
|
||||
'',
|
||||
'class Late(Base):',
|
||||
' """写在父类前面,照样认得出。"""',
|
||||
'',
|
||||
'',
|
||||
'class Base(Exception):',
|
||||
' # 错误码',
|
||||
' # 机器可读',
|
||||
' code = "E"',
|
||||
' retry: bool = False # 能不能重试',
|
||||
' detail: str',
|
||||
' """额外的说明。"""',
|
||||
'',
|
||||
' # 讲方法的注释',
|
||||
' def describe(self) -> str:',
|
||||
' return self.code',
|
||||
'',
|
||||
' level = 1',
|
||||
' a = b = 2',
|
||||
' x, y = 1, 2',
|
||||
'',
|
||||
' def __init__(self, message: str, *, level: int = 1, **extra) -> None:',
|
||||
' super().__init__(message)',
|
||||
'',
|
||||
' # 收尾',
|
||||
'',
|
||||
'',
|
||||
'class Deeper(Late):',
|
||||
' pass',
|
||||
'',
|
||||
'',
|
||||
'class Stop(StopIteration): ...',
|
||||
'class Warned(UserWarning): ...',
|
||||
'class Remote(pkg.errors.AppException): ...',
|
||||
'class Typed(typing.Generic[T], KeyboardInterrupt): ...',
|
||||
'class Plain(Model, metaclass=ErrorMeta): ...',
|
||||
'class Other(Mapping[str, int]): ...',
|
||||
'class Starred(*bases): ...',
|
||||
'',
|
||||
'',
|
||||
'class Loose(Exception):',
|
||||
' def __init__(*args):',
|
||||
' pass',
|
||||
'',
|
||||
'',
|
||||
'@dataclass',
|
||||
'class Payload(ValueError):',
|
||||
' field: int = 0',
|
||||
'',
|
||||
'',
|
||||
'def factory():',
|
||||
' class Local(RuntimeError):',
|
||||
' pass',
|
||||
' return Local',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
describe('异常卡片(Python)', () => {
|
||||
it('luffy 的 core/exceptions:基类的类属性、构造参数,子类只改类属性;方法留作子块', async () => {
|
||||
const list = await pyViews(APP)
|
||||
expect(list.map(view => view.kind)).toEqual(['comment', 'import', 'import', ...Array.from({ length: 12 }, () => 'exception')])
|
||||
expect(lines(list, 'AppException')).toEqual([
|
||||
'名称: AppException',
|
||||
'说明: 应用异常基类.\n\nAttributes:\n status_code: HTTP 状态码。\n error_code: 业务错误码(机器可读)。\n message: 错误描述(人类可读)。\n detail: 额外错误详情。',
|
||||
'访问级别: 公共',
|
||||
'继承: Exception',
|
||||
'属性: 3 个',
|
||||
' status_code | 整数 | 500',
|
||||
' error_code | 字符串 | "INTERNAL_ERROR"',
|
||||
' message | 字符串 | "服务器内部错误"',
|
||||
'构造参数: 3 个',
|
||||
' message | 字符串 | 空 | 空',
|
||||
' detail | Any | 空',
|
||||
' error_code | 字符串 | 空 | 空',
|
||||
])
|
||||
const app = properties(named(list, 'AppException'))
|
||||
expect(app.map(row => row.columns)).toEqual([undefined, undefined, undefined, undefined, ['属性', '类型', '值'], ['名称', '类型', '默认值']])
|
||||
expect(named(list, 'AppException').children.map(child => [child.kind, child.startLine])).toEqual([['function', 25], ['function', 39]])
|
||||
expect(lines(list, 'NotFound')).toEqual([
|
||||
'名称: NotFound',
|
||||
'说明: 资源不存在 (404).',
|
||||
'访问级别: 公共',
|
||||
'继承: AppException',
|
||||
'属性: 3 个',
|
||||
' status_code | 404',
|
||||
' error_code | "NOT_FOUND"',
|
||||
' message | "资源不存在"',
|
||||
])
|
||||
expect(properties(named(list, 'NotFound')).at(-1)?.columns).toEqual(['属性', '值'])
|
||||
expect(named(list, 'NotFound').children).toEqual([])
|
||||
expect(lines(list, 'GatewayTimeout').slice(0, 2)).toEqual([
|
||||
'名称: GatewayTimeout',
|
||||
'说明: 上游服务响应超时 (504).\n\n用于表达 "请求已发往上游但在时限内未拿到响应":区别于 502\n(上游明确报错) 和 503 (上游不可达/未就绪)。',
|
||||
])
|
||||
})
|
||||
|
||||
it('luffy 的 commerce/exceptions:一串子类都是异常;只有说明的一张卡片四行,写了 __init__ 的多一行构造参数', async () => {
|
||||
const list = await pyViews(COMMERCE)
|
||||
expect(list.map(view => view.kind)).toEqual(['comment', 'import', ...Array.from({ length: 16 }, () => 'exception')])
|
||||
expect(lines(list, 'CommerceError')).toEqual(['名称: CommerceError', '说明: commerce 域基础异常.', '访问级别: 公共', '继承: Exception'])
|
||||
expect(lines(list, 'ProductNotFoundError')).toEqual(['名称: ProductNotFoundError', '说明: 商品不存在或已下架.', '访问级别: 公共', '继承: CommerceError'])
|
||||
expect(lines(list, 'UnsupportedPaymentMethodError')).toEqual([
|
||||
'名称: UnsupportedPaymentMethodError',
|
||||
'说明: 未配置当前 payment_method 对应的 driver. 通常意味着环境变量缺失.',
|
||||
'访问级别: 公共',
|
||||
'继承: CommerceError',
|
||||
'构造参数: 1 个',
|
||||
' method | 字符串 | ',
|
||||
])
|
||||
expect(named(list, 'UnsupportedPaymentMethodError').children.map(child => child.kind)).toEqual(['function'])
|
||||
})
|
||||
|
||||
it('认哪些类:父类是自带的异常类(含 Warning)、同一文件里的异常类(写在前面后面都行,一层层往下传)、名字以 Error / Exception / Warning 结尾;关键字参数、泛型、展开不算', async () => {
|
||||
const list = await pyViews(PY_CASES)
|
||||
const kinds = Object.fromEntries(flatten(list).filter(view => view.kind === 'exception' || view.kind === 'class')
|
||||
.map(view => [propertyLines(properties(view))[0].replace('名称: ', ''), view.kind]))
|
||||
expect(kinds).toEqual({
|
||||
Late: 'exception',
|
||||
Base: 'exception',
|
||||
Deeper: 'exception',
|
||||
Stop: 'exception',
|
||||
Warned: 'exception',
|
||||
Remote: 'exception',
|
||||
Typed: 'exception',
|
||||
Plain: 'class',
|
||||
Other: 'class',
|
||||
Starred: 'class',
|
||||
Loose: 'exception',
|
||||
Payload: 'exception',
|
||||
Local: 'exception',
|
||||
})
|
||||
})
|
||||
|
||||
it('类属性:给一个名字赋值(带不带注解、有没有值),说明取上面的注释块、行尾注释、属性 docstring;连着赋值、解构赋值、方法照常是子块;构造参数去掉 self', async () => {
|
||||
const list = await pyViews(PY_CASES)
|
||||
expect(lines(list, 'Base')).toEqual([
|
||||
'名称: Base',
|
||||
'访问级别: 公共',
|
||||
'继承: Exception',
|
||||
'属性: 4 个',
|
||||
' code | | "E" | 错误码 机器可读',
|
||||
' retry | 布尔 | 假 | 能不能重试',
|
||||
' detail | 字符串 | | 额外的说明。',
|
||||
' level | | 1 | ',
|
||||
'构造参数: 3 个',
|
||||
' message | 字符串 | ',
|
||||
' level | 整数 | 1',
|
||||
' **extra | 未标注 | ',
|
||||
])
|
||||
const source = PY_CASES.split('\n')
|
||||
const base = named(list, 'Base')
|
||||
expect(base.children.map(child => source[child.startLine].trim())).toEqual([
|
||||
'# 讲方法的注释',
|
||||
'def describe(self) -> str:',
|
||||
'a = b = 2',
|
||||
'x, y = 1, 2',
|
||||
'def __init__(self, message: str, *, level: int = 1, **extra) -> None:',
|
||||
'# 收尾',
|
||||
])
|
||||
expect(lines(list, 'Loose').slice(-2)).toEqual(['构造参数: 1 个', ' *args | 未标注 | '])
|
||||
expect(lines(list, 'Payload')).toEqual(['名称: Payload', '访问级别: 公共', '装饰器: @dataclass', '继承: ValueError', '属性: 1 个', ' field | 整数 | 0'])
|
||||
expect(named(list, 'Local').attributes).toEqual({ kind: 'exception' })
|
||||
})
|
||||
|
||||
it('编辑范围是整个类(并进来的属性、留下的方法都在里面),改卡片写回整个类;块自己是类名的定义,属性行、参数行各是它那个名字的定义', async () => {
|
||||
const source = PY_CASES.split('\n')
|
||||
const base = named(await pyViews(PY_CASES), 'Base')
|
||||
expect([source[base.startLine], source[base.endLine]]).toEqual(['class Base(Exception):', ' # 收尾'])
|
||||
expect(replaceLines(PY_CASES, base.startLine, base.endLine, base.code.replace('code = "E"', 'code = "F"')))
|
||||
.toBe(PY_CASES.replace('code = "E"', 'code = "F"'))
|
||||
expect(PY_CASES.slice(base.defines?.startIndex, base.defines?.endIndex)).toBe('Base')
|
||||
const rows = properties(base)
|
||||
const defined = (label: string): string[] => (rows.find(row => row.label === label)?.items ?? []).map(item => PY_CASES.slice(item.defines?.startIndex, item.defines?.endIndex))
|
||||
expect(defined('属性')).toEqual(['code', 'retry', 'detail', 'level'])
|
||||
expect(defined('构造参数')).toEqual(['message', 'level', '**extra'])
|
||||
const [fieldName] = rows.find(row => row.label === '属性')?.items?.[0].cells[0] as Segment[]
|
||||
expect(fieldName).toEqual({ text: 'code', role: 'variable', offset: PY_CASES.indexOf('code = "E"') })
|
||||
for (const segment of linkedSegments(rows))
|
||||
expect(PY_CASES.slice(segment.offset, (segment.offset ?? 0) + segment.text.length), segment.text).toBe(segment.text)
|
||||
})
|
||||
})
|
||||
|
||||
describe('异常卡片(TypeScript)', () => {
|
||||
it('extends Error 的类:JSDoc 当说明,类字段(含 static)进属性表,constructor 的参数(含参数属性)进构造参数,方法留作子块', async () => {
|
||||
const list = await tsViews(TS)
|
||||
expect(lines(list, 'ApiError')).toEqual([
|
||||
'名称: ApiError',
|
||||
'说明: 调接口出错。',
|
||||
'访问级别: 公共',
|
||||
'继承: Error',
|
||||
'属性: 2 个',
|
||||
' status | | 500 | ',
|
||||
' kind | 字符串 | \'API_ERROR\' | 机器可读的错误码',
|
||||
'构造参数: 2 个',
|
||||
' message | 字符串 | ',
|
||||
' code? | 字符串 | ',
|
||||
])
|
||||
const api = named(list, 'ApiError')
|
||||
expect(api.children.map(child => [child.kind, child.startLine])).toEqual([['function', 6], ['function', 10]])
|
||||
expect([api.startLine, api.endLine]).toEqual([0, 13])
|
||||
expect(lines(list, 'NotFoundError')).toEqual(['名称: NotFoundError', '访问级别: 公共', '继承: ApiError', '属性: 1 个', ' status | 404 | 找不到'])
|
||||
})
|
||||
|
||||
it('认哪些类:同一文件里的异常类(写在后面也算)、赋给变量的类表达式、`ns.X<T>`;父类名不合习惯、不在这份文件里认出来的照常是类', async () => {
|
||||
const list = await tsViews(TS)
|
||||
expect(lines(list, 'Slow')).toEqual(['名称: Slow', '访问级别: 私有', '继承: Timeout'])
|
||||
expect(named(list, 'Timeout').kind).toBe('exception')
|
||||
expect(lines(list, 'Cancelled')).toEqual(['名称: Cancelled', '访问级别: 私有', '继承: RangeError'])
|
||||
expect(lines(list, 'Forbidden')).toEqual(['名称: Forbidden', '访问级别: 公共', '继承: errors.HttpException<字符串>'])
|
||||
const repository = named(list, 'Repository')
|
||||
expect(repository.kind).toBe('class')
|
||||
expect(repository.children.map(child => child.kind)).toEqual(['field'])
|
||||
})
|
||||
|
||||
it('javaScript 的类字段同样认', async () => {
|
||||
const source = 'class HttpError extends Error {\n status = 500\n static code\n}\n'
|
||||
const list = await views(ts, 'javascript', typescriptZhTranslator, source)
|
||||
expect(lines(list, 'HttpError')).toEqual(['名称: HttpError', '访问级别: 私有', '继承: Error', '属性: 2 个', ' status | 500', ' code | '])
|
||||
})
|
||||
|
||||
it('typeScript 和 Python 同样的异常得到一样的种类、属性表行、列名', async () => {
|
||||
const pyList = await pyViews('class Failed(Exception):\n status = 500 # 状态码\n\n def __init__(self, message: str) -> None:\n pass\n')
|
||||
const tsList = await tsViews('export class Failed extends Error {\n status = 500 // 状态码\n\n constructor(message: string) {\n super(message)\n }\n}\n')
|
||||
const shape = (list: BlockView[]): unknown => properties(named(list, 'Failed')).map(row => [row.label, row.columns, row.items?.map(item => item.cells.length)])
|
||||
expect(named(pyList, 'Failed').kind).toBe('exception')
|
||||
expect(named(tsList, 'Failed').kind).toBe('exception')
|
||||
expect(shape(pyList)).toEqual(shape(tsList))
|
||||
expect(lines(pyList, 'Failed').slice(-4)).toEqual(lines(tsList, 'Failed').slice(-4))
|
||||
})
|
||||
})
|
||||
|
||||
describe('异常卡片(宿主、筛选、中文名)', () => {
|
||||
it('筛选:种类是 exception、叫「异常」,访问级别同原来的类;没有种类标签(badge),库规则不认', async () => {
|
||||
const list = await pyViews(PY_CASES)
|
||||
expect(named(list, 'Base').attributes).toEqual({ kind: 'exception', access: 'public' })
|
||||
expect(named(list, 'Base').badge).toBeUndefined()
|
||||
expect(fileAttributes(list, pythonZhTranslator.attributeNames).find(attribute => attribute.id === 'kind')?.values)
|
||||
.toContainEqual({ id: 'exception', name: '异常', count: 10 })
|
||||
})
|
||||
|
||||
it('各块的源码着色拼起来等于编辑范围的整行源码', async () => {
|
||||
for (const [list, source] of [[await pyViews(APP), APP], [await pyViews(COMMERCE), COMMERCE], [await tsViews(TS), TS]] as const) {
|
||||
const rows = source.split('\n')
|
||||
for (const view of flatten(list)) {
|
||||
expect(view.codeTokens.map(token => token.text).join(''), view.id).toBe(view.code)
|
||||
const indent = Math.min(...rows.slice(view.startLine, view.endLine + 1).filter(row => row.trim()).map(row => row.length - row.trimStart().length))
|
||||
expect(view.code, view.id).toBe(rows.slice(view.startLine, view.endLine + 1).map(row => row.slice(indent)).join('\n'))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('代码块树:异常块的头部是整个类,留下的方法是子块', async () => {
|
||||
expect(await withBlocks(py, 'python', 'class E(Exception):\n code = 1\n\n def run(self):\n pass\n', outline)).toEqual([
|
||||
{ at: [1, 5], kind: 'exception', kids: [{ at: 4, kind: 'function', kids: [{ at: 5, kind: 'statement' }] }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('属性的中文名:属性格子按下标换字,行尾记号不进说明', async () => {
|
||||
const source = [
|
||||
'from typing import Annotated',
|
||||
'class Failed(Exception):',
|
||||
' code: Annotated[str, Comment("错误码")] = "E"',
|
||||
' status = 500 # 状态 @Comment 状态码',
|
||||
].join('\n')
|
||||
const { blocks, displayNames } = await readBlockViews(runtime, py, pythonZhTranslator, 'python', source)
|
||||
expect(propertyLines(properties(named(blocks, 'Failed'))).slice(-3)).toEqual([
|
||||
'属性: 2 个',
|
||||
' code | Annotated[字符串, Comment("错误码")] | "E" | ',
|
||||
' status | | 500 | 状态',
|
||||
])
|
||||
expect(displayNames.map(({ name, text }) => `${source.slice(name.startIndex, name.endIndex)} → ${text}`)).toEqual(['code → 错误码', 'status → 状态码'])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user