✨ feat(vscode): 代码阅读的调用嵌套:实参的值又是跨多行的调用时在值那一格里写它的一行、套它的实参表,一层层往下套;埋在式子里后面还跟着东西的调用括起来
This commit is contained in:
1 parent
dc100692b3
commit
285d711740
16 files changed
+471
-55
No files matched your search
@@ -344,6 +344,11 @@ export interface PropertyItem extends Defines {
|
||||
cells: Inline[][]
|
||||
/** 展开在这个子行下面的下一层子行(字典 / 对象的值又是字典 / 对象时,它的各项);一层层可以一直往下套。 */
|
||||
items?: PropertyItem[]
|
||||
/**
|
||||
* 排在这一行最后一格里、那格文字下面的表:实参表里一个实参的值里有跨几行写的调用时,是那个调用的实参表,
|
||||
* 表里的行又可以带 table,一层层往下套。总是展开的。
|
||||
*/
|
||||
table?: PreviewTable
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# luffy 的两段节选:workspace_repo/service.py 的 WorkspaceRepoService.__init__、commerce/tasks.py 的 _async_check_pending_refunds
|
||||
import base64
|
||||
|
||||
|
||||
class WorkspaceRepoService:
|
||||
def __init__(
|
||||
self,
|
||||
client: GiteaAdminClient,
|
||||
*,
|
||||
public_url: str,
|
||||
internal_url: str,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._public_url = public_url.rstrip("/")
|
||||
self._internal_url = internal_url.rstrip("/")
|
||||
self._admin_basic = base64.b64encode(
|
||||
f"{admin_user}:{admin_password}".encode()
|
||||
).decode()
|
||||
|
||||
|
||||
|
||||
async def _async_check_pending_refunds() -> None:
|
||||
"""超时未回调的退款 cron 兜底: 主动查 → 调 reversal_hook → 推进 REFUNDED.
|
||||
|
||||
与异步回调 ``handle_alipay_refund_notify`` 共用 ``_finalize_refund``
|
||||
helper, 保证账本侧 reversal 与订单状态机推进始终一对一. 此前仅写
|
||||
``refund_trade_no`` 不调 reversal_hook, 会让"用户钱已退 + 账本 grant
|
||||
仍在"成为双花漏洞.
|
||||
|
||||
金额来源说明: 当前 ``RefundQueryResult`` 不带 alipay 实际退款金额, cron
|
||||
无法判断 unused 模式下的部分退款. 折中策略: 按订单金额 (snapshot) 走
|
||||
``_finalize_refund``, full 模式严格相等会通过, unused 模式
|
||||
``0 < refunded <= rmb_amount_minor`` 也通过. 等 driver 后续暴露
|
||||
``refund_amount_minor`` 字段再切换.
|
||||
"""
|
||||
from luffy_agent.commerce.config import get_commerce_config
|
||||
from luffy_agent.commerce.exceptions import UnsupportedPaymentMethodError
|
||||
from luffy_agent.commerce.orders.repositories import order_repository
|
||||
from luffy_agent.commerce.payments.service import _finalize_refund
|
||||
|
||||
config = get_commerce_config()
|
||||
factory = get_pg_session_factory()
|
||||
checked = 0
|
||||
confirmed = 0
|
||||
|
||||
async with factory() as db:
|
||||
orders = await order_repository.list_pending_refunds(
|
||||
db, age_hours=24, limit=100
|
||||
)
|
||||
for order in orders:
|
||||
checked += 1
|
||||
try:
|
||||
result = await config.get_payment_driver(
|
||||
order.payment_method
|
||||
).query_refund_status(
|
||||
out_trade_no=str(order.id),
|
||||
out_request_no=f"{order.id}-refund",
|
||||
)
|
||||
except UnsupportedPaymentMethodError:
|
||||
logger.warning(
|
||||
"check_pending_refunds: skip order %s, no driver for %s",
|
||||
order.id,
|
||||
order.payment_method,
|
||||
)
|
||||
continue
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception(
|
||||
"query_refund_status failed for order %s: %s", order.id, exc
|
||||
)
|
||||
continue
|
||||
|
||||
if not result.refunded:
|
||||
continue
|
||||
|
||||
# 已加 with_for_update(skip_locked) 行锁, 与 refund-notify 互斥.
|
||||
logger.warning("refund confirmed via active query, order=%s", order.id)
|
||||
confirmed += 1
|
||||
await _finalize_refund(
|
||||
db,
|
||||
order=order,
|
||||
refund_trade_no=result.refund_trade_no,
|
||||
refunded_amount_minor=order.rmb_amount_minor,
|
||||
source="check-refund-cron",
|
||||
)
|
||||
logger.info(
|
||||
@@ -0,0 +1,18 @@
|
||||
// 本仓库 vscode/src/extension.ts 的 activate 节选
|
||||
export function activate(context: ExtensionContext): SuperpowersApi {
|
||||
context.subscriptions.push(
|
||||
treeView,
|
||||
commands.registerCommand('superpowers.openKanban', () => {
|
||||
KanbanWebviewPanel.createOrShow(context)
|
||||
}),
|
||||
commands.registerCommand('superpowers.refresh', () => {
|
||||
KanbanWebviewPanel.refresh()
|
||||
}),
|
||||
commands.registerCommand('superpowers.setGiteaToken', () => {
|
||||
KanbanWebviewPanel.createOrShow(context)
|
||||
KanbanWebviewPanel.requestEditAuth()
|
||||
}),
|
||||
)
|
||||
|
||||
return { codeReader: registerCodeReader(context) }
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"""支付子域管理后台路由.
|
||||
|
||||
仅含管理后台用的支付流水列表/详情. URL prefix 与 tags 与拆分前完全一致
|
||||
(``/admin/commerce/payments`` + ``commerce-admin-payments``), 保证对外契约不变.
|
||||
共享 schema ``OrderResponse`` 与 repository 从父子域绝对 import.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Literal
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from luffy_agent.commerce.bootstrap import _secret_text, jsapi_ready
|
||||
from luffy_agent.commerce.orders.console.schemas import (
|
||||
AdminOrderRow,
|
||||
to_admin_order_row,
|
||||
to_admin_order_rows,
|
||||
)
|
||||
from luffy_agent.commerce.orders.repositories import order_repository
|
||||
from luffy_agent.commerce.payments.wechat_jsapi import repositories as jsapi_repo
|
||||
from luffy_agent.commerce.payments.wechat_jsapi import service as jsapi_service
|
||||
from luffy_agent.core.db import get_db
|
||||
from luffy_agent.core.exceptions import AppException
|
||||
from luffy_agent.core.params import Int4Query
|
||||
from luffy_agent.core.security.fernet import FernetSecretService, MasterKeyMissingError
|
||||
from luffy_agent.core.setting import get_settings
|
||||
from luffy_agent.core.setting.schemas import Settings
|
||||
from luffy_agent.user.auth.deps import ConsolePrincipal, require_permission
|
||||
from luffy_agent.user.console.rbac.service import (
|
||||
bind_console_audit_request,
|
||||
console_rbac_service,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin/commerce/payments", tags=["commerce-admin-payments"])
|
||||
|
||||
|
||||
class CredentialPresence(BaseModel):
|
||||
mp_app_secret: bool
|
||||
wechat_pay_api_v3_key: bool
|
||||
wechat_pay_private_key: bool
|
||||
jsapi_intent_master_key: bool
|
||||
@router.get("/capability", response_model=PaymentCapabilityDiagnostic)
|
||||
async def get_payment_capability(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: object = Depends(require_permission("commerce.payments:view")),
|
||||
) -> PaymentCapabilityDiagnostic:
|
||||
"""admin: JSAPI/Native/H5 只读诊断. 不含密钥、openid、签名或密文."""
|
||||
settings = get_settings()
|
||||
oauth_host = urlparse(settings.WECHAT_MP_OAUTH_REDIRECT_URI).hostname or ""
|
||||
allowed = list(settings.PAYMENT_RETURN_ALLOWED_ORIGINS)
|
||||
request_origin = request.headers.get("origin")
|
||||
pay_origin = request_origin if request_origin in allowed else None
|
||||
hint_origins = [pay_origin] if pay_origin else allowed
|
||||
private_key_path = settings.WECHAT_PAY_PRIVATE_KEY_PATH
|
||||
count, earliest = await jsapi_repo.recovery_required_summary(db)
|
||||
return PaymentCapabilityDiagnostic(
|
||||
native_enabled=bool(settings.WECHAT_PAY_MCH_ID),
|
||||
h5_enabled=settings.WECHAT_H5_ENABLED,
|
||||
jsapi_enabled=settings.WECHAT_JSAPI_ENABLED,
|
||||
jsapi_ready=jsapi_ready(settings),
|
||||
pay_app_id_masked=_mask_app_id(settings.WECHAT_PAY_APP_ID),
|
||||
mp_app_id_masked=_mask_app_id(settings.WECHAT_MP_APP_ID),
|
||||
credentials_present=CredentialPresence(
|
||||
mp_app_secret=bool(_secret_text(settings.WECHAT_MP_APP_SECRET)),
|
||||
wechat_pay_api_v3_key=bool(settings.WECHAT_PAY_API_V3_KEY),
|
||||
wechat_pay_private_key=bool(private_key_path)
|
||||
and Path(private_key_path).is_file(),
|
||||
jsapi_intent_master_key=_intent_key_present(settings),
|
||||
),
|
||||
oauth_callback_host=oauth_host,
|
||||
pay_origin=pay_origin,
|
||||
allowed_pay_origins=allowed,
|
||||
jsapi_auth_dir_hint=[f"{origin}/" for origin in hint_origins if origin],
|
||||
domain_whitelist="unknown_manual_check",
|
||||
merchant_binding="unknown_manual_check",
|
||||
recovery_required_count=count,
|
||||
recovery_required_earliest=earliest,
|
||||
)
|
||||
@@ -96,11 +96,14 @@ describe('中文名落到哪些名字上', () => {
|
||||
expect(rendered.get(at(ROUTES, 'delete_item'))).toBe('delete_item')
|
||||
})
|
||||
|
||||
it('renderedNames:一行译文下面那张表(跨几行写的调用的实参)里带下标的名字也算', async () => {
|
||||
const source = 'def f(user):\n return Detail(\n id=user.id,\n )\n'
|
||||
it('renderedNames:一行译文下面那张表(跨几行写的调用的实参)、值那一格里套着的表里带下标的名字也算', async () => {
|
||||
const source = 'def f(user):\n return Detail(\n id=user.id,\n owner=Owner(\n name=user.name,\n ),\n )\n'
|
||||
const rendered = renderedNames((await read(source)).blocks)
|
||||
expect(rendered.get(at(source, 'id='))).toBe('id')
|
||||
expect(rendered.get(at(source, 'user.id'))).toBe('user')
|
||||
expect(rendered.get(at(source, 'Owner('))).toBe('Owner')
|
||||
expect(rendered.get(at(source, 'name='))).toBe('name')
|
||||
expect(rendered.get(at(source, 'user.name'))).toBe('user')
|
||||
})
|
||||
|
||||
it('本文件定义的:定义处问各处出现,落在画出来的名字上的都换,定义处本身标 definition;长度对不上、没画出来的不换', async () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export function renderedNames(blocks: readonly BlockView[]): Map<number, string>
|
||||
for (const item of list ?? []) {
|
||||
item.cells.forEach(inline)
|
||||
items(item.items)
|
||||
items(item.table?.items)
|
||||
}
|
||||
}
|
||||
const visit = (list: readonly BlockView[]): void => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Tree } from '@vscode/tree-sitter-wasm'
|
||||
import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewText, PropertyItem, PropertyRow, Segment } from './contract'
|
||||
import type { BlockKind, CodeBlock, Inline, LanguageAdapter, PreviewTable, PreviewText, PropertyItem, PropertyRow, Segment } from './contract'
|
||||
import type { FileEvent, IndexHost, WorkspaceFile } from './workspaceIndex'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
@@ -62,15 +62,25 @@ export function previewText(text: PreviewText): string {
|
||||
return 'markdown' in text ? text.markdown : segmentText(text)
|
||||
}
|
||||
|
||||
/** 属性表写成文字:每行 `名称: 值`,展开的子行比上一层多缩进两格、各列用 ` | ` 隔开。 */
|
||||
export function propertyLines(rows: PropertyRow[]): string[] {
|
||||
const itemLines = (items: PropertyItem[] | undefined, indent: string): string[] => (items ?? []).flatMap(item => [
|
||||
/** 子行写成文字:各列用 ` | ` 隔开,下一层子行、排在最后一格里的表(PropertyItem.table)的各行比这一层多缩进两格。 */
|
||||
function itemLines(items: PropertyItem[] | undefined, indent: string): string[] {
|
||||
return (items ?? []).flatMap(item => [
|
||||
`${indent}${item.cells.map(segmentText).join(' | ')}`,
|
||||
...itemLines(item.items, `${indent} `),
|
||||
...itemLines(item.table?.items, `${indent} `),
|
||||
])
|
||||
}
|
||||
|
||||
/** 属性表写成文字:每行 `名称: 值`,展开的子行比上一层多缩进两格(见 itemLines)。 */
|
||||
export function propertyLines(rows: PropertyRow[]): string[] {
|
||||
return rows.flatMap(row => [`${row.label}: ${previewText(row.value)}`, ...itemLines(row.items, ' ')])
|
||||
}
|
||||
|
||||
/** 一行译文下面那张表写成文字,同 propertyLines 的子行,第一层不缩进。 */
|
||||
export function tableLines(table: PreviewTable | undefined): string[] {
|
||||
return itemLines(table?.items, '')
|
||||
}
|
||||
|
||||
/** 语法树节点换成它的源码,没填的可选字段去掉。 */
|
||||
export function nodesToText(value: unknown): unknown {
|
||||
if (value instanceof Node)
|
||||
|
||||
@@ -3,7 +3,7 @@ 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, walk, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { flatSegments, propertyLines, readFixture, segmentText, tableLines, walk, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { pythonZhTranslator } from './pythonZh'
|
||||
import { typescriptZhTranslator } from './typescriptZh'
|
||||
import { createZhTemplateTranslator } from './zhTemplate'
|
||||
@@ -1049,10 +1049,6 @@ describe('跨几行写的调用', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function tableLines(table: PreviewTable | undefined): string[] {
|
||||
return (table?.items ?? []).map(item => item.cells.map(segmentText).join(' | '))
|
||||
}
|
||||
|
||||
const USER_DETAIL = [
|
||||
'id | user.id',
|
||||
'username | user.username',
|
||||
@@ -1146,21 +1142,125 @@ describe('跨几行写的调用', () => {
|
||||
expect(asked).not.toContain('file.read')
|
||||
})
|
||||
|
||||
it('*x 写「展开」、**x 写「展开关键字」,「第 N 个」只数按位置传的;实参里的调用不再排成表', async () => {
|
||||
it('*x 写「展开」、**x 写「展开关键字」,「第 N 个」只数按位置传的', async () => {
|
||||
const source = [
|
||||
'run(',
|
||||
' first,',
|
||||
' *rest,',
|
||||
' second,',
|
||||
' key=Inner(',
|
||||
' x=1,',
|
||||
' ),',
|
||||
' key=value,',
|
||||
' **options,',
|
||||
')',
|
||||
].join('\n')
|
||||
const line = (await translateAll(py, 'python', pythonZhTranslator, source)).get(1)
|
||||
expect(text(line?.segments)).toBe('调用 run · 5 个参数')
|
||||
expect(tableLines(line?.table)).toEqual(['第 1 个 | first', '展开 | rest', '第 2 个 | second', 'key | Inner( x=1 )', '展开关键字 | options'])
|
||||
expect(tableLines(line?.table)).toEqual(['第 1 个 | first', '展开 | rest', '第 2 个 | second', 'key | value', '展开关键字 | options'])
|
||||
})
|
||||
|
||||
it('嵌套:实参的值是跨几行写的调用(可以带 await)时,值那一格写它的一行,它的实参表排在格子里;一行写完的调用、只是式子里有调用的照旧一行', async () => {
|
||||
const payment = readFixture('paymentConsoleRoutes.py')
|
||||
const { asked, calleeKind } = kinds({ PaymentCapabilityDiagnostic: 'class', CredentialPresence: 'class' })
|
||||
const line = (await translateAll(py, 'python', pythonZhTranslator, payment, calleeKind)).get(64)
|
||||
expect(text(line?.segments)).toBe('返回 新建 PaymentCapabilityDiagnostic · 15 个参数')
|
||||
expect(tableLines(line?.table)).toEqual([
|
||||
'native_enabled | bool(settings.WECHAT_PAY_MCH_ID)',
|
||||
'h5_enabled | settings.WECHAT_H5_ENABLED',
|
||||
'jsapi_enabled | settings.WECHAT_JSAPI_ENABLED',
|
||||
'jsapi_ready | jsapi_ready(settings)',
|
||||
'pay_app_id_masked | _mask_app_id(settings.WECHAT_PAY_APP_ID)',
|
||||
'mp_app_id_masked | _mask_app_id(settings.WECHAT_MP_APP_ID)',
|
||||
'credentials_present | 新建 CredentialPresence · 4 个参数',
|
||||
' mp_app_secret | bool(_secret_text(settings.WECHAT_MP_APP_SECRET))',
|
||||
' wechat_pay_api_v3_key | bool(settings.WECHAT_PAY_API_V3_KEY)',
|
||||
// 跨了两行、却是式子不是一次调用:照旧一行
|
||||
' wechat_pay_private_key | bool(private_key_path) 并且 Path(private_key_path).is_file()',
|
||||
' jsapi_intent_master_key | _intent_key_present(settings)',
|
||||
'oauth_callback_host | oauth_host',
|
||||
'pay_origin | pay_origin',
|
||||
'allowed_pay_origins | allowed',
|
||||
'jsapi_auth_dir_hint | ⟦列表|遍历 hint_origins 的每个 origin,仅当 origin,取 f"{origin}/"⟧',
|
||||
'domain_whitelist | "unknown_manual_check"',
|
||||
'merchant_binding | "unknown_manual_check"',
|
||||
'recovery_required_count | count',
|
||||
'recovery_required_earliest | earliest',
|
||||
])
|
||||
expect(line?.table?.columns).toEqual(['参数', '值'])
|
||||
// 里层和外层一样:问定义分新建和调用,名字带源码下标
|
||||
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(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 个参数')
|
||||
})
|
||||
|
||||
it('嵌套一层层往下套;await 的调用写「等待」', async () => {
|
||||
const source = [
|
||||
'return Outer(',
|
||||
' inner=Middle(',
|
||||
' leaf=Leaf(',
|
||||
' value=1,',
|
||||
' ),',
|
||||
' flag=await fetch(',
|
||||
' key,',
|
||||
' ),',
|
||||
' ),',
|
||||
')',
|
||||
].join('\n')
|
||||
const { asked, calleeKind } = kinds({ Outer: 'class', Middle: 'class', Leaf: 'class' })
|
||||
const line = (await translateAll(py, 'python', pythonZhTranslator, source, calleeKind)).get(1)
|
||||
expect(text(line?.segments)).toBe('返回 新建 Outer · 1 个参数')
|
||||
expect(tableLines(line?.table)).toEqual([
|
||||
'inner | 新建 Middle · 2 个参数',
|
||||
' leaf | 新建 Leaf · 1 个参数',
|
||||
' value | 1',
|
||||
' flag | 等待 调用 fetch · 1 个参数',
|
||||
' 第 1 个 | key',
|
||||
])
|
||||
expect([...asked].sort()).toEqual(['Leaf', 'Middle', 'Outer', 'fetch'])
|
||||
})
|
||||
|
||||
it('埋在式子里的跨几行调用也排:后面紧跟着同一个式子的别的部分(.x()、[0]、or)时整行括起来,后面是分隔符或什么都没有时不括', async () => {
|
||||
const chained = readFixture('chainedCalls.py')
|
||||
const rows = await describeAll(py, 'python', pythonZhTranslator, chained)
|
||||
expect(propertyLines(rows.get(18) ?? []).slice(1)).toEqual([
|
||||
'初始值: (调用 base64.b64encode · 1 个参数).decode()',
|
||||
' 第 1 个 | f"{admin_user}:{admin_password}".encode()',
|
||||
])
|
||||
// 调用跨几行的是外面那次:被调用的里面那次调用照旧一行,挤成一行时圆括号贴着里面的内容
|
||||
expect(propertyLines(rows.get(55) ?? []).slice(1, 2)).toEqual(['初始值: 等待 调用 config.get_payment_driver(order.payment_method).query_refund_status · 2 个参数'])
|
||||
|
||||
const source = [
|
||||
'first = make(',
|
||||
' a,',
|
||||
')[0]',
|
||||
'either = make(',
|
||||
' a,',
|
||||
') or fallback',
|
||||
'other = fallback or make(',
|
||||
' a,',
|
||||
')',
|
||||
'items = [',
|
||||
' make(',
|
||||
' a,',
|
||||
' ),',
|
||||
' b,',
|
||||
']',
|
||||
'run(',
|
||||
' key=Model(',
|
||||
' a=1,',
|
||||
' ).model_dump(),',
|
||||
')',
|
||||
].join('\n')
|
||||
const values = await describeAll(py, 'python', pythonZhTranslator, source)
|
||||
const value = (line: number): string | undefined => propertyLines(values.get(line) ?? []).find(row => row.startsWith('初始值'))
|
||||
expect(value(1)).toBe('初始值: (调用 make · 1 个参数)[0]')
|
||||
expect(value(4)).toBe('初始值: (调用 make · 1 个参数) 或者 fallback')
|
||||
expect(value(7)).toBe('初始值: fallback 或者 调用 make · 1 个参数')
|
||||
expect(value(10)).toBe('初始值: [ 调用 make · 1 个参数, b ]')
|
||||
const cell = (await translateAll(py, 'python', pythonZhTranslator, source)).get(16)
|
||||
expect(tableLines(cell?.table)).toEqual(['key | (调用 Model · 1 个参数).model_dump()', ' a | 1'])
|
||||
})
|
||||
|
||||
it('typeScript:new 写法就是新建,不带 new 的是调用,都不问定义;...x 写「展开」', async () => {
|
||||
@@ -1184,6 +1284,26 @@ describe('跨几行写的调用', () => {
|
||||
expect(asked).toEqual([])
|
||||
})
|
||||
|
||||
it('typeScript 嵌套:实参是跨几行写的调用、new 时同样排在值那一格里', async () => {
|
||||
const line = (await translateAll(ts, 'typescript', typescriptZhTranslator, readFixture('nestedCalls.ts.txt'))).get(3)
|
||||
expect(text(line?.segments)).toBe('调用 context.subscriptions.push · 4 个参数')
|
||||
expect(tableLines(line?.table)).toEqual([
|
||||
'第 1 个 | treeView',
|
||||
'第 2 个 | 调用 commands.registerCommand · 2 个参数',
|
||||
' 第 1 个 | \'superpowers.openKanban\'',
|
||||
' 第 2 个 | () => { KanbanWebviewPanel.createOrShow(context) }',
|
||||
'第 3 个 | 调用 commands.registerCommand · 2 个参数',
|
||||
' 第 1 个 | \'superpowers.refresh\'',
|
||||
' 第 2 个 | () => { KanbanWebviewPanel.refresh() }',
|
||||
'第 4 个 | 调用 commands.registerCommand · 2 个参数',
|
||||
' 第 1 个 | \'superpowers.setGiteaToken\'',
|
||||
' 第 2 个 | () => { KanbanWebviewPanel.createOrShow(context) KanbanWebviewPanel.requestEditAuth() }',
|
||||
])
|
||||
const source = 'const server = new Server(\n new Options(\n port,\n ),\n handler,\n)'
|
||||
const rows = await describeAll(ts, 'typescript', typescriptZhTranslator, source)
|
||||
expect(propertyLines(rows.get(1) ?? []).slice(-4)).toEqual(['值: 新建 Server · 2 个参数', ' 第 1 个 | 新建 Options · 1 个参数', ' 第 1 个 | port', ' 第 2 个 | handler'])
|
||||
})
|
||||
|
||||
it('链式调用跨了几行、实参列表各自写在一行里的不排成表;回调主体不在块头部里的调用也不排', async () => {
|
||||
const source = [
|
||||
'const ids = items',
|
||||
|
||||
@@ -89,6 +89,8 @@ interface Collector {
|
||||
|
||||
const CONTEXT_SEPARATOR = ' > '
|
||||
const CLOSING = new Set([')', ']', '}'])
|
||||
/** 跟在一个式子后面、不属于这个式子的标点:右括号、逗号、分号、冒号。 */
|
||||
const SEPARATORS = new Set([...CLOSING, ',', ';', ':'])
|
||||
const HEADER_OPENERS = new Set(['{', ':'])
|
||||
/** 源码着色里代表名字的词性:着这几种色的具名叶子是标识符、属性名、类型名,能查定义。 */
|
||||
const NAME_ROLES = new Set<TokenRole>(['variable', 'function', 'type'])
|
||||
@@ -117,6 +119,25 @@ function withoutMarkers(node: Node, markers: readonly SourceRange[]): string {
|
||||
return text + node.text.slice(cursor - node.startIndex)
|
||||
}
|
||||
|
||||
function sameSpan(a: Node, b: Node): boolean {
|
||||
return a.startIndex === b.startIndex && a.endIndex === b.endIndex && a.type === b.type
|
||||
}
|
||||
|
||||
/**
|
||||
* 在要翻的 root 里,node 后面还紧跟着包着它的式子的别的部分(`.model_dump()`、`[0]`、`or x`):从 node 往上找第一个后面有东西的节点,
|
||||
* 那个东西不是右括号、逗号、分号、冒号(语法之外的注释不算)。
|
||||
*/
|
||||
function continued(node: Node, root: Node): boolean {
|
||||
for (let current: Node | null = node; current && !sameSpan(current, root); current = current.parent) {
|
||||
let next = current.nextSibling
|
||||
while (next?.isExtra)
|
||||
next = next.nextSibling
|
||||
if (next)
|
||||
return !SEPARATORS.has(next.type)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isComment(token: Token): boolean {
|
||||
return token.text === '' && !token.group
|
||||
}
|
||||
@@ -245,9 +266,14 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return parts.every((part): part is string | Node => part != null) ? parts : undefined
|
||||
}
|
||||
|
||||
/** 源码里上一个词到 index 之间有空白,下一个词前面就空一格。 */
|
||||
/**
|
||||
* 源码里上一个词到 index 之间有空白,下一个词前面就空一格;跨行写的一对圆括号挤成一行时贴着里面的内容
|
||||
* (`f(\n x\n)` 写成 `f(x)`),方括号、花括号照旧空一格。
|
||||
*/
|
||||
function advance(out: Collector, index: number): void {
|
||||
out.gap ||= /\s/.test(out.document.source.slice(out.cursor, index))
|
||||
const between = out.document.source.slice(out.cursor, index)
|
||||
const hugging = between.includes('\n') && (out.tokens.at(-1)?.raw === '(' || out.document.source[index] === ')')
|
||||
out.gap ||= !hugging && /\s/.test(between)
|
||||
out.cursor = index
|
||||
}
|
||||
|
||||
@@ -344,12 +370,22 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return facts && facts.argumentList.startPosition.row !== facts.argumentList.endPosition.row ? facts : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* node 落在 span 里的那一段翻成一行译文,里面跨几行写的第一个调用排成一行「新建 / 调用 …」,实参摊成表(见 emitCall)。
|
||||
* 一行译文的块、常量 / 变量的值、实参表里每个值各翻各的,各自排自己里面的那个调用。
|
||||
*/
|
||||
function expandedText(node: Node, span: SourceSpan, document: SourceDocument): { text: Inline[], table?: PreviewTable } {
|
||||
const calls: CallSlot = {}
|
||||
const text = joinTokens(tokensIn(node, span, document, calls))
|
||||
return { text, ...(calls.table && { table: calls.table }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 实参表:参数 | 值,每个实参一行,照源码顺序。按名字传的写名字,按位置传的写「第 N 个」(只数按位置传的),
|
||||
* 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs);值照常翻,里面的调用不再排成表。
|
||||
* 展开的写「展开」(*args、...args)、「展开关键字」(**kwargs)。值照常翻;值里有跨几行写的调用时同样排成一行,
|
||||
* 它的实参表排在值那一格里(PropertyItem.table),一层层往下套。
|
||||
*/
|
||||
function argumentTable(facts: CallFacts, span: SourceSpan, document: SourceDocument): PreviewTable {
|
||||
const value = (node: Node): Inline[] => joinTokens(tokensIn(node, span, document))
|
||||
let position = 0
|
||||
const label = (arg: CallArgument): Inline[] => {
|
||||
switch (arg.kind) {
|
||||
@@ -364,18 +400,26 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
return [hint('展开关键字')]
|
||||
}
|
||||
}
|
||||
return { columns: ARGUMENT_COLUMNS, items: facts.arguments.map(arg => ({ cells: [label(arg), value(arg.value)] })) }
|
||||
const row = (arg: CallArgument): PropertyItem => {
|
||||
const { text, table } = expandedText(arg.value, span, document)
|
||||
return { cells: [label(arg), text], ...(table && { table }) }
|
||||
}
|
||||
return { columns: ARGUMENT_COLUMNS, items: facts.arguments.map(row) }
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用排成一行「新建 / 调用 被调用的名字 · N 个参数」,实参摊成表记在 calls 里。被调用的名字照常翻、带源码下标,
|
||||
* 最后那个名字新建时按类型、调用时按函数着色,前面几节按变量着色。新建还是调用看写法(CallFacts.construct),
|
||||
* 写法看不出来的问名字的定义:是类(含异常、枚举)就是新建,还没查到、查不到按调用算。
|
||||
* 调用后面还紧跟着同一个式子的别的部分(见 continued)时整行括起来:「(新建 X · 3 个参数).model_dump()」。
|
||||
*/
|
||||
function emitCall(out: Collector, calls: CallSlot, node: Node, facts: CallFacts, span: SourceSpan): void {
|
||||
calls.table = argumentTable(facts, span, out.document)
|
||||
const construct = facts.construct ?? CONSTRUCTED.has(out.document.calleeKind?.(facts))
|
||||
const wrapped = continued(node, out.root)
|
||||
advance(out, node.startIndex)
|
||||
if (wrapped)
|
||||
emit(out, { text: '(', role: 'plain', translated: true, raw: '' })
|
||||
emit(out, { text: construct ? '新建' : '调用', role: 'storage', translated: true, raw: '' })
|
||||
out.gap = true
|
||||
out.cursor = facts.callee.startIndex
|
||||
@@ -387,6 +431,8 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
}
|
||||
out.gap = true
|
||||
emit(out, { text: `· ${facts.arguments.length} 个参数`, role: 'hint', translated: true, raw: '' })
|
||||
if (wrapped)
|
||||
emit(out, { text: ')', role: 'plain', translated: true, raw: '' })
|
||||
out.cursor = node.endIndex
|
||||
}
|
||||
|
||||
@@ -512,11 +558,7 @@ export function createZhTemplateTranslator(table: ZhTable): Translator {
|
||||
const phrases: Phrases = {
|
||||
translated: node => within(node, nodeSpan(node)),
|
||||
within,
|
||||
expanded: (node) => {
|
||||
const calls: CallSlot = {}
|
||||
const text = joinTokens(tokensIn(node, clipped(nodeSpan(node)), document, calls))
|
||||
return { text, ...(calls.table && { table: calls.table }) }
|
||||
},
|
||||
expanded: node => expandedText(node, clipped(nodeSpan(node)), document),
|
||||
entryCount: (literal, size) => {
|
||||
const name = table.mapLiterals[literal.type]
|
||||
return [hint(name ? `${name} ${size} 项` : `${size} 项`)]
|
||||
|
||||
Reference in new issue
Block a user