✨ feat(vscode): 代码阅读加异常卡片,Python、TypeScript 的异常类画成同一张表(属性、构造参数),方法照常是子块
This commit is contained in:
1 parent
d5a092dd6f
commit
0afd012c94
21 files changed
+1118
-66
No files matched your search
@@ -27,6 +27,7 @@ function factAttributes(block: CodeBlock): Partial<Record<CommonAttribute, strin
|
||||
case 'class':
|
||||
return { access: block.facts.access, abstract: flag(block.facts.abstract) }
|
||||
case 'enum':
|
||||
case 'exception':
|
||||
case 'const':
|
||||
case 'variable':
|
||||
return { access: block.facts.access }
|
||||
|
||||
@@ -117,7 +117,7 @@ describe('blockView', () => {
|
||||
const notFound = byId(await views(py, 'python', pythonZhTranslator, readFixture('classes.py')), '3')
|
||||
expect(notFound).toMatchObject({ kind: 'class', startLine: 17, endLine: 19 })
|
||||
expect(notFound.code.split('\n')).toEqual([
|
||||
'class NotFound(AppException): # 行尾',
|
||||
'class NotFound(AppResponse): # 行尾',
|
||||
' # 说明前面的注释',
|
||||
' """找不到资源。"""',
|
||||
])
|
||||
|
||||
@@ -32,7 +32,7 @@ interface BlockViewBase {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发给 webview 的一块(可序列化):函数、类、枚举、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
|
||||
* 发给 webview 的一块(可序列化):函数、类、枚举、异常、导入、常量、变量块和库规则的卡片预览时是属性表,注释块是 Markdown 说明,其余是一行译文。
|
||||
* 一行译文的块带说明(TextBlock.doc)时 doc 是翻译插件给的 Markdown 原文,预览排在译文下面。
|
||||
*/
|
||||
export type BlockView = BlockViewBase & ({ translation: PreviewText, doc?: Markdown } | { properties: PropertyRow[] })
|
||||
@@ -107,6 +107,7 @@ function ownName(block: CodeBlock): Node | undefined {
|
||||
case 'function':
|
||||
case 'class':
|
||||
case 'enum':
|
||||
case 'exception':
|
||||
return block.facts.name
|
||||
case 'const':
|
||||
case 'variable':
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Disposable } from 'vscode'
|
||||
export type { Node }
|
||||
|
||||
export type BlockKind
|
||||
= | 'import' | 'function' | 'class' | 'enum' | 'type' | 'field' | 'const' | 'variable'
|
||||
= | 'import' | 'function' | 'class' | 'enum' | 'exception' | 'type' | 'field' | 'const' | 'variable'
|
||||
| 'if' | 'else' | 'loop' | 'try' | 'catch' | 'finally' | 'return' | 'statement' | 'comment'
|
||||
|
||||
/** 源码里一段的下标:源码字符串下标(web-tree-sitter 的 startIndex / endIndex),end 为开区间。 */
|
||||
@@ -101,6 +101,35 @@ export interface EnumFacts {
|
||||
members: EnumMemberFacts[]
|
||||
}
|
||||
|
||||
/** 异常类的一个类属性:Python 类主体里给一个名字赋值的语句(带不带注解、有没有值),TypeScript 的类字段(含 static);节点都指向源码。 */
|
||||
export interface FieldFacts {
|
||||
name: Node
|
||||
type?: Node
|
||||
value?: Node
|
||||
/** 属性的说明文字节点,同 EnumMemberFacts.docs:紧挨在上面单独成行的注释 / JSDoc、这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */
|
||||
docs: Node[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 异常类:父类是语言自带的异常类、同一文件里认出来的异常类,或名字按命名习惯以 Error / Exception(Python 还有 Warning)结尾。
|
||||
* Python、TypeScript 都由语言插件报,同一种块、同一张属性表。块的头部是整个类(类属性在属性表里讲),留下的方法等是子块。
|
||||
*/
|
||||
export interface ExceptionFacts {
|
||||
/** 同 ClassFacts.name。 */
|
||||
name?: Node
|
||||
/** 同 ClassFacts.doc。 */
|
||||
doc?: Node
|
||||
/** 同 ClassFacts.access。 */
|
||||
access?: Access
|
||||
decorators: Node[]
|
||||
/** 照源码写的父类,同 ClassFacts.bases。 */
|
||||
bases: BaseFacts[]
|
||||
/** 类属性,照源码顺序。 */
|
||||
fields: FieldFacts[]
|
||||
/** 构造函数的参数(Python 的 `__init__` 去掉 self,TypeScript 的 `constructor(...)`);类里没写构造函数时没有。 */
|
||||
parameters?: ParameterFacts[]
|
||||
}
|
||||
|
||||
/** 导入的一项。 */
|
||||
export type ImportItem
|
||||
= | { kind: 'named', name: Node, alias?: Node } // import { a as b }、from m import a as b
|
||||
@@ -176,7 +205,7 @@ export interface VariableFacts {
|
||||
* - kind:块的种类(BlockKind);
|
||||
* - access:访问级别,'public' | 'private' | 'protected',语言可以加自己的值(Kotlin 的 'internal',见 BlockShape.attributes);
|
||||
* - async、abstract、typeOnly:异步、抽象、仅类型,值是 'true' | 'false'。
|
||||
* 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、EnumFacts.access、ImportFacts.typeOnly、VariableFacts.access),
|
||||
* 宿主从块的 kind 和 facts 推出它们(FunctionFacts.access / async、ClassFacts.access / abstract、EnumFacts.access、ExceptionFacts.access、ImportFacts.typeOnly、VariableFacts.access),
|
||||
* 语言插件不用另报;facts 里没填的(语言没有这个概念、块没有这一项),块就没有这个属性,也不受这个属性的筛选影响。
|
||||
*/
|
||||
export const COMMON_ATTRIBUTES = ['kind', 'access', 'async', 'abstract', 'typeOnly'] as const
|
||||
@@ -207,12 +236,14 @@ export interface FunctionBlock extends BlockShape { kind: 'function', facts: Fun
|
||||
export interface ClassBlock extends BlockShape { kind: 'class', facts: ClassFacts }
|
||||
/** 枚举块的头部是整个枚举(成员都在属性表里讲),没有子块;库规则认出来的枚举(Python)是整个类,留下的方法等是子块。 */
|
||||
export interface EnumBlock extends BlockShape { kind: 'enum', facts: EnumFacts }
|
||||
/** 异常块的头部是整个类(类属性都在属性表里讲),留下的方法(含构造函数)等是子块。 */
|
||||
export interface ExceptionBlock extends BlockShape { kind: 'exception', facts: ExceptionFacts }
|
||||
export interface ImportBlock extends BlockShape { kind: 'import', facts: ImportFacts }
|
||||
export interface VariableBlock extends BlockShape { kind: 'const' | 'variable', facts: VariableFacts }
|
||||
export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ImportBlock | VariableBlock
|
||||
export type FactBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | ImportBlock | VariableBlock
|
||||
|
||||
/** 带说明(facts.doc)的块。 */
|
||||
export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | VariableBlock
|
||||
export type DocumentedBlock = FunctionBlock | ClassBlock | EnumBlock | ExceptionBlock | VariableBlock
|
||||
|
||||
/** 属性表块的种类和 facts,不带块的形状(节点、头部、子块):库规则把一块认成现成的一种属性表块时给它(RuleFacts)。 */
|
||||
export type BlockFacts = FactBlock extends infer B ? B extends FactBlock ? Pick<B, 'kind' | 'facts'> : never : never
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""自定义异常体系.
|
||||
|
||||
所有业务异常继承自 AppException,携带 HTTP 状态码和结构化错误信息。
|
||||
在 main.py 中注册全局异常处理器统一返回格式。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""应用异常基类.
|
||||
|
||||
Attributes:
|
||||
status_code: HTTP 状态码。
|
||||
error_code: 业务错误码(机器可读)。
|
||||
message: 错误描述(人类可读)。
|
||||
detail: 额外错误详情。
|
||||
"""
|
||||
|
||||
status_code: int = 500
|
||||
error_code: str = "INTERNAL_ERROR"
|
||||
message: str = "服务器内部错误"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str | None = None,
|
||||
*,
|
||||
detail: Any = None,
|
||||
error_code: str | None = None,
|
||||
) -> None:
|
||||
if message is not None:
|
||||
self.message = message
|
||||
if error_code is not None:
|
||||
self.error_code = error_code
|
||||
self.detail = detail
|
||||
super().__init__(self.message)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""序列化为 JSON 响应体."""
|
||||
body: dict[str, Any] = {
|
||||
"error_code": self.error_code,
|
||||
"message": self.message,
|
||||
}
|
||||
if self.detail is not None:
|
||||
body["detail"] = self.detail
|
||||
return body
|
||||
|
||||
|
||||
class NotFound(AppException):
|
||||
"""资源不存在 (404)."""
|
||||
|
||||
status_code = 404
|
||||
error_code = "NOT_FOUND"
|
||||
message = "资源不存在"
|
||||
|
||||
|
||||
class Unauthorized(AppException):
|
||||
"""未认证 (401)."""
|
||||
|
||||
status_code = 401
|
||||
error_code = "UNAUTHORIZED"
|
||||
message = "未认证或凭据无效"
|
||||
|
||||
|
||||
class Forbidden(AppException):
|
||||
"""无权限 (403)."""
|
||||
|
||||
status_code = 403
|
||||
error_code = "FORBIDDEN"
|
||||
message = "无操作权限"
|
||||
|
||||
|
||||
class ValidationError(AppException):
|
||||
"""数据验证失败 (422)."""
|
||||
|
||||
status_code = 422
|
||||
error_code = "VALIDATION_ERROR"
|
||||
message = "数据验证失败"
|
||||
|
||||
|
||||
class ExternalServiceError(AppException):
|
||||
"""外部服务调用失败 (502)."""
|
||||
|
||||
status_code = 502
|
||||
error_code = "EXTERNAL_SERVICE_ERROR"
|
||||
message = "外部服务调用失败"
|
||||
|
||||
|
||||
class BusinessError(AppException):
|
||||
"""业务错误 (400)."""
|
||||
|
||||
status_code = 400
|
||||
error_code = "BUSINESS_ERROR"
|
||||
message = "业务处理失败"
|
||||
|
||||
|
||||
class Conflict(AppException):
|
||||
"""资源冲突 (409)."""
|
||||
|
||||
status_code = 409
|
||||
error_code = "CONFLICT"
|
||||
message = "资源已存在"
|
||||
|
||||
|
||||
class GatewayTimeout(AppException):
|
||||
"""上游服务响应超时 (504).
|
||||
|
||||
用于表达 "请求已发往上游但在时限内未拿到响应":区别于 502
|
||||
(上游明确报错) 和 503 (上游不可达/未就绪)。
|
||||
"""
|
||||
|
||||
status_code = 504
|
||||
error_code = "UPSTREAM_TIMEOUT"
|
||||
message = "上游服务响应超时"
|
||||
|
||||
|
||||
class ServiceUnavailable(AppException):
|
||||
"""服务暂不可用 (503).
|
||||
|
||||
用于表达 "功能依赖的配置/外部资源未就绪" 这类状态:
|
||||
资源本身合法存在但当前不可达 / 未配置;区别于 404 (资源不存在)
|
||||
和 502 (上游报错)。
|
||||
"""
|
||||
|
||||
status_code = 503
|
||||
error_code = "SERVICE_UNAVAILABLE"
|
||||
message = "服务暂不可用"
|
||||
|
||||
|
||||
class PayloadTooLarge(AppException):
|
||||
"""请求体超限 (413)."""
|
||||
|
||||
status_code = 413
|
||||
error_code = "PAYLOAD_TOO_LARGE"
|
||||
message = "压缩包超过 20MB 上限"
|
||||
|
||||
|
||||
class UploadReadTimeout(AppException):
|
||||
"""上传收流超时 (408)."""
|
||||
|
||||
status_code = 408
|
||||
error_code = "UPLOAD_READ_TIMEOUT"
|
||||
message = "上传超时, 请检查网络后重试"
|
||||
@@ -1,10 +1,10 @@
|
||||
"""领域异常。"""
|
||||
"""接口响应。"""
|
||||
|
||||
from abc import ABCMeta
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""应用异常的基类。
|
||||
class AppResponse(BaseModel):
|
||||
"""应用响应的基类。
|
||||
|
||||
带上 HTTP 状态码。
|
||||
"""
|
||||
@@ -15,7 +15,7 @@ class AppException(Exception):
|
||||
self.message = message
|
||||
|
||||
|
||||
class NotFound(AppException): # 行尾
|
||||
class NotFound(AppResponse): # 行尾
|
||||
# 说明前面的注释
|
||||
"""找不到资源。"""
|
||||
status_code = 404
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""commerce 域异常体系."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class CommerceError(Exception):
|
||||
"""commerce 域基础异常."""
|
||||
|
||||
|
||||
class ProductNotFoundError(CommerceError):
|
||||
"""商品不存在或已下架."""
|
||||
|
||||
|
||||
class OrderNotFoundError(CommerceError):
|
||||
"""订单不存在."""
|
||||
|
||||
|
||||
class InvalidOrderStateError(CommerceError):
|
||||
"""订单状态非法转换."""
|
||||
|
||||
|
||||
class PaymentVerifyError(CommerceError):
|
||||
"""支付通道验签失败."""
|
||||
|
||||
|
||||
class AmountMismatchError(CommerceError):
|
||||
"""支付金额与订单金额不匹配."""
|
||||
|
||||
|
||||
class FulfillmentError(CommerceError):
|
||||
"""fulfillment 调 billing 失败."""
|
||||
|
||||
|
||||
class RefundError(CommerceError):
|
||||
"""退款失败."""
|
||||
|
||||
|
||||
class UnsupportedPaymentMethodError(CommerceError):
|
||||
"""未配置当前 payment_method 对应的 driver. 通常意味着环境变量缺失."""
|
||||
|
||||
def __init__(self, method: str) -> None:
|
||||
super().__init__(f"unsupported payment method: {method}")
|
||||
self.method = method
|
||||
|
||||
|
||||
class OutOfStockError(CommerceError):
|
||||
"""商品库存不足, 无法下单。"""
|
||||
|
||||
|
||||
class PurchaseLimitExceededError(CommerceError):
|
||||
"""超出商品 purchase_limit_per_user 限购次数。"""
|
||||
|
||||
|
||||
class PendingOrderExistsError(CommerceError):
|
||||
"""同一用户已有相同商品的待支付订单。"""
|
||||
|
||||
|
||||
class ProductPlanMissingError(CommerceError):
|
||||
"""商品未绑定任何 billing.Plan, 无法下单/履约。"""
|
||||
|
||||
|
||||
class InstanceTypeNotFoundError(CommerceError):
|
||||
"""instance_type_id 指向的档位不存在; admin 应回 400 而不是撞 FK 500。"""
|
||||
|
||||
def __init__(self, instance_type_id: int) -> None:
|
||||
super().__init__(f"instance_type {instance_type_id} not found")
|
||||
self.instance_type_id = instance_type_id
|
||||
|
||||
|
||||
class ProductPlanMismatchError(CommerceError):
|
||||
"""商品展示档位与 Billing Plan 实际发放档位不一致."""
|
||||
|
||||
|
||||
class WechatPayUnknownError(CommerceError):
|
||||
"""微信预下单结果未知: 网络中断/超时, 不能判定远端是否已受理."""
|
||||
@@ -0,0 +1,30 @@
|
||||
/** 调接口出错。 */
|
||||
export class ApiError extends Error {
|
||||
status = 500
|
||||
// 机器可读的错误码
|
||||
static readonly kind: string = 'API_ERROR'
|
||||
|
||||
constructor(message: string, public code?: string) {
|
||||
super(message)
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return { status: this.status, code: this.code }
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends ApiError {
|
||||
status = 404 // 找不到
|
||||
}
|
||||
|
||||
class Slow extends Timeout {}
|
||||
|
||||
class Timeout extends NotFoundError {}
|
||||
|
||||
const Cancelled = class extends RangeError {}
|
||||
|
||||
export class Forbidden extends errors.HttpException<string> {}
|
||||
|
||||
export class Repository extends Base {
|
||||
status = 1
|
||||
}
|
||||
@@ -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 → 状态码'])
|
||||
})
|
||||
})
|
||||
@@ -367,15 +367,15 @@ describe('python 类', () => {
|
||||
it('classes.py:名称、说明、访问级别(同函数的下划线规则,类里的类也按它,函数里的没有)、装饰器、父类(参数列表照写的顺序,关键字参数也算)', async () => {
|
||||
const found = await facts(readFixture('classes.py'))
|
||||
expect(found[6]).toEqual({
|
||||
name: 'AppException',
|
||||
doc: '"""应用异常的基类。\n\n 带上 HTTP 状态码。\n """',
|
||||
name: 'AppResponse',
|
||||
doc: '"""应用响应的基类。\n\n 带上 HTTP 状态码。\n """',
|
||||
access: 'public',
|
||||
decorators: [],
|
||||
typeParameters: [],
|
||||
bases: [{ value: 'Exception' }],
|
||||
bases: [{ value: 'BaseModel' }],
|
||||
implements: [],
|
||||
})
|
||||
expect(found[18]).toMatchObject({ name: 'NotFound', doc: '"""找不到资源。"""', bases: [{ value: 'AppException' }] })
|
||||
expect(found[18]).toMatchObject({ name: 'NotFound', doc: '"""找不到资源。"""', bases: [{ value: 'AppResponse' }] })
|
||||
expect(found[24]).toEqual({
|
||||
name: 'Meta',
|
||||
access: 'public',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Access, BlockShape, ClassFacts, CodeBlock, EntryFacts, FunctionFacts, ImportFacts, ImportItem, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
|
||||
import type { CommentRules } from './shared'
|
||||
import type { CommentRules, ExceptionRules } from './shared'
|
||||
import * as path from 'node:path'
|
||||
import { pythonNameReader } from './pythonNames'
|
||||
import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren } from './shared'
|
||||
import { children, compoundShape, leafBlock, leafShape, listBlocks, named, sameNode, syntaxChildren, withExceptions } from './shared'
|
||||
|
||||
/** 常量、类字段只认直接写在模块 / 类主体里的赋值;这两处的声明也才有访问级别。 */
|
||||
type Scope = 'module' | 'class' | 'other'
|
||||
@@ -398,6 +398,122 @@ function tryBlocks(node: Node): CodeBlock[] {
|
||||
return blocks
|
||||
}
|
||||
|
||||
// ─── 异常类 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Python 自带的异常类(builtins 里的,含各种 Warning)。 */
|
||||
const BUILTIN_EXCEPTIONS = new Set([
|
||||
'BaseException',
|
||||
'BaseExceptionGroup',
|
||||
'GeneratorExit',
|
||||
'KeyboardInterrupt',
|
||||
'SystemExit',
|
||||
'Exception',
|
||||
'ArithmeticError',
|
||||
'FloatingPointError',
|
||||
'OverflowError',
|
||||
'ZeroDivisionError',
|
||||
'AssertionError',
|
||||
'AttributeError',
|
||||
'BufferError',
|
||||
'EOFError',
|
||||
'ExceptionGroup',
|
||||
'ImportError',
|
||||
'ModuleNotFoundError',
|
||||
'LookupError',
|
||||
'IndexError',
|
||||
'KeyError',
|
||||
'MemoryError',
|
||||
'NameError',
|
||||
'UnboundLocalError',
|
||||
'OSError',
|
||||
'EnvironmentError',
|
||||
'IOError',
|
||||
'WindowsError',
|
||||
'BlockingIOError',
|
||||
'ChildProcessError',
|
||||
'ConnectionError',
|
||||
'BrokenPipeError',
|
||||
'ConnectionAbortedError',
|
||||
'ConnectionRefusedError',
|
||||
'ConnectionResetError',
|
||||
'FileExistsError',
|
||||
'FileNotFoundError',
|
||||
'InterruptedError',
|
||||
'IsADirectoryError',
|
||||
'NotADirectoryError',
|
||||
'PermissionError',
|
||||
'ProcessLookupError',
|
||||
'TimeoutError',
|
||||
'ReferenceError',
|
||||
'RuntimeError',
|
||||
'NotImplementedError',
|
||||
'PythonFinalizationError',
|
||||
'RecursionError',
|
||||
'StopAsyncIteration',
|
||||
'StopIteration',
|
||||
'SyntaxError',
|
||||
'IndentationError',
|
||||
'TabError',
|
||||
'SystemError',
|
||||
'TypeError',
|
||||
'ValueError',
|
||||
'UnicodeError',
|
||||
'UnicodeDecodeError',
|
||||
'UnicodeEncodeError',
|
||||
'UnicodeTranslateError',
|
||||
'Warning',
|
||||
'BytesWarning',
|
||||
'DeprecationWarning',
|
||||
'EncodingWarning',
|
||||
'FutureWarning',
|
||||
'ImportWarning',
|
||||
'PendingDeprecationWarning',
|
||||
'ResourceWarning',
|
||||
'RuntimeWarning',
|
||||
'SyntaxWarning',
|
||||
'UnicodeWarning',
|
||||
'UserWarning',
|
||||
])
|
||||
/** PEP 8 的命名习惯:异常类名以 Error 结尾(别的文件里的 `AppException`、`*Warning` 也常见)。 */
|
||||
const EXCEPTION_NAME = /(?:Error|Exception|Warning)$/
|
||||
|
||||
/** 父类写的类名:`X`、`pkg.X` 取 X,泛型 `Base[T]` 取 Base 的;关键字参数、`*bases`、调用没有。 */
|
||||
function className(node: Node | null): string | undefined {
|
||||
switch (node?.type) {
|
||||
case 'identifier':
|
||||
return node.text
|
||||
case 'attribute':
|
||||
return node.childForFieldName('attribute')?.text
|
||||
case 'subscript':
|
||||
return className(node.childForFieldName('value'))
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 类属性:类主体里给一个名字赋值的语句(`x = 1`、`x: int = 1`、只写注解的 `x: int`);连着赋值(`a = b = 1`)、解构赋值不算。
|
||||
* 构造函数是 `__init__`,参数去掉第一个(self;`*args` 这类不是 self 的不去)。
|
||||
*/
|
||||
const PYTHON_EXCEPTIONS: ExceptionRules = {
|
||||
baseName: base => className(base.value),
|
||||
isExceptionName: name => BUILTIN_EXCEPTIONS.has(name) || EXCEPTION_NAME.test(name),
|
||||
field: (block) => {
|
||||
const assignment = block.kind === 'field' || block.kind === 'variable' ? assignmentIn(block.node) : undefined
|
||||
const name = assignment?.childForFieldName('left')
|
||||
const value = assignment?.childForFieldName('right')
|
||||
if (!assignment || name?.type !== 'identifier' || value?.type === 'assignment')
|
||||
return undefined
|
||||
return { name, type: assignment.childForFieldName('type') ?? undefined, value: value ?? undefined }
|
||||
},
|
||||
constructorParameters: (block) => {
|
||||
if (block.kind !== 'function' || block.facts.name?.text !== '__init__')
|
||||
return undefined
|
||||
const [receiver, ...rest] = block.facts.parameters
|
||||
return receiver?.name.type === 'identifier' ? rest : block.facts.parameters
|
||||
},
|
||||
}
|
||||
|
||||
// ─── 源码着色 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEYWORDS = new Set(['def', 'class', 'async', 'lambda', 'global', 'nonlocal', 'del'])
|
||||
@@ -484,7 +600,7 @@ export function createPythonAdapter(wasmDir: string): LanguageAdapter {
|
||||
id: 'python',
|
||||
languageIds: ['python'],
|
||||
grammarPath: () => path.join(wasmDir, 'tree-sitter-python.wasm'),
|
||||
toBlocks: root => statements(named(root), 'module', -1),
|
||||
toBlocks: root => withExceptions(statements(named(root), 'module', -1), PYTHON_EXCEPTIONS),
|
||||
tokenRole: pythonTokenRole,
|
||||
iteration: pythonIteration,
|
||||
displayNames: pythonNameReader,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BlockShape, CodeBlock, DocumentedBlock, Node, SourceSpan, TextKind } from '../contract'
|
||||
import type { BaseFacts, BlockShape, ClassBlock, ClassFacts, CodeBlock, DocumentedBlock, ExceptionBlock, FieldFacts, Node, ParameterFacts, SourceSpan, TextKind } from '../contract'
|
||||
|
||||
/** 语言插件共用的建树工具:取子节点、算头部、把一串语句切成块。 */
|
||||
|
||||
@@ -164,3 +164,128 @@ export function listBlocks(
|
||||
export function sameNode(a: Node | null | undefined, b: Node): boolean {
|
||||
return !!a && a.startIndex === b.startIndex && a.endIndex === b.endIndex && a.type === b.type
|
||||
}
|
||||
|
||||
// ─── 异常类 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 语言怎么认异常类、类主体里哪些子块是类属性、哪个是构造函数。 */
|
||||
export interface ExceptionRules {
|
||||
/** 父类一项写的类名(点分写法取最后一节);关键字参数、展开、调用这类不是类名的项没有。 */
|
||||
baseName: (base: BaseFacts) => string | undefined
|
||||
/** 光看名字就认得是异常类:语言自带的异常类,或按命名习惯以 Error 这类后缀结尾。 */
|
||||
isExceptionName: (name: string) => boolean
|
||||
/** 类主体里的子块是类属性时它的名字、类型、值;不是时没有。 */
|
||||
field: (block: CodeBlock) => Omit<FieldFacts, 'docs'> | undefined
|
||||
/** 子块是构造函数时它的参数(Python 去掉 self);不是时没有。 */
|
||||
constructorParameters: (block: CodeBlock) => ParameterFacts[] | undefined
|
||||
}
|
||||
|
||||
function classBlocks(blocks: readonly CodeBlock[]): ClassBlock[] {
|
||||
return blocks.flatMap(block => [...(block.kind === 'class' ? [block] : []), ...classBlocks(block.children)])
|
||||
}
|
||||
|
||||
/** 紧挨在 child 前面的注释块(连着几块都算,中间没隔别的子块),照源码顺序:讲的是它下面的这个 child。 */
|
||||
function leadingComments(children: readonly CodeBlock[], child: CodeBlock): CodeBlock[] {
|
||||
const at = children.indexOf(child)
|
||||
let from = at
|
||||
while (from > 0 && children[from - 1].kind === 'comment')
|
||||
from--
|
||||
return children.slice(from, at)
|
||||
}
|
||||
|
||||
/** 注释块里的各条注释(几条行注释连成的一块是好几个节点);Python 当注释用的字符串是那个字符串。 */
|
||||
function commentNodes(block: CodeBlock): Node[] {
|
||||
if (block.node.type !== 'comment')
|
||||
return syntaxChildren(block.node).slice(0, 1)
|
||||
const nodes: Node[] = []
|
||||
for (let node: Node | null = block.node; node?.type === 'comment' && node.endIndex <= block.header.endIndex; node = node.nextSibling)
|
||||
nodes.push(node)
|
||||
return nodes
|
||||
}
|
||||
|
||||
/** 块自己的说明节点:属性表块的 facts.doc,一行译文块的 doc(Python 字段的属性 docstring)。 */
|
||||
function ownDoc(block: CodeBlock): Node | undefined {
|
||||
if (!('facts' in block))
|
||||
return block.doc
|
||||
return block.kind === 'import' ? undefined : block.facts.doc
|
||||
}
|
||||
|
||||
/** 类属性的说明,照源码顺序:紧挨在它上面的注释块、它这一行行尾的注释、Python 紧跟在后面的属性 docstring。 */
|
||||
function fieldDocs(children: readonly CodeBlock[], block: CodeBlock): Node[] {
|
||||
const trailing = block.node.nextNamedSibling
|
||||
const doc = ownDoc(block)
|
||||
return [
|
||||
...leadingComments(children, block).flatMap(commentNodes),
|
||||
...(trailing?.type === 'comment' && trailing.startPosition.row === block.node.endPosition.row ? [trailing] : []),
|
||||
...(doc ? [doc] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 类块换成异常块:类属性连同紧挨在它前面的注释块并进属性表,构造函数(写了几个取最后一个,重载的实现写在最后)的参数进属性表,
|
||||
* 留下的子块(方法、构造函数本身、别的语句)不管写在哪都照源码顺序留作子块;头部是整个类(到它连同各子块的最后一行),
|
||||
* 「编辑」时整段一起改,webview 在它编辑时不画留下的子块。
|
||||
*/
|
||||
function exceptionBlock(block: ClassBlock, rules: ExceptionRules): ExceptionBlock {
|
||||
const { children, facts } = block
|
||||
const fields: FieldFacts[] = []
|
||||
const members: CodeBlock[] = []
|
||||
let parameters: ParameterFacts[] | undefined
|
||||
for (const child of children) {
|
||||
const field = rules.field(child)
|
||||
if (field) {
|
||||
fields.push({ ...field, docs: fieldDocs(children, child) })
|
||||
members.push(child)
|
||||
}
|
||||
parameters = rules.constructorParameters(child) ?? parameters
|
||||
}
|
||||
const absorbed = new Set(members.flatMap(member => [...leadingComments(children, member), member]))
|
||||
const ends = [block.header, nodeSpan(block.node), ...children.flatMap(child => [child.header, nodeSpan(child.node)])]
|
||||
const last = ends.reduce((end, span) => span.endIndex > end.endIndex ? span : end)
|
||||
return {
|
||||
kind: 'exception',
|
||||
node: block.node,
|
||||
header: { ...block.header, endIndex: last.endIndex, endRow: last.endRow },
|
||||
children: children.filter(child => !absorbed.has(child)),
|
||||
...(block.attributes && { attributes: block.attributes }),
|
||||
facts: {
|
||||
name: facts.name,
|
||||
doc: facts.doc,
|
||||
access: facts.access,
|
||||
decorators: facts.decorators,
|
||||
bases: facts.bases,
|
||||
fields,
|
||||
...(parameters && { parameters }),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 认出异常类,把它们的类块换成异常块(见 exceptionBlock)。父类里有一项是异常类就是异常类:光看名字认得出(isExceptionName),
|
||||
* 或是同一文件里(不论写在前面还是后面、嵌套在哪)认出来的异常类,一层层往下传。父类从别的文件导入、名字又不合命名习惯的认不出来。
|
||||
*/
|
||||
export function withExceptions(blocks: CodeBlock[], rules: ExceptionRules): CodeBlock[] {
|
||||
const classes = classBlocks(blocks)
|
||||
const local = new Set<string>()
|
||||
const isException = (facts: ClassFacts): boolean => facts.bases.some((base) => {
|
||||
const name = rules.baseName(base)
|
||||
return name !== undefined && (rules.isExceptionName(name) || local.has(name))
|
||||
})
|
||||
let grew = true
|
||||
while (grew) {
|
||||
grew = false
|
||||
for (const { facts } of classes) {
|
||||
const name = facts.name?.text
|
||||
if (name !== undefined && !local.has(name) && isException(facts)) {
|
||||
local.add(name)
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
const convert = (list: CodeBlock[]): CodeBlock[] => list.map((block) => {
|
||||
const children = convert(block.children)
|
||||
const same = children.every((child, i) => child === block.children[i])
|
||||
const next: CodeBlock = same ? block : { ...block, children }
|
||||
return next.kind === 'class' && isException(next.facts) ? exceptionBlock(next, rules) : next
|
||||
})
|
||||
return convert(blocks)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Access, BaseFacts, BlockShape, ClassFacts, CodeBlock, DeclaratorFacts, EntryFacts, EnumMemberFacts, FunctionFacts, ImportFacts, ImportItem, LanguageAdapter, Node, ParameterFacts, TextKind, TokenRole } from '../contract'
|
||||
import type { CommentRules } from './shared'
|
||||
import type { CommentRules, ExceptionRules } from './shared'
|
||||
import * as path from 'node:path'
|
||||
import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren } from './shared'
|
||||
import { children, compoundShape, extendHeader, headerSpan, leafBlock, leafShape, listBlocks, named, nodeSpan, sameNode, syntaxChildren, withExceptions } from './shared'
|
||||
import { typescriptNameReader } from './typescriptNames'
|
||||
|
||||
const GRAMMARS: Record<string, string> = {
|
||||
@@ -476,6 +476,40 @@ function blockReader(typed: boolean): (root: Node) => CodeBlock[] {
|
||||
return root => statements(named(root), true, -1)
|
||||
}
|
||||
|
||||
// ─── 异常类 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 自带的错误类(Error、TypeError、RangeError、AggregateError……)名字都以 Error 结尾,DOMException 以 Exception 结尾;自己写的照这个习惯起名。 */
|
||||
const EXCEPTION_NAME = /(?:Error|Exception)$/
|
||||
/** 类字段:TypeScript 的 public_field_definition(名字在 name),JavaScript 的 field_definition(名字在 property)。 */
|
||||
const FIELDS = new Set(['public_field_definition', 'field_definition'])
|
||||
|
||||
/** extends 后面写的类名:`X`、`ns.X` 取 X;调用(`Mixin(Base)`)这类没有。 */
|
||||
function className(node: Node): string | undefined {
|
||||
switch (node.type) {
|
||||
case 'identifier':
|
||||
case 'type_identifier':
|
||||
return node.text
|
||||
case 'member_expression':
|
||||
return node.childForFieldName('property')?.text
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** 类属性是类字段(含 static,值不是带语句块主体的函数、类表达式的:那些照常是方法、类块);构造函数是 `constructor`。 */
|
||||
const TYPESCRIPT_EXCEPTIONS: ExceptionRules = {
|
||||
baseName: base => className(base.value),
|
||||
isExceptionName: name => EXCEPTION_NAME.test(name),
|
||||
field: (block) => {
|
||||
const member = block.node
|
||||
const name = block.kind === 'field' && FIELDS.has(member.type) ? member.childForFieldName('name') ?? member.childForFieldName('property') : null
|
||||
if (!name)
|
||||
return undefined
|
||||
return { name, type: annotatedType(member.childForFieldName('type')), value: member.childForFieldName('value') ?? undefined }
|
||||
},
|
||||
constructorParameters: block => block.kind === 'function' && block.facts.name?.text === 'constructor' ? block.facts.parameters : undefined,
|
||||
}
|
||||
|
||||
// ─── 源码着色 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 流程关键字;其余字母关键字都算声明关键字(storage)。 */
|
||||
@@ -584,7 +618,7 @@ export function createTypeScriptAdapter(wasmDir: string): LanguageAdapter {
|
||||
throw new Error(`TypeScript 语言插件不支持 ${languageId}`)
|
||||
return path.join(wasmDir, file)
|
||||
},
|
||||
toBlocks: (root, _source, languageId) => blockReader(TYPED_LANGUAGES.has(languageId))(root),
|
||||
toBlocks: (root, _source, languageId) => withExceptions(blockReader(TYPED_LANGUAGES.has(languageId))(root), TYPESCRIPT_EXCEPTIONS),
|
||||
tokenRole: typescriptTokenRole,
|
||||
displayNames: typescriptNameReader,
|
||||
}
|
||||
|
||||
@@ -725,8 +725,8 @@ describe('属性表', () => {
|
||||
expect(tsRows[32]).toEqual(['名称: Plain', '访问级别: 私有', '抽象: 否', '装饰器: @sealed'])
|
||||
|
||||
const pyRows = rowTexts(await describeAll(py, 'python', pythonZhTranslator, readFixture('classes.py')))
|
||||
expect(pyRows[6]).toEqual(['名称: AppException', '说明: 应用异常的基类。\n\n带上 HTTP 状态码。', '访问级别: 公共', '父类: Exception'])
|
||||
expect(pyRows[18]).toEqual(['名称: NotFound', '说明: 找不到资源。', '访问级别: 公共', '父类: AppException'])
|
||||
expect(pyRows[6]).toEqual(['名称: AppResponse', '说明: 应用响应的基类。\n\n带上 HTTP 状态码。', '访问级别: 公共', '父类: BaseModel'])
|
||||
expect(pyRows[18]).toEqual(['名称: NotFound', '说明: 找不到资源。', '访问级别: 公共', '父类: AppResponse'])
|
||||
expect(pyRows[24]).toEqual(['名称: Meta', '访问级别: 公共', '装饰器: @dataclass(frozen=True)', '父类: Base, metaclass=ABCMeta'])
|
||||
expect(pyRows[29]).toEqual(['名称: _Hidden', '访问级别: 私有'])
|
||||
expect(pyRows[38]).toEqual(['名称: Local', '说明: 局部类。'])
|
||||
@@ -987,11 +987,11 @@ describe('源码下标(跳到定义用)', () => {
|
||||
expect(linked(valueOf(found, '实现的接口'))).toEqual([['Loader', at('Loader')], ['K', at('K>,')], ['Disposable', at('Disposable')]])
|
||||
expect(found.filter(row => row.defines).map(row => [row.label, source.slice(row.defines?.startIndex, row.defines?.endIndex)])).toEqual([['类型参数', 'K, T extends Entity']])
|
||||
|
||||
const pySource = 'class NotFound(errors.AppException, metaclass=ABCMeta):\n pass'
|
||||
const pySource = 'class NotFound(models.AppResponse, metaclass=ABCMeta):\n pass'
|
||||
const pyRows = await describeAll(py, 'python', pythonZhTranslator, pySource)
|
||||
expect(linked(valueOf(pyRows.get(1), '父类'))).toEqual([
|
||||
['errors', pySource.indexOf('errors')],
|
||||
['AppException', pySource.indexOf('AppException')],
|
||||
['models', pySource.indexOf('models')],
|
||||
['AppResponse', pySource.indexOf('AppResponse')],
|
||||
['metaclass', pySource.indexOf('metaclass')],
|
||||
['ABCMeta', pySource.indexOf('ABCMeta')],
|
||||
])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
|
||||
import type { Access, AttributeNames, BaseFacts, BlockKind, ClassFacts, CommonAttribute, Defines, EntryFacts, EnumFacts, ExceptionFacts, FactBlock, FunctionFacts, ImportFacts, ImportItem, Inline, IterationClause, IterationFacts, IterationResult, LanguageAdapter, Markdown, Node, ParameterFacts, PreviewText, PropertyItem, PropertyRow, Segment, SegmentGroup, SourceDocument, SourceRange, SourceSpan, TextBlock, TokenRole, Translator, VariableBlock } from '../contract'
|
||||
import { commonIndent, splitLines } from '../editText'
|
||||
|
||||
/** 按所在语法节点区分译法的关键字,例如 Python 的 else:if 后「否则」,for / while 后「未中断时」。 */
|
||||
@@ -148,6 +148,7 @@ const KIND_ZH: Record<BlockKind, string> = {
|
||||
function: '函数',
|
||||
class: '类',
|
||||
enum: '枚举',
|
||||
exception: '异常',
|
||||
type: '类型',
|
||||
field: '字段',
|
||||
const: '常量',
|
||||
@@ -451,6 +452,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return classRows(block.facts, phrases)
|
||||
case 'enum':
|
||||
return enumRows(block.facts, phrases)
|
||||
case 'exception':
|
||||
return exceptionRows(block.facts, phrases)
|
||||
case 'import':
|
||||
return importRows(block.facts, phrases)
|
||||
default:
|
||||
@@ -599,23 +602,28 @@ function functionRows(facts: FunctionFacts, say: Phrases): PropertyRow[] {
|
||||
...accessRows(facts.access),
|
||||
...flagRows('async', facts.async),
|
||||
...decoratorRows(facts.decorators, say),
|
||||
{
|
||||
label: '输入参数',
|
||||
value: count(facts.parameters.length),
|
||||
columns: ['名称', '类型', '默认值'],
|
||||
items: facts.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) : [],
|
||||
],
|
||||
...defining(param.name),
|
||||
})),
|
||||
},
|
||||
parameterRow('输入参数', facts.parameters, say),
|
||||
{ label: '输出参数', value: facts.returnType ? say.translated(facts.returnType) : [hint('未标注')] },
|
||||
]
|
||||
}
|
||||
|
||||
/** 参数表:列名是名称 / 类型 / 默认值,每个参数一行,讲的定义是参数名;没有默认值的格子留空。 */
|
||||
function parameterRow(label: string, parameters: ParameterFacts[], say: Phrases): PropertyRow {
|
||||
return {
|
||||
label,
|
||||
value: count(parameters.length),
|
||||
columns: ['名称', '类型', '默认值'],
|
||||
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) : [],
|
||||
],
|
||||
...defining(param.name),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 类:名称 / 说明 / 访问级别 / 抽象(只有分这个的语言才有)/ 装饰器 / 类型参数 / 父类 / 实现的接口,
|
||||
* 说明及以后各行有才显示;类型参数、父类、实现的接口各是一行,多项用逗号隔开。类型参数那一行讲的是这几个泛型参数的定义。
|
||||
@@ -646,14 +654,14 @@ function baseText(base: BaseFacts, say: Phrases): Inline[] {
|
||||
* 说明是成员的注释、JSDoc、属性 docstring 的原文用空格连起来,一个成员都没有说明时不给这一列。
|
||||
*/
|
||||
function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] {
|
||||
const docs = facts.members.map(member => member.docs.map(node => say.doc(node)?.markdown).filter(Boolean).join(' '))
|
||||
const docs = facts.members.map(member => docsText(member.docs, say))
|
||||
const described = docs.some(Boolean)
|
||||
return [
|
||||
{ label: '名称', value: say.name(facts.name, 'type') },
|
||||
...docRows(facts.doc, say),
|
||||
...accessRows(facts.access),
|
||||
...decoratorRows(facts.decorators, say),
|
||||
...(facts.bases.length > 0 ? [{ label: '继承', value: joined(facts.bases.map(base => baseText(base, say)), ', ') }] : []),
|
||||
...baseRows(facts.bases, say),
|
||||
{
|
||||
label: '成员',
|
||||
value: count(facts.members.length),
|
||||
@@ -662,7 +670,7 @@ function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] {
|
||||
cells: [
|
||||
say.name(member.name, 'variable'),
|
||||
member.value ? say.translated(member.value) : [hint(member.computed === undefined ? '自动' : String(member.computed))],
|
||||
...(described ? [docs[i] ? [segment(docs[i], 'comment', undefined)] : []] : []),
|
||||
...(described ? [docCell(docs[i])] : []),
|
||||
],
|
||||
...defining(member.name),
|
||||
})),
|
||||
@@ -670,6 +678,54 @@ function enumRows(facts: EnumFacts, say: Phrases): PropertyRow[] {
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 异常(Python、TypeScript 同一张表):名称 / 说明 / 访问级别 / 装饰器 / 继承(照写的父类),说明及以后有才显示;
|
||||
* 类属性收成一张表:属性 | 类型 | 值 | 说明,一个属性都没写类型时没有「类型」列、都没有说明时没有「说明」列,没写的格子留空;
|
||||
* 构造参数同函数的输入参数表。没有类属性、没写构造函数时没有这两行。
|
||||
*/
|
||||
function exceptionRows(facts: ExceptionFacts, say: Phrases): PropertyRow[] {
|
||||
const docs = facts.fields.map(field => docsText(field.docs, say))
|
||||
const described = docs.some(Boolean)
|
||||
const typed = facts.fields.some(field => field.type)
|
||||
const fields: PropertyRow = {
|
||||
label: '属性',
|
||||
value: count(facts.fields.length),
|
||||
columns: ['属性', ...(typed ? ['类型'] : []), '值', ...(described ? ['说明'] : [])],
|
||||
items: facts.fields.map((field, i) => ({
|
||||
cells: [
|
||||
say.name(field.name, 'variable'),
|
||||
...(typed ? [field.type ? say.translated(field.type) : []] : []),
|
||||
field.value ? say.translated(field.value) : [],
|
||||
...(described ? [docCell(docs[i])] : []),
|
||||
],
|
||||
...defining(field.name),
|
||||
})),
|
||||
}
|
||||
return [
|
||||
{ label: '名称', value: facts.name ? say.name(facts.name, 'type') : [hint('匿名')] },
|
||||
...docRows(facts.doc, say),
|
||||
...accessRows(facts.access),
|
||||
...decoratorRows(facts.decorators, say),
|
||||
...baseRows(facts.bases, say),
|
||||
...(facts.fields.length > 0 ? [fields] : []),
|
||||
...(facts.parameters ? [parameterRow('构造参数', facts.parameters, say)] : []),
|
||||
]
|
||||
}
|
||||
|
||||
/** 继承:照写的父类,逗号隔开;没有父类时没有这一行。 */
|
||||
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(' ')
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user