+192
-56
@@ -1,21 +1,24 @@
|
||||
# TSL Codegen Toolkit
|
||||
|
||||
本工具把 YAML/JSON 录入文件转换为 TSL API skill 使用的 Markdown 函数文档,
|
||||
并根据 Markdown 重建 `function_index.tsv`
|
||||
本工具把一个或多个 tsf function、独立 class、完整 unit 转换为统一
|
||||
`declarations` json/yaml 录入文件,再生成 TSL API skill 使用的 markdown 文档,
|
||||
并根据 markdown 重建统一 API 索引 `function_index.tsv`。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
tools/tsl-codegen/
|
||||
├─ README.md 使用说明
|
||||
├─ STANDARD.md 函数文档与录入格式标准
|
||||
├─ STANDARD.md API 文档与录入格式标准
|
||||
├─ examples/
|
||||
│ ├─ example.yaml YAML 录入例子
|
||||
│ └─ example.json JSON 录入例子
|
||||
│ ├─ example.yaml yaml 录入例子
|
||||
│ └─ example.json json 录入例子
|
||||
├─ scripts/
|
||||
│ ├─ generate.py YAML/JSON → Markdown
|
||||
│ ├─ lint.py Markdown 格式校验
|
||||
│ └─ build_index.py 重建 function_index.tsv
|
||||
│ ├─ convert_tsf.py tsf → json/yaml
|
||||
│ ├─ generate.py yaml/json → markdown
|
||||
│ ├─ lint.py markdown 格式校验
|
||||
│ ├─ api_markdown.py lint/index 共用标题模型
|
||||
│ └─ build_index.py 重建 13 列 function_index.tsv
|
||||
└─ tests/ 工具测试
|
||||
```
|
||||
|
||||
@@ -29,38 +32,165 @@ tools/tsl-codegen/
|
||||
cd /path/to/playbook
|
||||
```
|
||||
|
||||
JSON 使用 Python 标准库,不需要额外安装解析包。YAML 需要安装 `pyyaml`:
|
||||
json 使用 Python 标准库,不需要额外安装解析包。yaml 需要安装 `pyyaml`:
|
||||
|
||||
```bash
|
||||
python -m pip install pyyaml
|
||||
```
|
||||
|
||||
markdown 生成器会强制使用仓库锁定版本的 Prettier。首次使用前安装 Node.js,并在
|
||||
仓库根目录安装依赖:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 阅读标准
|
||||
|
||||
先阅读 [`STANDARD.md`](STANDARD.md)。其中定义:
|
||||
|
||||
- Markdown 函数条目的固定结构
|
||||
- YAML/JSON 录入字段
|
||||
- function、class、unit 同级 H2 顶级声明和混合页面的固定层级
|
||||
- tsf function、独立 class 和完整 unit 文档块格式
|
||||
- yaml/json 统一 `declarations` 判别联合与成员字段
|
||||
- 13 列统一 API 索引和完全限定名称
|
||||
- 参数表、返回类型和示例代码规则
|
||||
|
||||
录入文件和生成的 Markdown 都必须符合该标准
|
||||
录入文件和生成的 markdown 都必须符合该标准
|
||||
|
||||
### 3. 准备自己的 YAML 或 JSON
|
||||
### 3. 准备自己的 yaml 或 json
|
||||
|
||||
#### 从 tsf 转换
|
||||
|
||||
转换器接受任意混合输入:function、独立 class 和完整 unit TSF 可以在同一条命令中
|
||||
出现。每个文件贡献一个对外 declaration,生成的 `declarations` 严格保持命令行
|
||||
输入顺序。页面级 `module` 和 `path` 通过参数统一提供,`--format` 明确选择
|
||||
json 或 yaml:
|
||||
|
||||
```bash
|
||||
python tools/tsl-codegen/scripts/convert_tsf.py \
|
||||
src/OpenXmlAttribute.tsf \
|
||||
src/ParseOpenXml.tsf \
|
||||
src/OpenXmlRuntime.tsf \
|
||||
--format json \
|
||||
--module "OfficeXml / OpenXml" \
|
||||
--path officexml/openxml/elements \
|
||||
--output tmp/openxml-elements.json
|
||||
```
|
||||
|
||||
生成 yaml 时,把 `--format json` 改为 `--format yaml`,并把输出文件后缀改为
|
||||
`.yaml`。混合输入规则不变。
|
||||
|
||||
查看中文帮助:
|
||||
|
||||
```bash
|
||||
python tools/tsl-codegen/scripts/convert_tsf.py --help
|
||||
```
|
||||
|
||||
转换规则:
|
||||
|
||||
- function TSF 只转换第一个独立顶层 `function`
|
||||
- 独立 class 只公开第一个与文件名一致的 class;后续内部 class 和 private 成员忽略
|
||||
- class 开头默认 public;public/protected 进入草稿;类方法只接受 `class function`
|
||||
- 类方法没有 `///` 描述时,使用最终声明或修饰符分号后的同一行 `//` 注释作为描述;
|
||||
正式 `///` 描述优先,类外实现行和函数体内注释不读取
|
||||
- property 类型可选;源码未声明类型时草稿中的 `type` 为空字符串,访问方式仍然必填
|
||||
- 完整 unit 只收录 interface 的 function/var/const/class;implementation 全部忽略
|
||||
- function、class、unit 可以任意混合;重复 function signature、重复 class/unit
|
||||
名称会报错,不同 signature 的 function 重载和跨 kind 同名允许
|
||||
- 公开 API 中的 `procedure` 当前不支持
|
||||
- 输入支持 utf-8(含 BOM)和 gb18030;输出统一为 utf-8
|
||||
- 参数类型和返回类型都是可选的 TSL 注解;转换器从函数签名和 `///` 文档块中尽量
|
||||
解析,无法取得时保留空字段,生成待手工完善的录入稿
|
||||
- `@param: name {类型} 参数说明` 可以为无类型签名补充参数类型;花括号内的全部内容
|
||||
都属于类型;`@returns:` 可以补充返回类型;签名和 `///` 同时提供类型时必须一致
|
||||
- 描述、参数类型或说明等编辑性缺口不会阻止转换;类型冲突、文档结构或声明冲突会按
|
||||
“文件:行号”报错;全部输入成功后才写输出文件
|
||||
- json/yaml 输出补齐各 kind 的固定维护字段:function/method 固定包含
|
||||
`desc`、`params`、`returns`,property 固定包含 `desc`、`type`、`params`、`access`,
|
||||
field/variable 固定包含 `desc`、`type`,constant 固定包含 `desc`、`type`、`value`;
|
||||
参数固定包含 `name`、`type`、`desc`,class/unit 固定包含 `desc`、`members`;缺失值
|
||||
使用空字符串或空数组,条件字段只在源码确实存在时输出
|
||||
- 生成器把 method `returns`、property/constant `type` 的空字符串按可选字段未填写处理;
|
||||
顶级/unit function 返回类型和 field/variable 类型等必填内容仍须补全
|
||||
- 输出文件不得与任一输入 tsf 相同
|
||||
|
||||
#### 手工准备
|
||||
|
||||
从以下例子选择一种格式:
|
||||
|
||||
- [`examples/example.yaml`](examples/example.yaml)
|
||||
- [`examples/example.json`](examples/example.json)
|
||||
|
||||
例子中的函数是格式示例,不是真实 TSL API。复制例子到自己的工作目录,再修改
|
||||
`module`、`path` 和 `functions`。例如:
|
||||
两份完整例子深度等价,同时包含顶级 function、完整 class、class function、
|
||||
typed/untyped property、字段、常量、完整 unit 和 interface class 成员
|
||||
|
||||
```text
|
||||
tmp/my-functions.yaml
|
||||
tmp/my-functions.json
|
||||
例子中的 API 是格式示例,不是真实 TSL API。复制例子到自己的工作目录,再修改
|
||||
`module`、`path` 和非空有序 `declarations`。录入根只允许这三个字段;旧
|
||||
`functions`、`class`、`unit` 根不兼容,生成器会直接拒绝。混合页面的最小
|
||||
完整外形:
|
||||
|
||||
```json
|
||||
{
|
||||
"module": "OfficeXml / OpenXml",
|
||||
"path": "officexml/openxml/elements",
|
||||
"declarations": [
|
||||
{
|
||||
"kind": "class",
|
||||
"name": "OpenXmlAttribute",
|
||||
"desc": "表示 OpenXml 属性",
|
||||
"members": []
|
||||
},
|
||||
{
|
||||
"kind": "function",
|
||||
"name": "ParseOpenXml",
|
||||
"signature": "ParseOpenXml(xml)",
|
||||
"desc": "解析 OpenXml 文本",
|
||||
"params": [
|
||||
{"name": "xml", "type": "string", "desc": "OpenXml 文本"}
|
||||
],
|
||||
"returns": "OpenXmlElement"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
一个录入文件对应一个 Markdown 叶子页。录入文件不放入 skill;是否长期保留由
|
||||
生成的 markdown 使用纯 API 标题和独立声明行。class function 的参数类型进入参数表,
|
||||
返回类型写在返回行:
|
||||
|
||||
```markdown
|
||||
# OfficeXml / OpenXml
|
||||
|
||||
## `OpenXmlAttribute`
|
||||
|
||||
声明:class
|
||||
|
||||
表示 OpenXml 属性
|
||||
|
||||
### `CreateVirtual(position, row_index, _story)`
|
||||
|
||||
声明:class function
|
||||
|
||||
创建虚段落
|
||||
|
||||
可见性:`public`
|
||||
|
||||
| 参数 | 类型 | 说明 |
|
||||
| ----------- | --------- | -------- |
|
||||
| `position` | integer | 段落位置 |
|
||||
| `row_index` | integer | 行索引 |
|
||||
| `_story` | StoryNode | 故事节点 |
|
||||
|
||||
返回:ParagraphSegment
|
||||
```
|
||||
|
||||
录入文件可放在自己的临时目录,例如:
|
||||
|
||||
```text
|
||||
tmp/my-api.yaml
|
||||
tmp/my-api.json
|
||||
```
|
||||
|
||||
一个录入文件对应一个 markdown 叶子页。录入文件不放入 skill;是否长期保留由
|
||||
维护者自行决定
|
||||
|
||||
### 4. 选择 Skill 中的目标位置
|
||||
@@ -82,18 +212,18 @@ skills/tsl-api-reference/
|
||||
└─ lookup.py
|
||||
```
|
||||
|
||||
Markdown 目标路径固定为:
|
||||
markdown 目标路径固定为:
|
||||
|
||||
```text
|
||||
skills/tsl-api-reference/references/codegen/<scope>/<module-dir>/<page>.md
|
||||
```
|
||||
|
||||
- `<scope>`:用户文档默认使用 `project`,也可以自定义单级目录名。`builtin` 和
|
||||
`dotnet` 由 playbook 项目维护,不应用于存放用户自己的函数文档
|
||||
`dotnet` 由 playbook 项目维护,不应用于存放用户自己的 API 文档
|
||||
- `<module-dir>`:功能分类目录,例如 `base`、`runtime`、`document`
|
||||
- `<page>.md`:同类函数的叶子文档,例如 `array.md`、`string.md`
|
||||
- `<page>.md`:相关 API 的叶子文档,例如 `array.md`、`elements.md`
|
||||
|
||||
录入文件的 `module` 是 Markdown 一级标题,不是目录名。例如:
|
||||
录入文件的 `module` 是 markdown 一级标题,不是目录名。例如:
|
||||
|
||||
```text
|
||||
module: 我的项目 / 数组
|
||||
@@ -107,63 +237,60 @@ path: base/array
|
||||
rg --files skills/tsl-api-reference/references/codegen/project
|
||||
```
|
||||
|
||||
生成后可以直接打开目标 Markdown 手动阅读。例如:
|
||||
生成后可以直接打开目标 markdown 手动阅读。例如:
|
||||
|
||||
```text
|
||||
skills/tsl-api-reference/references/codegen/project/base/array.md
|
||||
```
|
||||
|
||||
### 5. 生成 Markdown
|
||||
### 5. 生成 markdown
|
||||
|
||||
#### 新建叶子页
|
||||
|
||||
目标文件不存在时,可以直接生成到 skill。例如:
|
||||
|
||||
```bash
|
||||
python tools/tsl-codegen/scripts/generate.py tmp/my-functions.yaml
|
||||
python tools/tsl-codegen/scripts/generate.py tmp/my-api.yaml
|
||||
```
|
||||
|
||||
JSON 使用相同命令:
|
||||
json 使用相同命令:
|
||||
|
||||
```bash
|
||||
python tools/tsl-codegen/scripts/generate.py tmp/my-functions.json
|
||||
python tools/tsl-codegen/scripts/generate.py tmp/my-api.json
|
||||
```
|
||||
|
||||
生成器读取录入文件中的 `path`,默认写入
|
||||
`skills/tsl-api-reference/references/codegen/project/<path>.md`。不指定
|
||||
`--scope` 时,scope 就是 `project`
|
||||
|
||||
写入前会自动使用仓库的 `.prettierrc.json` 格式化 markdown,使新页面与现有
|
||||
builtin 页面保持一致。未安装 Prettier 或格式化失败时,生成器会停止且不写目标文件
|
||||
|
||||
需要使用自定义 scope 时,通过 `--scope` 指定单级目录名:
|
||||
|
||||
```bash
|
||||
python tools/tsl-codegen/scripts/generate.py tmp/my-functions.json --scope my-project
|
||||
python tools/tsl-codegen/scripts/generate.py tmp/my-api.json --scope my-project
|
||||
```
|
||||
|
||||
#### 修改现有叶子页
|
||||
|
||||
生成器会整体覆盖 `path` 对应的页面。只有录入文件包含该页面的全部函数时才运行
|
||||
生成器。只修改现有页面中的少量函数时,应按照 `STANDARD.md` 直接编辑 Markdown
|
||||
生成器会整体覆盖 `path` 对应的页面。只有录入文件包含该页面的全部 API 时才运行
|
||||
生成器。只修改现有页面中的少量条目时,应按照 `STANDARD.md` 直接编辑 markdown
|
||||
|
||||
### 6. 手动检查并校验 Markdown
|
||||
### 6. 手动检查并校验 markdown
|
||||
|
||||
以下命令以新建页面
|
||||
`skills/tsl-api-reference/references/codegen/project/base/my_functions.md` 为例
|
||||
`skills/tsl-api-reference/references/codegen/project/base/my_api.md` 为例
|
||||
|
||||
先打开文件,检查页面标题、函数签名、参数、返回类型和示例
|
||||
先打开文件,检查页面标题、API 名称、成员层级、参数、返回类型和示例
|
||||
|
||||
#### 6.1 格式化表格(可选)
|
||||
#### 6.1 格式说明
|
||||
|
||||
此步骤不是必需的,仅用于对齐 Markdown 表格列宽。使用前需要安装 Node.js,并在
|
||||
仓库根目录安装 `prettier`:
|
||||
通过 `generate.py` 生成的页面已经完成 Prettier 格式化,不需要再次处理。直接手工
|
||||
编辑 markdown 后,可以单独格式化目标文件:
|
||||
|
||||
```bash
|
||||
npm install --save-dev prettier
|
||||
```
|
||||
|
||||
然后格式化目标文件:
|
||||
|
||||
```bash
|
||||
npx prettier --write skills/tsl-api-reference/references/codegen/project/base/my_functions.md
|
||||
npx --no-install prettier --write skills/tsl-api-reference/references/codegen/project/base/my_api.md
|
||||
```
|
||||
|
||||
#### 6.2 校验
|
||||
@@ -171,7 +298,7 @@ npx prettier --write skills/tsl-api-reference/references/codegen/project/base/my
|
||||
校验目标文件:
|
||||
|
||||
```bash
|
||||
python tools/tsl-codegen/scripts/lint.py --file skills/tsl-api-reference/references/codegen/project/base/my_functions.md
|
||||
python tools/tsl-codegen/scripts/lint.py --file skills/tsl-api-reference/references/codegen/project/base/my_api.md
|
||||
```
|
||||
|
||||
校验整个项目目录:
|
||||
@@ -185,46 +312,55 @@ python tools/tsl-codegen/scripts/lint.py --dir skills/tsl-api-reference/referenc
|
||||
校验器将以下问题视为错误:
|
||||
|
||||
- 缺少描述
|
||||
- 缺少返回类型
|
||||
- 顶层/unit function 缺少返回类型
|
||||
- 有参数但没有参数表
|
||||
- 无参数但存在参数表
|
||||
- 参数表不是固定三列
|
||||
- 参数表不是固定三列,或参数名称/顺序与签名不一致
|
||||
- 参数表数据行缺少参数名、类型或说明
|
||||
- class member 缺少 visibility
|
||||
- property 缺少访问方式,field/variable 缺少类型,constant 缺少值
|
||||
- class/unit 成员标题层级、声明形式或子标题错误,或出现 private API
|
||||
|
||||
以下问题默认作为警告:
|
||||
|
||||
- 可选参数说明未以 `可选。` 开头
|
||||
- tags 行为空
|
||||
|
||||
### 7. 更新并验证函数索引
|
||||
### 7. 更新并验证统一 API 索引
|
||||
|
||||
Markdown 确认无误后,重建 TSV:
|
||||
markdown 确认无误后,重建 TSV:
|
||||
|
||||
索引会分别保存函数的 tags 和描述。关键词检索会同时匹配函数名、签名、模块、
|
||||
tags 和描述
|
||||
索引固定为 13 列:保留原 8 列并追加 kind、binding、visibility、owner、
|
||||
qualified_name。关键词检索会同时匹配原有字段和这些扩展字段。
|
||||
|
||||
```bash
|
||||
python tools/tsl-codegen/scripts/build_index.py --skill-dir skills/tsl-api-reference
|
||||
```
|
||||
|
||||
检查 TSV 是否与 Markdown 一致:
|
||||
检查 TSV 是否与 markdown 一致:
|
||||
|
||||
```bash
|
||||
python tools/tsl-codegen/scripts/build_index.py --skill-dir skills/tsl-api-reference --check
|
||||
```
|
||||
|
||||
默认从 `<skill-dir>/references/codegen` 读取 Markdown,并写入
|
||||
默认从 `<skill-dir>/references/codegen` 读取 markdown,并写入
|
||||
`<skill-dir>/data/function_index.tsv`
|
||||
|
||||
最后使用函数名验证 skill 可以检索到新条目。把 `myFunction` 替换为真实函数名:
|
||||
最后使用简单名称或完全限定名称验证 skill 可以检索到新条目:
|
||||
|
||||
```bash
|
||||
python skills/tsl-api-reference/scripts/lookup.py --name myFunction
|
||||
python skills/tsl-api-reference/scripts/lookup.py --name DemoUnit.Document.Save
|
||||
```
|
||||
|
||||
查询简单成员名会返回所有 owner 下的同名 API;`Unit.Class.Member` 等完全限定名称
|
||||
用于缩小范围。重载共享完全限定名称,查询会返回全部重载。关键词候选显示
|
||||
`class function`、`static field` 等实际声明术语。
|
||||
|
||||
最终提交:
|
||||
|
||||
- 新增或修改的 Markdown 叶子页
|
||||
- 新增或修改的 markdown 叶子页
|
||||
- `skills/tsl-api-reference/data/function_index.tsv`
|
||||
|
||||
TSL API skill 只需要 Markdown 和 TSV。录入用的 YAML/JSON 可以由维护者在自己的
|
||||
TSL API skill 只需要 markdown 和 TSV。录入用的 yaml/json 可以由维护者在自己的
|
||||
版本库中管理
|
||||
|
||||
+985
-55
File diff suppressed because it is too large
Load Diff
@@ -1,53 +1,213 @@
|
||||
{
|
||||
"module": "示例 / 数组",
|
||||
"path": "base/example",
|
||||
"functions": [
|
||||
"module": "OfficeXml / OpenXml",
|
||||
"path": "officexml/openxml/elements",
|
||||
"declarations": [
|
||||
{
|
||||
"signature": "demoNow()",
|
||||
"desc": "返回示例值。",
|
||||
"returns": "integer",
|
||||
"example": "return demoNow();\n// 输出:1"
|
||||
},
|
||||
{
|
||||
"signature": "demoFn(src, mode, factor, ...)",
|
||||
"tags": ["示例", "数组"],
|
||||
"desc": "按指定模式处理数组并返回结果。",
|
||||
"params": [
|
||||
"kind": "class",
|
||||
"name": "OpenXmlAttribute",
|
||||
"desc": "表示 OpenXml 属性",
|
||||
"tags": ["OpenXml", "XML", "属性"],
|
||||
"members": [
|
||||
{
|
||||
"name": "src",
|
||||
"type": "array",
|
||||
"desc": "待处理数组"
|
||||
},
|
||||
{
|
||||
"name": "mode",
|
||||
"type": "integer",
|
||||
"desc": "处理模式,取值见下。",
|
||||
"values": [
|
||||
"kind": "method",
|
||||
"name": "create",
|
||||
"visibility": "public",
|
||||
"binding": "instance",
|
||||
"signature": "create(_prefix, _local_name)",
|
||||
"desc": "创建 OpenXml 属性",
|
||||
"tags": ["OpenXml", "属性", "创建"],
|
||||
"params": [
|
||||
{
|
||||
"value": 0,
|
||||
"desc": "原样返回"
|
||||
"name": "_prefix",
|
||||
"type": "string",
|
||||
"desc": "命名空间前缀"
|
||||
},
|
||||
{
|
||||
"value": 1,
|
||||
"desc": "去重"
|
||||
"name": "_local_name",
|
||||
"type": "string",
|
||||
"desc": "本地名称"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "factor",
|
||||
"type": "float",
|
||||
"optional": true,
|
||||
"desc": "默认 1.0,结果乘以该系数。"
|
||||
"kind": "method",
|
||||
"name": "CreateVirtual",
|
||||
"visibility": "public",
|
||||
"binding": "class",
|
||||
"signature": "CreateVirtual(position, row_index, _story)",
|
||||
"desc": "创建虚段落",
|
||||
"params": [
|
||||
{
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"desc": "段落位置"
|
||||
},
|
||||
{
|
||||
"name": "row_index",
|
||||
"type": "integer",
|
||||
"desc": "行索引"
|
||||
},
|
||||
{
|
||||
"name": "_story",
|
||||
"type": "StoryNode",
|
||||
"desc": "故事节点"
|
||||
}
|
||||
],
|
||||
"returns": "ParagraphSegment"
|
||||
},
|
||||
{
|
||||
"name": "...",
|
||||
"type": "nil|array",
|
||||
"optional": true,
|
||||
"desc": "需要追加处理的其他数组。"
|
||||
"kind": "property",
|
||||
"name": "Prefix",
|
||||
"visibility": "public",
|
||||
"desc": "命名空间前缀",
|
||||
"type": "string",
|
||||
"access": "readwrite"
|
||||
},
|
||||
{
|
||||
"kind": "property",
|
||||
"name": "NamespaceUri",
|
||||
"visibility": "public",
|
||||
"desc": "命名空间 URI",
|
||||
"access": "read"
|
||||
},
|
||||
{
|
||||
"kind": "field",
|
||||
"name": "value_",
|
||||
"visibility": "protected",
|
||||
"desc": "属性值存储",
|
||||
"type": "any"
|
||||
},
|
||||
{
|
||||
"kind": "field",
|
||||
"name": "Count",
|
||||
"visibility": "public",
|
||||
"desc": "属性实例数量",
|
||||
"type": "integer",
|
||||
"static": true
|
||||
},
|
||||
{
|
||||
"kind": "constant",
|
||||
"name": "DefaultPrefix",
|
||||
"visibility": "public",
|
||||
"desc": "默认命名空间前缀",
|
||||
"type": "string",
|
||||
"value": "xml"
|
||||
},
|
||||
{
|
||||
"kind": "constant",
|
||||
"name": "MaximumAttributes",
|
||||
"visibility": "public",
|
||||
"desc": "最大属性数量",
|
||||
"type": "integer",
|
||||
"value": 256,
|
||||
"static": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "function",
|
||||
"name": "ParseOpenXml",
|
||||
"signature": "ParseOpenXml(xml)",
|
||||
"desc": "解析 OpenXml 文本",
|
||||
"tags": ["OpenXml", "解析"],
|
||||
"params": [
|
||||
{
|
||||
"name": "xml",
|
||||
"type": "string",
|
||||
"desc": "OpenXml 文本"
|
||||
}
|
||||
],
|
||||
"returns": "array",
|
||||
"example": "src := array(1, 1, 2);\nreturn demoFn(src, 1, 2.0);\n// 输出:array(2,4)"
|
||||
"returns": "OpenXmlElement",
|
||||
"examples": [
|
||||
{
|
||||
"desc": "解析根元素",
|
||||
"code": "xml := '<root/>';\nreturn ParseOpenXml(xml);"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "unit",
|
||||
"name": "OpenXmlRuntime",
|
||||
"desc": "提供 OpenXml 运行时接口",
|
||||
"tags": ["OpenXml", "运行时"],
|
||||
"members": [
|
||||
{
|
||||
"kind": "constant",
|
||||
"name": "DefaultSize",
|
||||
"desc": "默认文档容量",
|
||||
"type": "integer",
|
||||
"value": 100
|
||||
},
|
||||
{
|
||||
"kind": "variable",
|
||||
"name": "CurrentDocument",
|
||||
"desc": "当前文档",
|
||||
"type": "Document"
|
||||
},
|
||||
{
|
||||
"kind": "function",
|
||||
"name": "OpenDocument",
|
||||
"signature": "OpenDocument(path)",
|
||||
"desc": "打开文档",
|
||||
"tags": ["OpenXml", "文档", "打开"],
|
||||
"params": [
|
||||
{
|
||||
"name": "path",
|
||||
"type": "string",
|
||||
"desc": "文档路径"
|
||||
}
|
||||
],
|
||||
"returns": "Document"
|
||||
},
|
||||
{
|
||||
"kind": "class",
|
||||
"name": "Document",
|
||||
"desc": "表示 OpenXml 文档",
|
||||
"tags": ["OpenXml", "文档"],
|
||||
"members": [
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "Save",
|
||||
"visibility": "public",
|
||||
"binding": "instance",
|
||||
"signature": "Save(path)",
|
||||
"desc": "保存文档",
|
||||
"params": [
|
||||
{
|
||||
"name": "path",
|
||||
"type": "string",
|
||||
"desc": "保存路径"
|
||||
}
|
||||
],
|
||||
"returns": "boolean"
|
||||
},
|
||||
{
|
||||
"kind": "method",
|
||||
"name": "Create",
|
||||
"visibility": "public",
|
||||
"binding": "class",
|
||||
"signature": "Create(name)",
|
||||
"desc": "创建文档",
|
||||
"params": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "string",
|
||||
"desc": "文档名称"
|
||||
}
|
||||
],
|
||||
"returns": "Document"
|
||||
},
|
||||
{
|
||||
"kind": "property",
|
||||
"name": "Title",
|
||||
"visibility": "public",
|
||||
"desc": "文档标题",
|
||||
"type": "string",
|
||||
"access": "readwrite"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,39 +1,160 @@
|
||||
module: 示例 / 数组
|
||||
path: base/example
|
||||
module: OfficeXml / OpenXml
|
||||
path: officexml/openxml/elements
|
||||
declarations:
|
||||
- kind: class
|
||||
name: OpenXmlAttribute
|
||||
desc: 表示 OpenXml 属性
|
||||
tags: [OpenXml, XML, 属性]
|
||||
members:
|
||||
- kind: method
|
||||
name: create
|
||||
visibility: public
|
||||
binding: instance
|
||||
signature: create(_prefix, _local_name)
|
||||
desc: 创建 OpenXml 属性
|
||||
tags: [OpenXml, 属性, 创建]
|
||||
params:
|
||||
- name: _prefix
|
||||
type: string
|
||||
desc: 命名空间前缀
|
||||
- name: _local_name
|
||||
type: string
|
||||
desc: 本地名称
|
||||
|
||||
functions:
|
||||
- signature: demoNow()
|
||||
desc: 返回示例值。
|
||||
returns: integer
|
||||
example: |
|
||||
return demoNow();
|
||||
// 输出:1
|
||||
- kind: method
|
||||
name: CreateVirtual
|
||||
visibility: public
|
||||
binding: class
|
||||
signature: CreateVirtual(position, row_index, _story)
|
||||
desc: 创建虚段落
|
||||
params:
|
||||
- name: position
|
||||
type: integer
|
||||
desc: 段落位置
|
||||
- name: row_index
|
||||
type: integer
|
||||
desc: 行索引
|
||||
- name: _story
|
||||
type: StoryNode
|
||||
desc: 故事节点
|
||||
returns: ParagraphSegment
|
||||
|
||||
- signature: demoFn(src, mode, factor, ...)
|
||||
tags: [示例, 数组]
|
||||
desc: 按指定模式处理数组并返回结果。
|
||||
params:
|
||||
- name: src
|
||||
type: array
|
||||
desc: 待处理数组
|
||||
- name: mode
|
||||
- kind: property
|
||||
name: Prefix
|
||||
visibility: public
|
||||
desc: 命名空间前缀
|
||||
type: string
|
||||
access: readwrite
|
||||
|
||||
- kind: property
|
||||
name: NamespaceUri
|
||||
visibility: public
|
||||
desc: 命名空间 URI
|
||||
access: read
|
||||
|
||||
- kind: field
|
||||
name: value_
|
||||
visibility: protected
|
||||
desc: 属性值存储
|
||||
type: any
|
||||
|
||||
- kind: field
|
||||
name: Count
|
||||
visibility: public
|
||||
desc: 属性实例数量
|
||||
type: integer
|
||||
desc: 处理模式,取值见下。
|
||||
values:
|
||||
- value: 0
|
||||
desc: 原样返回
|
||||
- value: 1
|
||||
desc: 去重
|
||||
- name: factor
|
||||
type: float
|
||||
optional: true
|
||||
desc: 默认 1.0,结果乘以该系数。
|
||||
- name: "..."
|
||||
type: nil|array
|
||||
optional: true
|
||||
desc: 需要追加处理的其他数组。
|
||||
returns: array
|
||||
example: |
|
||||
src := array(1, 1, 2);
|
||||
return demoFn(src, 1, 2.0);
|
||||
// 输出:array(2,4)
|
||||
static: true
|
||||
|
||||
- kind: constant
|
||||
name: DefaultPrefix
|
||||
visibility: public
|
||||
desc: 默认命名空间前缀
|
||||
type: string
|
||||
value: xml
|
||||
|
||||
- kind: constant
|
||||
name: MaximumAttributes
|
||||
visibility: public
|
||||
desc: 最大属性数量
|
||||
type: integer
|
||||
value: 256
|
||||
static: true
|
||||
|
||||
- kind: function
|
||||
name: ParseOpenXml
|
||||
signature: ParseOpenXml(xml)
|
||||
desc: 解析 OpenXml 文本
|
||||
tags: [OpenXml, 解析]
|
||||
params:
|
||||
- name: xml
|
||||
type: string
|
||||
desc: OpenXml 文本
|
||||
returns: OpenXmlElement
|
||||
examples:
|
||||
- desc: 解析根元素
|
||||
code: |-
|
||||
xml := '<root/>';
|
||||
return ParseOpenXml(xml);
|
||||
|
||||
- kind: unit
|
||||
name: OpenXmlRuntime
|
||||
desc: 提供 OpenXml 运行时接口
|
||||
tags: [OpenXml, 运行时]
|
||||
members:
|
||||
- kind: constant
|
||||
name: DefaultSize
|
||||
desc: 默认文档容量
|
||||
type: integer
|
||||
value: 100
|
||||
|
||||
- kind: variable
|
||||
name: CurrentDocument
|
||||
desc: 当前文档
|
||||
type: Document
|
||||
|
||||
- kind: function
|
||||
name: OpenDocument
|
||||
signature: OpenDocument(path)
|
||||
desc: 打开文档
|
||||
tags: [OpenXml, 文档, 打开]
|
||||
params:
|
||||
- name: path
|
||||
type: string
|
||||
desc: 文档路径
|
||||
returns: Document
|
||||
|
||||
- kind: class
|
||||
name: Document
|
||||
desc: 表示 OpenXml 文档
|
||||
tags: [OpenXml, 文档]
|
||||
members:
|
||||
- kind: method
|
||||
name: Save
|
||||
visibility: public
|
||||
binding: instance
|
||||
signature: Save(path)
|
||||
desc: 保存文档
|
||||
params:
|
||||
- name: path
|
||||
type: string
|
||||
desc: 保存路径
|
||||
returns: boolean
|
||||
|
||||
- kind: method
|
||||
name: Create
|
||||
visibility: public
|
||||
binding: class
|
||||
signature: Create(name)
|
||||
desc: 创建文档
|
||||
params:
|
||||
- name: name
|
||||
type: string
|
||||
desc: 文档名称
|
||||
returns: Document
|
||||
|
||||
- kind: property
|
||||
name: Title
|
||||
visibility: public
|
||||
desc: 文档标题
|
||||
type: string
|
||||
access: readwrite
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Shared recognition of TSL codegen API headings and declaration lines."""
|
||||
|
||||
from bisect import bisect_right
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
|
||||
TOP_LEVEL_RE = re.compile(r"^##(?!#)\s+`(.+?)`\s*$")
|
||||
PLAIN_H2_RE = re.compile(r"^##(?!#)\s+")
|
||||
BARE_API_RE = re.compile(r"^(#{3,5})(?!#)\s+`(.+?)`\s*$")
|
||||
LEGACY_TYPED_RE = re.compile(
|
||||
r"^(#{3,5})(?!#)\s+"
|
||||
r"(class function|static function|static field|static const|"
|
||||
r"function|property|field|const|var|class)\s+`(.+?)`\s*$"
|
||||
)
|
||||
DECLARATION_LINE_RE = re.compile(r"^声明:(.*?)\s*$")
|
||||
DECLARATION_PREFIX_RE = re.compile(r"^声明[::]", re.IGNORECASE)
|
||||
LOOSE_DECLARATION_RE = re.compile(r"^声明[::]\s*(.*?)\s*$", re.IGNORECASE)
|
||||
TOP_LEVEL_TYPES = {"function", "class", "unit"}
|
||||
CLASS_DECLARATIONS = {
|
||||
"function": ("method", "instance"),
|
||||
"class function": ("method", "class"),
|
||||
"property": ("property", "instance"),
|
||||
"field": ("field", "instance"),
|
||||
"static field": ("field", "static"),
|
||||
"const": ("constant", "instance"),
|
||||
"static const": ("constant", "static"),
|
||||
}
|
||||
UNIT_DECLARATIONS = {
|
||||
"function": ("function", "unit"),
|
||||
"var": ("variable", "unit"),
|
||||
"const": ("constant", "unit"),
|
||||
"class": ("class", "unit"),
|
||||
}
|
||||
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+")
|
||||
FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiHeading:
|
||||
level: int
|
||||
kind: str
|
||||
binding: str
|
||||
name: str
|
||||
signature: str
|
||||
visible_title: str
|
||||
root_kind: str
|
||||
valid: bool = True
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApiEntry:
|
||||
heading: ApiHeading
|
||||
start: int
|
||||
end: int
|
||||
|
||||
|
||||
def simple_name(signature):
|
||||
return signature.split("(", 1)[0].strip()
|
||||
|
||||
|
||||
def fence_flags(lines):
|
||||
flags = []
|
||||
in_fence = False
|
||||
marker = ""
|
||||
for line in lines:
|
||||
stripped = line.lstrip()
|
||||
if not in_fence and FENCE_RE.match(line):
|
||||
marker = stripped[:3]
|
||||
flags.append(True)
|
||||
in_fence = True
|
||||
continue
|
||||
flags.append(in_fence)
|
||||
if in_fence and stripped.startswith(marker):
|
||||
in_fence = False
|
||||
marker = ""
|
||||
return flags
|
||||
|
||||
|
||||
def heading_body_end(lines, start, flags=None):
|
||||
if flags is None:
|
||||
flags = fence_flags(lines)
|
||||
for index in range(start + 1, len(lines)):
|
||||
if flags[index]:
|
||||
continue
|
||||
if HEADING_RE.match(lines[index]):
|
||||
return index
|
||||
return len(lines)
|
||||
|
||||
|
||||
def top_level_header_end(lines, start, flags=None):
|
||||
return heading_body_end(lines, start, flags)
|
||||
|
||||
|
||||
def _declaration(lines, start, end, flags):
|
||||
content = []
|
||||
declaration_lines = []
|
||||
for index in range(start + 1, end):
|
||||
if flags[index]:
|
||||
continue
|
||||
stripped = lines[index].strip()
|
||||
if not stripped:
|
||||
continue
|
||||
content.append((index, stripped))
|
||||
if DECLARATION_PREFIX_RE.match(stripped):
|
||||
declaration_lines.append((index, stripped))
|
||||
|
||||
if not content:
|
||||
return "", "API 标题后的第一条非空正文必须是 `声明:...`"
|
||||
first_index, first = content[0]
|
||||
match = DECLARATION_LINE_RE.fullmatch(first)
|
||||
if not match:
|
||||
loose = LOOSE_DECLARATION_RE.fullmatch(first)
|
||||
if loose and loose.group(1).strip():
|
||||
expected = f"声明:{loose.group(1).strip().casefold()}"
|
||||
return "", f"声明行必须精确写为 `{expected}`"
|
||||
return (
|
||||
"",
|
||||
"API 标题后的第一条非空正文必须是规范的 `声明:...` 行",
|
||||
)
|
||||
value = match.group(1).strip().casefold()
|
||||
expected = f"声明:{value}"
|
||||
if first != expected:
|
||||
return "", f"声明行必须精确写为 `{expected}`"
|
||||
if len(declaration_lines) != 1 or declaration_lines[0][0] != first_index:
|
||||
return "", "每个 API 必须且只能包含一行声明"
|
||||
return value, ""
|
||||
|
||||
|
||||
def top_level_type(lines, start, end):
|
||||
return _declaration(lines, start, end, fence_flags(lines))
|
||||
|
||||
|
||||
def invalid_heading(level, name, signature, visible_title, root_kind, message):
|
||||
return ApiHeading(
|
||||
level,
|
||||
"invalid",
|
||||
"",
|
||||
name,
|
||||
signature,
|
||||
visible_title,
|
||||
root_kind,
|
||||
False,
|
||||
message,
|
||||
)
|
||||
|
||||
|
||||
def _valid_heading(level, kind, binding, signature, root_kind):
|
||||
return ApiHeading(
|
||||
level,
|
||||
kind,
|
||||
binding,
|
||||
simple_name(signature),
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
)
|
||||
|
||||
|
||||
def top_level_heading(signature, declaration, error):
|
||||
if error:
|
||||
return invalid_heading(
|
||||
2,
|
||||
simple_name(signature),
|
||||
signature,
|
||||
signature,
|
||||
"",
|
||||
error,
|
||||
)
|
||||
if declaration not in TOP_LEVEL_TYPES:
|
||||
return invalid_heading(
|
||||
2,
|
||||
simple_name(signature),
|
||||
signature,
|
||||
signature,
|
||||
"",
|
||||
f"顶级 API 声明必须是 function、class 或 unit,实为 {declaration}",
|
||||
)
|
||||
return _valid_heading(2, declaration, "", signature, declaration)
|
||||
|
||||
|
||||
def member_heading(
|
||||
level,
|
||||
declaration,
|
||||
signature,
|
||||
root_kind,
|
||||
unit_class_open,
|
||||
error,
|
||||
):
|
||||
name = simple_name(signature)
|
||||
if error:
|
||||
return invalid_heading(
|
||||
level, name, signature, signature, root_kind, error
|
||||
)
|
||||
if declaration == "static function":
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"不存在 static function,请使用 class function",
|
||||
)
|
||||
|
||||
if root_kind == "class":
|
||||
if level != 3 or declaration not in CLASS_DECLARATIONS:
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"class API 成员必须使用约定的 H3 声明",
|
||||
)
|
||||
kind, binding = CLASS_DECLARATIONS[declaration]
|
||||
return _valid_heading(level, kind, binding, signature, root_kind)
|
||||
|
||||
if root_kind == "unit":
|
||||
if level == 3:
|
||||
if declaration not in UNIT_DECLARATIONS:
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"unit interface 成员必须使用约定的 H3 声明",
|
||||
)
|
||||
kind, binding = UNIT_DECLARATIONS[declaration]
|
||||
return _valid_heading(level, kind, binding, signature, root_kind)
|
||||
if (
|
||||
level == 4
|
||||
and unit_class_open
|
||||
and declaration in CLASS_DECLARATIONS
|
||||
):
|
||||
kind, binding = CLASS_DECLARATIONS[declaration]
|
||||
return _valid_heading(level, kind, binding, signature, root_kind)
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"unit class 成员必须位于所属 class 的 H4",
|
||||
)
|
||||
|
||||
return invalid_heading(
|
||||
level,
|
||||
name,
|
||||
signature,
|
||||
signature,
|
||||
root_kind,
|
||||
"function 顶级 API 不能包含 API 成员",
|
||||
)
|
||||
|
||||
|
||||
def collect_headings_and_boundaries(lines):
|
||||
flags = fence_flags(lines)
|
||||
headings = []
|
||||
h2_boundaries = []
|
||||
root_kind = ""
|
||||
unit_class_open = False
|
||||
|
||||
for index, line in enumerate(lines):
|
||||
if flags[index]:
|
||||
continue
|
||||
|
||||
if PLAIN_H2_RE.match(line):
|
||||
h2_boundaries.append(index)
|
||||
root_kind = ""
|
||||
unit_class_open = False
|
||||
top_level = TOP_LEVEL_RE.match(line)
|
||||
if not top_level:
|
||||
continue
|
||||
signature = top_level.group(1)
|
||||
end = heading_body_end(lines, index, flags)
|
||||
declaration, error = _declaration(
|
||||
lines, index, end, flags
|
||||
)
|
||||
heading = top_level_heading(signature, declaration, error)
|
||||
if heading.valid:
|
||||
root_kind = heading.kind
|
||||
headings.append((index, heading))
|
||||
continue
|
||||
|
||||
if not root_kind:
|
||||
continue
|
||||
|
||||
heading_match = HEADING_RE.match(line)
|
||||
if root_kind == "unit" and heading_match:
|
||||
if len(heading_match.group(1)) == 3:
|
||||
unit_class_open = False
|
||||
|
||||
legacy = LEGACY_TYPED_RE.match(line)
|
||||
if legacy:
|
||||
level = len(legacy.group(1))
|
||||
signature = legacy.group(3)
|
||||
headings.append(
|
||||
(
|
||||
index,
|
||||
invalid_heading(
|
||||
level,
|
||||
simple_name(signature),
|
||||
signature,
|
||||
line.lstrip("# "),
|
||||
root_kind,
|
||||
"API 标题只写名称或调用签名,声明种类写在声明行",
|
||||
),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
bare = BARE_API_RE.match(line)
|
||||
if not bare:
|
||||
continue
|
||||
level = len(bare.group(1))
|
||||
signature = bare.group(2)
|
||||
end = heading_body_end(lines, index, flags)
|
||||
declaration, error = _declaration(lines, index, end, flags)
|
||||
heading = member_heading(
|
||||
level,
|
||||
declaration,
|
||||
signature,
|
||||
root_kind,
|
||||
unit_class_open,
|
||||
error,
|
||||
)
|
||||
if root_kind == "unit" and level == 3:
|
||||
unit_class_open = heading.valid and heading.kind == "class"
|
||||
headings.append((index, heading))
|
||||
|
||||
return headings, h2_boundaries
|
||||
|
||||
|
||||
def collect_headings(lines):
|
||||
headings, _ = collect_headings_and_boundaries(lines)
|
||||
return headings
|
||||
|
||||
|
||||
def iter_api_entries(lines):
|
||||
headings, h2_boundaries = collect_headings_and_boundaries(lines)
|
||||
for index, (start, heading) in enumerate(headings):
|
||||
next_heading = (
|
||||
headings[index + 1][0]
|
||||
if index + 1 < len(headings)
|
||||
else len(lines)
|
||||
)
|
||||
boundary_index = bisect_right(h2_boundaries, start)
|
||||
next_h2 = (
|
||||
h2_boundaries[boundary_index]
|
||||
if boundary_index < len(h2_boundaries)
|
||||
else len(lines)
|
||||
)
|
||||
yield ApiEntry(heading, start, min(next_heading, next_h2))
|
||||
|
||||
|
||||
def slug(text):
|
||||
return re.sub(r"[^a-z0-9_]", "", text.casefold())
|
||||
@@ -1,23 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild the bundled TSL API function index from the codegen markdown tree.
|
||||
"""Rebuild the bundled TSL API index from the codegen markdown tree.
|
||||
|
||||
The markdown tree is the source of truth: each `## `sig`` / `### `sig`` heading
|
||||
is one function entry. The TSV is a derived product; regenerate it whenever the
|
||||
leaf Markdown changes rather than editing it by hand.
|
||||
The markdown tree is the source of truth. The TSV is a derived product;
|
||||
regenerate it whenever the leaf Markdown changes rather than editing it by
|
||||
hand.
|
||||
|
||||
Columns (tab-separated, LF line endings, UTF-8):
|
||||
name scope module signature page anchor tags summary
|
||||
kind binding visibility owner qualified_name
|
||||
- name: signature text up to the first '('
|
||||
- scope: first path segment under the codegen root (for example project)
|
||||
- module: second path segment for nested pages, else the flat file stem
|
||||
- signature: verbatim from the heading, backticks stripped
|
||||
- page: POSIX path relative to the codegen root
|
||||
- anchor: GitHub-style slug of the name (lowercased, chars outside
|
||||
[a-z0-9_] removed); per-page duplicate slugs get -1/-2 suffixes
|
||||
in document order, matching the rendered heading anchors.
|
||||
- anchor: top-level declarations use the historic name slug; typed members
|
||||
slug the complete visible API title. Per-page duplicate slugs get
|
||||
-1/-2 suffixes in document order.
|
||||
- tags: space-separated keywords from `<!-- tags: ... -->`
|
||||
- summary: first prose line under the entry heading, empty for table,
|
||||
heading, or standalone return-type lines
|
||||
- summary: first prose line under the entry heading
|
||||
- kind: function/class/method/property/field/constant/unit/variable
|
||||
- binding: instance/class/static/unit, or empty when not applicable
|
||||
- visibility: public/protected for class members; public for unit interface
|
||||
- owner: dot-separated containing API path, excluding the entry name
|
||||
- qualified_name: dot-separated stable API identity
|
||||
|
||||
Usage (run from repo root; --skill-dir is required):
|
||||
SKILL=skills/tsl-api-reference
|
||||
@@ -31,9 +36,22 @@ import sys
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
ENTRY_RE = re.compile(r"^#{2,3}(?!#)\s+`(.+?)`\s*$")
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from api_markdown import DECLARATION_LINE_RE, iter_api_entries, slug
|
||||
|
||||
|
||||
RETURN_RE = re.compile(r"^返回[::]")
|
||||
DECLARATION_TYPE_RE = re.compile(
|
||||
r"^类型[::]\s*(function|class|unit)\s*$", re.IGNORECASE
|
||||
)
|
||||
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
|
||||
VISIBILITY_RE = re.compile(
|
||||
r"^可见性[::]\s*`?(public|protected|private)`?\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
HEADER = [
|
||||
"name",
|
||||
"scope",
|
||||
@@ -43,29 +61,56 @@ HEADER = [
|
||||
"anchor",
|
||||
"tags",
|
||||
"summary",
|
||||
"kind",
|
||||
"binding",
|
||||
"visibility",
|
||||
"owner",
|
||||
"qualified_name",
|
||||
]
|
||||
|
||||
|
||||
def slug(name):
|
||||
"""GitHub-style anchor slug: lowercase, keep [a-z0-9_], drop the rest."""
|
||||
return re.sub(r"[^a-z0-9_]", "", name.lower())
|
||||
|
||||
|
||||
def extract_metadata(lines, heading_idx):
|
||||
"""Return tags and the first prose line under a function entry heading."""
|
||||
def extract_metadata(lines, heading_idx, end_idx=None):
|
||||
"""Return tags and the first prose line under one API heading."""
|
||||
tags = ""
|
||||
for line in lines[heading_idx + 1:]:
|
||||
summary = ""
|
||||
summary_open = True
|
||||
for line in lines[heading_idx + 1:end_idx]:
|
||||
text = line.strip()
|
||||
if not text:
|
||||
continue
|
||||
if DECLARATION_LINE_RE.fullmatch(text):
|
||||
continue
|
||||
tag_match = TAGS_RE.match(text)
|
||||
if tag_match:
|
||||
tags = " ".join(tag_match.group(1).split()).replace("\t", " ")
|
||||
continue
|
||||
if text.startswith("|") or text.startswith("#") or RETURN_RE.match(text):
|
||||
return tags, ""
|
||||
return tags, text.replace("\t", " ")
|
||||
return tags, ""
|
||||
if summary:
|
||||
continue
|
||||
if (
|
||||
text.startswith("|")
|
||||
or text.startswith("#")
|
||||
or RETURN_RE.match(text)
|
||||
or DECLARATION_TYPE_RE.match(text)
|
||||
):
|
||||
summary_open = False
|
||||
continue
|
||||
if summary_open:
|
||||
summary = text.replace("\t", " ")
|
||||
return tags, summary
|
||||
|
||||
|
||||
def extract_visibility(lines, start, end):
|
||||
for line in lines[start + 1:end]:
|
||||
match = VISIBILITY_RE.match(line.strip())
|
||||
if match:
|
||||
return match.group(1).casefold()
|
||||
return ""
|
||||
|
||||
|
||||
def next_anchor(base, seen):
|
||||
count = seen.get(base, 0)
|
||||
seen[base] = count + 1
|
||||
return base if count == 0 else f"{base}-{count}"
|
||||
|
||||
|
||||
def parse_page(codegen_root, md):
|
||||
@@ -75,18 +120,69 @@ def parse_page(codegen_root, md):
|
||||
seen = {}
|
||||
rows = []
|
||||
lines = md.read_text(encoding="utf-8").splitlines()
|
||||
for idx, line in enumerate(lines):
|
||||
m = ENTRY_RE.match(line)
|
||||
if not m:
|
||||
entries = list(iter_api_entries(lines))
|
||||
root_kind = ""
|
||||
root_name = ""
|
||||
unit_class_owner = ""
|
||||
|
||||
for entry in entries:
|
||||
heading = entry.heading
|
||||
if not heading.valid:
|
||||
continue
|
||||
sig = m.group(1)
|
||||
name = sig.split("(", 1)[0]
|
||||
base = slug(name)
|
||||
n = seen.get(base, 0)
|
||||
seen[base] = n + 1
|
||||
anchor = base if n == 0 else f"{base}-{n}"
|
||||
tags, summary = extract_metadata(lines, idx)
|
||||
rows.append([name, scope, module, sig, page, anchor, tags, summary])
|
||||
|
||||
if heading.level == 2:
|
||||
root_kind = heading.kind
|
||||
root_name = heading.name
|
||||
unit_class_owner = ""
|
||||
owner = ""
|
||||
anchor_base = slug(heading.name)
|
||||
elif root_kind == "class":
|
||||
owner = root_name
|
||||
anchor_base = slug(heading.visible_title)
|
||||
elif root_kind == "unit" and heading.level == 3:
|
||||
anchor_base = slug(heading.visible_title)
|
||||
owner = root_name
|
||||
unit_class_owner = (
|
||||
f"{root_name}.{heading.name}"
|
||||
if heading.kind == "class"
|
||||
else ""
|
||||
)
|
||||
elif root_kind == "unit" and heading.level == 4:
|
||||
owner = unit_class_owner
|
||||
anchor_base = slug(heading.visible_title)
|
||||
else:
|
||||
continue
|
||||
|
||||
qualified_name = (
|
||||
f"{owner}.{heading.name}" if owner else heading.name
|
||||
)
|
||||
if root_kind == "unit" and heading.level == 3:
|
||||
visibility = "public"
|
||||
elif (
|
||||
root_kind == "class" and heading.level == 3
|
||||
) or (root_kind == "unit" and heading.level == 4):
|
||||
visibility = extract_visibility(lines, entry.start, entry.end)
|
||||
else:
|
||||
visibility = ""
|
||||
|
||||
tags, summary = extract_metadata(lines, entry.start, entry.end)
|
||||
rows.append(
|
||||
[
|
||||
heading.name,
|
||||
scope,
|
||||
module,
|
||||
heading.signature,
|
||||
page,
|
||||
next_anchor(anchor_base, seen),
|
||||
tags,
|
||||
summary,
|
||||
heading.kind,
|
||||
heading.binding,
|
||||
visibility,
|
||||
owner,
|
||||
qualified_name,
|
||||
]
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -120,7 +216,16 @@ def read_tsv(tsv_path):
|
||||
def main(argv=None):
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__.splitlines()[0],
|
||||
add_help=False,
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--help",
|
||||
action="help",
|
||||
help="show this help message and exit (no -h short option)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skill-dir",
|
||||
required=True,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,17 +2,20 @@
|
||||
"""Generate compliant TSL codegen markdown from a YAML/JSON entry file.
|
||||
|
||||
The recording format is one leaf page: a `module` title, a relative `path`, and
|
||||
a `functions` list. This script renders it to the markdown the codegen tree
|
||||
stores, matching tools/tsl-codegen/STANDARD.md.
|
||||
an ordered `declarations` list containing function, class, or unit entries. This
|
||||
script renders it to the markdown the codegen tree stores, matching
|
||||
tools/tsl-codegen/STANDARD.md.
|
||||
|
||||
Tables are emitted as valid Markdown with single-space padding. Prettier may be
|
||||
used optionally to align columns.
|
||||
Rendered Markdown is passed through the repository-pinned Prettier before it is
|
||||
written, keeping generated pages consistent with the existing codegen tree.
|
||||
|
||||
Input dispatch is by extension: .json parses with the stdlib (keeping the
|
||||
toolchain dependency-free); .yml/.yaml needs pyyaml. If pyyaml is missing the
|
||||
script says so and points at the JSON path.
|
||||
|
||||
Entry schema (per function):
|
||||
Function declaration fields:
|
||||
kind required `function`
|
||||
name required must match the signature name case-insensitively
|
||||
signature required verbatim, underscores/case untouched
|
||||
desc required description; may contain multiple lines
|
||||
tags optional list of Chinese keywords -> `<!-- tags: ... -->`
|
||||
@@ -27,11 +30,20 @@ Usage (run from repo root):
|
||||
python tools/tsl-codegen/scripts/generate.py entry.json \
|
||||
--scope my-project
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
PRETTIER_CONFIG = REPO_ROOT / ".prettierrc.json"
|
||||
|
||||
|
||||
def die(msg):
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -53,10 +65,7 @@ def resolve_format(path, fmt):
|
||||
return "json"
|
||||
if suffix in (".yml", ".yaml"):
|
||||
return "yaml"
|
||||
die(
|
||||
f"cannot infer format from extension '{suffix}'; "
|
||||
f"pass --format json|yaml"
|
||||
)
|
||||
die(f"cannot infer format from extension '{suffix}'; " f"pass --format json|yaml")
|
||||
|
||||
|
||||
def load_entries(path, fmt=None):
|
||||
@@ -96,6 +105,322 @@ def require(cond, msg):
|
||||
die(msg)
|
||||
|
||||
|
||||
def require_mapping(value, where):
|
||||
require(isinstance(value, dict), f"{where}: must be a mapping")
|
||||
|
||||
|
||||
def reject_unknown(mapping, allowed, where):
|
||||
unknown = sorted(set(mapping) - set(allowed))
|
||||
require(not unknown, f"{where}: unknown field(s): {', '.join(unknown)}")
|
||||
|
||||
|
||||
def non_empty_string(value, where):
|
||||
require(isinstance(value, str) and value.strip(), f"{where}: must be non-empty")
|
||||
|
||||
|
||||
def optional_draft_string(value, where):
|
||||
if value == "":
|
||||
return
|
||||
non_empty_string(value, where)
|
||||
|
||||
|
||||
def validate_tags(tags, where):
|
||||
if tags is None:
|
||||
return
|
||||
require(isinstance(tags, list) and tags, f"{where}: tags must be a non-empty list")
|
||||
for index, tag in enumerate(tags):
|
||||
non_empty_string(tag, f"{where}: tags[{index}]")
|
||||
|
||||
|
||||
def signature_names(signature, where):
|
||||
non_empty_string(signature, f"{where}: signature")
|
||||
left = signature.find("(")
|
||||
right = signature.rfind(")")
|
||||
require(left > 0 and right == len(signature) - 1, f"{where}: invalid signature")
|
||||
name = signature[:left]
|
||||
non_empty_string(name, f"{where}: signature name")
|
||||
raw = signature[left + 1 : right].strip()
|
||||
if not raw:
|
||||
return name, []
|
||||
names = [item.strip() for item in raw.split(",")]
|
||||
require(all(names), f"{where}: signature contains an empty parameter")
|
||||
require(len({item.casefold() for item in names}) == len(names), f"{where}: duplicate parameter name")
|
||||
return name, names
|
||||
|
||||
|
||||
def validate_values(values, where):
|
||||
require(isinstance(values, list) and values, f"{where}: values must be a non-empty list")
|
||||
for index, item in enumerate(values):
|
||||
item_where = f"{where}[{index}]"
|
||||
require_mapping(item, item_where)
|
||||
reject_unknown(item, {"value", "desc"}, item_where)
|
||||
require("value" in item, f"{item_where}: missing 'value'")
|
||||
non_empty_string(item.get("desc"), f"{item_where}: desc")
|
||||
|
||||
|
||||
def validate_params(params, expected_names, where):
|
||||
if not expected_names:
|
||||
require(not params, f"{where}: nullary signature must not have params")
|
||||
return
|
||||
require(isinstance(params, list), f"{where}: params must be a list")
|
||||
require(len(params) == len(expected_names), f"{where}: params do not match signature")
|
||||
actual_names = []
|
||||
for index, param in enumerate(params):
|
||||
param_where = f"{where}: params[{index}]"
|
||||
require_mapping(param, param_where)
|
||||
reject_unknown(param, {"name", "type", "desc", "optional", "values"}, param_where)
|
||||
name = param.get("name")
|
||||
non_empty_string(name, f"{param_where}: name")
|
||||
non_empty_string(param.get("type"), f"{param_where}: type")
|
||||
non_empty_string(param.get("desc"), f"{param_where}: desc")
|
||||
if "optional" in param:
|
||||
require(isinstance(param["optional"], bool), f"{param_where}: optional must be boolean")
|
||||
if "values" in param:
|
||||
validate_values(param["values"], f"{param_where}: values")
|
||||
actual_names.append(name)
|
||||
require(
|
||||
[name.casefold() for name in actual_names]
|
||||
== [name.casefold() for name in expected_names],
|
||||
f"{where}: params must follow signature order",
|
||||
)
|
||||
|
||||
|
||||
def validate_examples(examples, where):
|
||||
require(isinstance(examples, list) and examples, f"{where}: examples must be a non-empty list")
|
||||
for index, example in enumerate(examples):
|
||||
example_where = f"{where}: examples[{index}]"
|
||||
require_mapping(example, example_where)
|
||||
reject_unknown(example, {"desc", "code", "output"}, example_where)
|
||||
non_empty_string(example.get("desc"), f"{example_where}: desc")
|
||||
non_empty_string(example.get("code"), f"{example_where}: code")
|
||||
if "output" in example:
|
||||
non_empty_string(example["output"], f"{example_where}: output")
|
||||
|
||||
|
||||
def validate_function(fn, where, *, returns_required, extra_fields=()):
|
||||
require_mapping(fn, where)
|
||||
allowed = {
|
||||
"signature",
|
||||
"desc",
|
||||
"tags",
|
||||
"params",
|
||||
"returns",
|
||||
"example",
|
||||
"examples",
|
||||
*extra_fields,
|
||||
}
|
||||
reject_unknown(fn, allowed, where)
|
||||
name, names = signature_names(fn.get("signature"), where)
|
||||
non_empty_string(fn.get("desc"), f"{where}: desc")
|
||||
validate_tags(fn.get("tags"), where)
|
||||
validate_params(fn.get("params"), names, where)
|
||||
if returns_required:
|
||||
non_empty_string(fn.get("returns"), f"{where}: missing 'returns'")
|
||||
elif "returns" in fn:
|
||||
optional_draft_string(fn["returns"], f"{where}: returns")
|
||||
require(not ("example" in fn and "examples" in fn), f"{where}: use example or examples, not both")
|
||||
if "example" in fn:
|
||||
non_empty_string(fn["example"], f"{where}: example")
|
||||
if "examples" in fn:
|
||||
validate_examples(fn["examples"], where)
|
||||
return name
|
||||
|
||||
|
||||
def validate_class_member(member, where):
|
||||
require_mapping(member, where)
|
||||
kind = member.get("kind")
|
||||
require(kind in {"method", "property", "field", "constant"}, f"{where}: unknown kind '{kind}'")
|
||||
non_empty_string(member.get("name"), f"{where}: name")
|
||||
visibility = member.get("visibility")
|
||||
require(visibility in {"public", "protected"}, f"{where}: visibility must be public or protected")
|
||||
non_empty_string(member.get("desc"), f"{where}: desc")
|
||||
validate_tags(member.get("tags"), where)
|
||||
|
||||
if kind == "method":
|
||||
reject_unknown(
|
||||
member,
|
||||
{
|
||||
"kind", "name", "visibility", "binding", "signature", "desc",
|
||||
"tags", "params", "returns", "modifiers", "example", "examples",
|
||||
},
|
||||
where,
|
||||
)
|
||||
require(member.get("binding") in {"instance", "class"}, f"{where}: invalid binding")
|
||||
parsed_name = validate_function(
|
||||
member,
|
||||
where,
|
||||
returns_required=False,
|
||||
extra_fields={"kind", "name", "visibility", "binding", "modifiers"},
|
||||
)
|
||||
require(parsed_name.casefold() == member["name"].casefold(), f"{where}: name and signature differ")
|
||||
if "modifiers" in member:
|
||||
modifiers = member["modifiers"]
|
||||
require(isinstance(modifiers, list), f"{where}: modifiers must be a list")
|
||||
allowed = {"overload", "virtual", "override"}
|
||||
require(all(item in allowed for item in modifiers), f"{where}: invalid modifier")
|
||||
require(len(set(modifiers)) == len(modifiers), f"{where}: duplicate modifier")
|
||||
return
|
||||
|
||||
common = {"kind", "name", "visibility", "desc", "tags"}
|
||||
if kind == "property":
|
||||
reject_unknown(member, common | {"type", "params", "access"}, where)
|
||||
if "type" in member:
|
||||
optional_draft_string(member["type"], f"{where}: type")
|
||||
require(member.get("access") in {"read", "write", "readwrite"}, f"{where}: invalid access")
|
||||
params = member.get("params")
|
||||
if params:
|
||||
expected = [param.get("name") for param in params]
|
||||
validate_params(params, expected, where)
|
||||
return
|
||||
if kind == "field":
|
||||
reject_unknown(member, common | {"type", "static"}, where)
|
||||
non_empty_string(member.get("type"), f"{where}: type")
|
||||
if "static" in member:
|
||||
require(isinstance(member["static"], bool), f"{where}: static must be boolean")
|
||||
return
|
||||
reject_unknown(member, common | {"type", "value", "static"}, where)
|
||||
require("value" in member and member["value"] is not None, f"{where}: missing 'value'")
|
||||
if isinstance(member["value"], str):
|
||||
non_empty_string(member["value"], f"{where}: value")
|
||||
if "type" in member:
|
||||
optional_draft_string(member["type"], f"{where}: type")
|
||||
if "static" in member:
|
||||
require(isinstance(member["static"], bool), f"{where}: static must be boolean")
|
||||
|
||||
|
||||
def validate_class(cls, where):
|
||||
require_mapping(cls, where)
|
||||
reject_unknown(cls, {"kind", "name", "desc", "tags", "bases", "members"}, where)
|
||||
require(cls.get("kind") == "class", f"{where}: kind must be class")
|
||||
non_empty_string(cls.get("name"), f"{where}: name")
|
||||
non_empty_string(cls.get("desc"), f"{where}: desc")
|
||||
validate_tags(cls.get("tags"), where)
|
||||
if "bases" in cls:
|
||||
require(isinstance(cls["bases"], list), f"{where}: bases must be a list")
|
||||
for index, base in enumerate(cls["bases"]):
|
||||
non_empty_string(base, f"{where}: bases[{index}]")
|
||||
require(isinstance(cls.get("members"), list), f"{where}: members must be a list")
|
||||
for index, member in enumerate(cls["members"]):
|
||||
validate_class_member(member, f"{where}: members[{index}]")
|
||||
|
||||
|
||||
def validate_unit_member(member, where):
|
||||
require_mapping(member, where)
|
||||
kind = member.get("kind")
|
||||
require(kind in {"function", "variable", "constant", "class"}, f"{where}: unknown kind '{kind}'")
|
||||
if kind == "class":
|
||||
validate_class(member, where)
|
||||
return
|
||||
non_empty_string(member.get("name"), f"{where}: name")
|
||||
non_empty_string(member.get("desc"), f"{where}: desc")
|
||||
validate_tags(member.get("tags"), where)
|
||||
if kind == "function":
|
||||
parsed_name = validate_function(
|
||||
member,
|
||||
where,
|
||||
returns_required=True,
|
||||
extra_fields={"kind", "name"},
|
||||
)
|
||||
require(parsed_name.casefold() == member["name"].casefold(), f"{where}: name and signature differ")
|
||||
return
|
||||
common = {"kind", "name", "desc", "tags", "type"}
|
||||
if kind == "variable":
|
||||
reject_unknown(member, common, where)
|
||||
non_empty_string(member.get("type"), f"{where}: type")
|
||||
return
|
||||
reject_unknown(member, common | {"value"}, where)
|
||||
require("value" in member and member["value"] is not None, f"{where}: missing 'value'")
|
||||
if isinstance(member["value"], str):
|
||||
non_empty_string(member["value"], f"{where}: value")
|
||||
if "type" in member:
|
||||
optional_draft_string(member["type"], f"{where}: type")
|
||||
|
||||
|
||||
def validate_unit(unit, where):
|
||||
require_mapping(unit, where)
|
||||
reject_unknown(unit, {"kind", "name", "desc", "tags", "members"}, where)
|
||||
require(unit.get("kind") == "unit", f"{where}: kind must be unit")
|
||||
non_empty_string(unit.get("name"), f"{where}: name")
|
||||
non_empty_string(unit.get("desc"), f"{where}: desc")
|
||||
validate_tags(unit.get("tags"), where)
|
||||
require(
|
||||
isinstance(unit.get("members"), list),
|
||||
f"{where}: members must be a list",
|
||||
)
|
||||
for index, member in enumerate(unit["members"]):
|
||||
validate_unit_member(member, f"{where}: members[{index}]")
|
||||
|
||||
|
||||
def validate_top_level_function(declaration, where):
|
||||
require(
|
||||
declaration.get("kind") == "function",
|
||||
f"{where}: kind must be function",
|
||||
)
|
||||
non_empty_string(declaration.get("name"), f"{where}: name")
|
||||
parsed_name = validate_function(
|
||||
declaration,
|
||||
where,
|
||||
returns_required=True,
|
||||
extra_fields={"kind", "name"},
|
||||
)
|
||||
require(
|
||||
parsed_name.casefold() == declaration["name"].casefold(),
|
||||
f"{where}: name and signature differ",
|
||||
)
|
||||
|
||||
|
||||
def validate_declaration(declaration, where):
|
||||
require_mapping(declaration, where)
|
||||
kind = declaration.get("kind")
|
||||
require(
|
||||
kind in {"function", "class", "unit"},
|
||||
f"{where}: unknown kind '{kind}'",
|
||||
)
|
||||
if kind == "function":
|
||||
validate_top_level_function(declaration, where)
|
||||
elif kind == "class":
|
||||
validate_class(declaration, where)
|
||||
else:
|
||||
validate_unit(declaration, where)
|
||||
|
||||
|
||||
def validate_declaration_uniqueness(declarations):
|
||||
function_signatures = set()
|
||||
class_names = set()
|
||||
unit_names = set()
|
||||
for index, declaration in enumerate(declarations):
|
||||
where = f"declarations[{index}]"
|
||||
kind = declaration["kind"]
|
||||
if kind == "function":
|
||||
key = declaration["signature"].casefold()
|
||||
require(
|
||||
key not in function_signatures,
|
||||
f"{where}: duplicate function signature",
|
||||
)
|
||||
function_signatures.add(key)
|
||||
continue
|
||||
names = class_names if kind == "class" else unit_names
|
||||
key = declaration["name"].casefold()
|
||||
require(key not in names, f"{where}: duplicate {kind} name")
|
||||
names.add(key)
|
||||
|
||||
|
||||
def validate_page(data):
|
||||
require_mapping(data, "input root")
|
||||
reject_unknown(data, {"module", "path", "declarations"}, "input root")
|
||||
non_empty_string(data.get("module"), "input root: module")
|
||||
non_empty_string(data.get("path"), "input root: path")
|
||||
declarations = data.get("declarations")
|
||||
require(
|
||||
isinstance(declarations, list) and declarations,
|
||||
"input root: declarations must be a non-empty list",
|
||||
)
|
||||
for index, declaration in enumerate(declarations):
|
||||
validate_declaration(declaration, f"declarations[{index}]")
|
||||
validate_declaration_uniqueness(declarations)
|
||||
return declarations
|
||||
|
||||
|
||||
def param_desc(param, where):
|
||||
"""Description column text: prepend `可选。` for optional params."""
|
||||
desc = param.get("desc")
|
||||
@@ -120,14 +445,17 @@ def render_param_table(params, where):
|
||||
return lines
|
||||
|
||||
|
||||
def render_enum_sections(params):
|
||||
"""`**name 取值**` sections for every param carrying a `values` list."""
|
||||
def render_enum_sections(params, heading_level=None):
|
||||
"""Render value sections, preserving legacy function-page headings."""
|
||||
lines = []
|
||||
for param in params:
|
||||
values = param.get("values")
|
||||
if not values:
|
||||
continue
|
||||
lines.append(f"**{param['name']} 取值**")
|
||||
if heading_level is None:
|
||||
lines.append(f"**{param['name']} 取值**")
|
||||
else:
|
||||
lines.append(f"{'#' * heading_level} `{param['name']}` 取值")
|
||||
lines.append("")
|
||||
for item in values:
|
||||
lines.append(f"- `{item['value']}` — {item['desc']}")
|
||||
@@ -135,23 +463,89 @@ def render_enum_sections(params):
|
||||
return lines
|
||||
|
||||
|
||||
def render_function(fn, index):
|
||||
"""Render one function entry to a list of lines (no trailing blank)."""
|
||||
where = f"functions[{index}]"
|
||||
sig = fn.get("signature")
|
||||
require(sig, f"{where}: missing 'signature'")
|
||||
desc = fn.get("desc")
|
||||
require(desc, f"{where} ({sig}): missing 'desc'")
|
||||
returns = fn.get("returns")
|
||||
require(returns, f"{where} ({sig}): missing 'returns'")
|
||||
def render_examples(fn, heading_level):
|
||||
lines = []
|
||||
if "examples" in fn:
|
||||
lines.extend([f"{'#' * heading_level} 示例", ""])
|
||||
for index, example in enumerate(fn["examples"], start=1):
|
||||
lines.append(f"范例{index:02d}:{example['desc']}")
|
||||
lines.append("")
|
||||
lines.append("```tsl")
|
||||
lines.extend(example["code"].rstrip("\n").split("\n"))
|
||||
output = example.get("output")
|
||||
if output is not None:
|
||||
output_lines = output.split("\n")
|
||||
if len(output_lines) == 1:
|
||||
lines.append(f"// 输出:{output_lines[0]}")
|
||||
else:
|
||||
lines.append("// 输出:")
|
||||
lines.extend(f"// {line}" if line else "//" for line in output_lines)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
return lines
|
||||
if fn.get("example"):
|
||||
return [
|
||||
f"{'#' * heading_level} 示例",
|
||||
"",
|
||||
"```tsl",
|
||||
*fn["example"].rstrip("\n").split("\n"),
|
||||
"```",
|
||||
]
|
||||
return []
|
||||
|
||||
lines = [f"## `{sig}`"]
|
||||
tags = fn.get("tags")
|
||||
|
||||
def render_intro(heading, declaration, item):
|
||||
lines = [heading, "", f"声明:{declaration}", "", item["desc"], ""]
|
||||
tags = item.get("tags")
|
||||
if tags:
|
||||
lines.append(f"<!-- tags: {' '.join(str(t) for t in tags)} -->")
|
||||
lines.append("")
|
||||
lines.append(desc)
|
||||
lines.append("")
|
||||
lines.extend([f"<!-- tags: {' '.join(tags)} -->", ""])
|
||||
return lines
|
||||
|
||||
|
||||
def render_callable(
|
||||
fn,
|
||||
signature,
|
||||
declaration,
|
||||
where,
|
||||
*,
|
||||
level,
|
||||
returns_required,
|
||||
show_visibility,
|
||||
):
|
||||
validate_function(
|
||||
fn,
|
||||
where,
|
||||
returns_required=returns_required,
|
||||
extra_fields={"kind", "name", "visibility", "binding", "modifiers"},
|
||||
)
|
||||
lines = render_intro(
|
||||
f"{'#' * level} `{signature}`", declaration, fn
|
||||
)
|
||||
if show_visibility:
|
||||
lines.extend([f"可见性:`{fn['visibility']}`", ""])
|
||||
if fn.get("modifiers"):
|
||||
rendered = "、".join(f"`{item}`" for item in fn["modifiers"])
|
||||
lines.extend([f"修饰符:{rendered}", ""])
|
||||
params = fn.get("params") or []
|
||||
if params:
|
||||
lines.extend(render_param_table(params, where))
|
||||
lines.append("")
|
||||
lines.extend(render_enum_sections(params, level + 1))
|
||||
if fn.get("returns"):
|
||||
lines.append(f"返回:{fn['returns']}")
|
||||
example_lines = render_examples(fn, level + 1)
|
||||
if example_lines:
|
||||
lines.extend(["", *example_lines])
|
||||
return lines
|
||||
|
||||
|
||||
def render_top_level_function(fn, index):
|
||||
"""Render one function entry to a list of lines (no trailing blank)."""
|
||||
where = f"declarations[{index}]"
|
||||
sig = fn["signature"]
|
||||
lines = render_intro(f"## `{sig}`", "function", fn)
|
||||
|
||||
params = fn.get("params") or []
|
||||
if params:
|
||||
@@ -159,34 +553,168 @@ def render_function(fn, index):
|
||||
lines.append("")
|
||||
lines.extend(render_enum_sections(params))
|
||||
|
||||
lines.append(f"返回:{returns}")
|
||||
|
||||
example = fn.get("example")
|
||||
if example:
|
||||
lines.append("")
|
||||
lines.append("### 示例")
|
||||
lines.append("")
|
||||
lines.append("```tsl")
|
||||
lines.extend(example.rstrip("\n").split("\n"))
|
||||
lines.append("```")
|
||||
lines.append(f"返回:{fn['returns']}")
|
||||
examples = render_examples(fn, 3)
|
||||
if examples:
|
||||
lines.extend(["", *examples])
|
||||
return lines
|
||||
|
||||
|
||||
def render_page(data):
|
||||
"""Render a whole leaf page: H1 + every function entry."""
|
||||
require(isinstance(data, dict), "input root must be a mapping")
|
||||
module = data.get("module")
|
||||
require(module, "input missing 'module'")
|
||||
functions = data.get("functions")
|
||||
require(functions, "input missing non-empty 'functions'")
|
||||
def render_class_member(member, level, where):
|
||||
kind = member["kind"]
|
||||
if kind == "method":
|
||||
declaration = (
|
||||
"class function"
|
||||
if member["binding"] == "class"
|
||||
else "function"
|
||||
)
|
||||
return render_callable(
|
||||
member,
|
||||
member["signature"],
|
||||
declaration,
|
||||
where,
|
||||
level=level,
|
||||
returns_required=False,
|
||||
show_visibility=True,
|
||||
)
|
||||
static_prefix = "static " if member.get("static") else ""
|
||||
declaration = {
|
||||
"property": "property",
|
||||
"field": f"{static_prefix}field",
|
||||
"constant": f"{static_prefix}const",
|
||||
}[kind]
|
||||
signature = member["name"]
|
||||
if kind == "property" and member.get("params"):
|
||||
signature += "(" + ", ".join(param["name"] for param in member["params"]) + ")"
|
||||
lines = render_intro(
|
||||
f"{'#' * level} `{signature}`", declaration, member
|
||||
)
|
||||
lines.extend([f"可见性:`{member['visibility']}`", ""])
|
||||
if kind == "property":
|
||||
if member.get("type"):
|
||||
lines.extend([f"类型:{member['type']}", ""])
|
||||
access = {"read": "read", "write": "write", "readwrite": "read / write"}[member["access"]]
|
||||
lines.append(f"访问:{access}")
|
||||
params = member.get("params") or []
|
||||
if params:
|
||||
lines.extend(["", *render_param_table(params, where), ""])
|
||||
lines.extend(render_enum_sections(params, level + 1))
|
||||
elif kind == "field":
|
||||
lines.append(f"类型:{member['type']}")
|
||||
else:
|
||||
if member.get("type"):
|
||||
lines.extend([f"类型:{member['type']}", ""])
|
||||
lines.append(f"值:`{render_scalar(member['value'])}`")
|
||||
return lines
|
||||
|
||||
out = [f"# {module}", ""]
|
||||
for index, fn in enumerate(functions):
|
||||
out.extend(render_function(fn, index))
|
||||
|
||||
def render_class(cls, level, where, *, page_root):
|
||||
lines = render_intro(
|
||||
f"{'#' * level} `{cls['name']}`", "class", cls
|
||||
)
|
||||
if cls.get("bases"):
|
||||
lines.extend(["父类:" + "、".join(f"`{base}`" for base in cls["bases"]), ""])
|
||||
for index, member in enumerate(cls["members"]):
|
||||
lines.extend(render_class_member(member, level + 1, f"{where}: members[{index}]"))
|
||||
lines.append("")
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
return lines
|
||||
|
||||
|
||||
def render_unit_member(member, index, unit_where):
|
||||
where = f"{unit_where}: members[{index}]"
|
||||
kind = member["kind"]
|
||||
if kind == "function":
|
||||
return render_callable(
|
||||
member,
|
||||
member["signature"],
|
||||
"function",
|
||||
where,
|
||||
level=3,
|
||||
returns_required=True,
|
||||
show_visibility=False,
|
||||
)
|
||||
if kind == "class":
|
||||
return render_class(member, 3, where, page_root=False)
|
||||
declaration = "var" if kind == "variable" else "const"
|
||||
lines = render_intro(
|
||||
f"### `{member['name']}`", declaration, member
|
||||
)
|
||||
if kind == "variable":
|
||||
lines.append(f"类型:{member['type']}")
|
||||
else:
|
||||
if member.get("type"):
|
||||
lines.extend([f"类型:{member['type']}", ""])
|
||||
lines.append(f"值:`{render_scalar(member['value'])}`")
|
||||
return lines
|
||||
|
||||
|
||||
def render_unit(unit, where):
|
||||
lines = render_intro(f"## `{unit['name']}`", "unit", unit)
|
||||
for index, member in enumerate(unit["members"]):
|
||||
lines.extend(render_unit_member(member, index, where))
|
||||
lines.append("")
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
return lines
|
||||
|
||||
|
||||
def render_scalar(value):
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def render_declaration(declaration, index):
|
||||
kind = declaration["kind"]
|
||||
where = f"declarations[{index}]"
|
||||
if kind == "function":
|
||||
return render_top_level_function(declaration, index)
|
||||
if kind == "class":
|
||||
return render_class(declaration, 2, where, page_root=True)
|
||||
return render_unit(declaration, where)
|
||||
|
||||
|
||||
def render_page(data):
|
||||
"""Validate and render a whole leaf page."""
|
||||
declarations = validate_page(data)
|
||||
out = [f"# {data['module']}", ""]
|
||||
for index, declaration in enumerate(declarations):
|
||||
out.extend(render_declaration(declaration, index))
|
||||
out.append("")
|
||||
return "\n".join(out).rstrip("\n") + "\n"
|
||||
|
||||
|
||||
def format_markdown(text):
|
||||
"""Format generated Markdown with the repository-pinned Prettier."""
|
||||
npx = shutil.which("npx")
|
||||
if not npx:
|
||||
die("未找到 Prettier;请先在仓库根目录运行 `npm install`")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
npx,
|
||||
"--no-install",
|
||||
"prettier",
|
||||
"--config",
|
||||
str(PRETTIER_CONFIG),
|
||||
"--parser",
|
||||
"markdown",
|
||||
],
|
||||
input=text,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=REPO_ROOT,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
detail = result.stderr.strip() or "未知错误"
|
||||
die(f"Prettier 格式化失败:{detail}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
def output_path(data, scope):
|
||||
"""Build the leaf-page destination from the recording file's relative path."""
|
||||
relative = data.get("path")
|
||||
@@ -204,10 +732,37 @@ def output_path(data, scope):
|
||||
)
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
"""Atomically replace path and remove the temporary file on failure."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
||||
)
|
||||
temporary_path = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary_path, path)
|
||||
finally:
|
||||
if temporary_path.exists():
|
||||
temporary_path.unlink()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="从 YAML/JSON 录入文件生成 TSL 函数文档")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="从 YAML/JSON 录入文件生成 TSL API 文档",
|
||||
add_help=False,
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--help",
|
||||
action="help",
|
||||
help="显示本帮助并退出(不提供 -h 短选项)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"input",
|
||||
metavar="INPUT_FILE",
|
||||
@@ -231,10 +786,9 @@ def main(argv=None):
|
||||
die(f"input not found: {in_path}")
|
||||
data = load_entries(in_path, args.format)
|
||||
|
||||
text = render_page(data)
|
||||
text = format_markdown(render_page(data))
|
||||
out_path = output_path(data, args.scope)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(text, encoding="utf-8", newline="\n")
|
||||
atomic_write(out_path, text)
|
||||
print(
|
||||
f"wrote {out_path}",
|
||||
file=sys.stderr,
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint TSL codegen function-doc markdown against the house standard.
|
||||
"""Lint TSL codegen API Markdown against the house standard.
|
||||
|
||||
The standard lives in tools/tsl-codegen/STANDARD.md.
|
||||
Each `## `sig`` / `### `sig`` heading starts one function entry. Rules split
|
||||
into hard errors (CI-blocking) and soft warnings (style
|
||||
convergence over the ~12k existing entries).
|
||||
Each typed H2 starts one top-level declaration; typed H3/H4 headings describe
|
||||
class or unit members. Rules split into hard errors (CI-blocking) and soft
|
||||
warnings (style convergence over the ~12k existing entries).
|
||||
|
||||
Hard errors:
|
||||
- missing/empty description (first prose line after the signature)
|
||||
- missing `返回:类型`
|
||||
- signature has parameters but the entry has no parameter table
|
||||
- signature has no parameters but a parameter table is present
|
||||
- parameter table header is not the fixed 参数 / 类型 / 说明 三列
|
||||
Hard errors include incomplete descriptions/metadata/parameter tables and
|
||||
invalid class/unit API headings, visibility, or child-heading levels.
|
||||
|
||||
Soft warnings:
|
||||
- optional-parameter wording not starting with `可选。`
|
||||
@@ -29,12 +25,26 @@ import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Entry heading: `## `sig`` or `### `sig``. Matches the index generator's rule
|
||||
# so the linter and the tsv agree on what a function entry is.
|
||||
ENTRY_RE = re.compile(r"^(#{2,3})(?!#)\s+`(.+?)`\s*$")
|
||||
RETURN_RE = re.compile(r"^返回[::]")
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
if str(SCRIPT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
from api_markdown import DECLARATION_LINE_RE, iter_api_entries
|
||||
|
||||
RETURN_LINE_RE = re.compile(r"^返回[::]")
|
||||
RETURN_RE = re.compile(r"^返回[::]\s*\S")
|
||||
TYPE_LINE_RE = re.compile(r"^类型[::]\s*(.*?)\s*$", re.IGNORECASE)
|
||||
TYPE_RE = re.compile(r"^类型[::]\s*\S")
|
||||
VALUE_LINE_RE = re.compile(r"^值[::]")
|
||||
VALUE_RE = re.compile(r"^值[::]\s*(?:`[^`]+`|[^`\s])")
|
||||
ACCESS_RE = re.compile(r"^访问[::]\s*(read|write|read\s*/\s*write)\s*$")
|
||||
VISIBILITY_RE = re.compile(r"^可见性[::]\s*`?(public|protected|private)`?\s*$")
|
||||
BASE_RE = re.compile(r"^父类[::]")
|
||||
MODIFIERS_RE = re.compile(r"^修饰符[::]")
|
||||
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->\s*$")
|
||||
FENCE_RE = re.compile(r"^(```|~~~)")
|
||||
HEADING_RE = re.compile(r"^(#{1,6})(?!#)\s+(.+?)\s*$")
|
||||
VALUE_HEADING_RE = re.compile(r"^`.+?`\s+取值$")
|
||||
OPTIONAL_HINT_RE = re.compile(r"可选|可省略|省略")
|
||||
# Split a table row on unescaped pipes so `nil\|array` stays one cell.
|
||||
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
||||
@@ -43,20 +53,6 @@ SEP_CELL_RE = re.compile(r"^:?-+:?$")
|
||||
PARAM_HEADER = ["参数", "类型", "说明"]
|
||||
|
||||
|
||||
def iter_entries(lines):
|
||||
"""Yield (start, end, signature): each entry spans one signature heading
|
||||
to the next. Category headings without backticks fall to the tail of the
|
||||
preceding entry (harmless — checks anchor on the entry's head)."""
|
||||
starts = [
|
||||
(idx, m.group(2))
|
||||
for idx, line in enumerate(lines)
|
||||
if (m := ENTRY_RE.match(line))
|
||||
]
|
||||
for i, (start, sig) in enumerate(starts):
|
||||
end = starts[i + 1][0] if i + 1 < len(starts) else len(lines)
|
||||
yield start, end, sig
|
||||
|
||||
|
||||
def scan_body(lines, start, end):
|
||||
"""Return [(lineno, raw, in_fence)] for the entry body (excludes the
|
||||
signature line). Fence delimiter lines are marked in_fence so callers
|
||||
@@ -82,6 +78,16 @@ def has_params(sig):
|
||||
return bool(sig[left + 1:right].strip())
|
||||
|
||||
|
||||
def signature_params(sig):
|
||||
"""Return the parameter names carried by a normalized API signature."""
|
||||
left = sig.find("(")
|
||||
right = sig.rfind(")")
|
||||
if left == -1 or right == -1 or right < left:
|
||||
return []
|
||||
raw = sig[left + 1:right].strip()
|
||||
return [item.strip() for item in raw.split(",")] if raw else []
|
||||
|
||||
|
||||
def split_row(text):
|
||||
"""Split a markdown table row into trimmed cells, honoring `\\|` escapes."""
|
||||
parts = CELL_SPLIT_RE.split(text.strip())
|
||||
@@ -130,16 +136,38 @@ def find_description(body):
|
||||
stripped = raw.strip()
|
||||
if not stripped or in_fence:
|
||||
continue
|
||||
if stripped.startswith("<!--"): # tags or other comment: skip
|
||||
if DECLARATION_LINE_RE.fullmatch(stripped):
|
||||
continue
|
||||
if stripped.startswith("|") or stripped.startswith("#") \
|
||||
or RETURN_RE.match(stripped):
|
||||
if (
|
||||
stripped.startswith("<!--")
|
||||
or stripped.startswith("声明:")
|
||||
or stripped.startswith("|")
|
||||
or stripped.startswith("#")
|
||||
or RETURN_LINE_RE.match(stripped)
|
||||
or TYPE_LINE_RE.match(stripped)
|
||||
or VALUE_LINE_RE.match(stripped)
|
||||
or ACCESS_RE.match(stripped)
|
||||
or VISIBILITY_RE.match(stripped)
|
||||
or BASE_RE.match(stripped)
|
||||
or MODIFIERS_RE.match(stripped)
|
||||
):
|
||||
return False, lineno
|
||||
return True, lineno
|
||||
return False, None
|
||||
|
||||
|
||||
def check_entry(md_display, lines, start, end, sig, findings):
|
||||
def check_entry(
|
||||
md_display,
|
||||
lines,
|
||||
start,
|
||||
end,
|
||||
sig,
|
||||
findings,
|
||||
*,
|
||||
returns_required=True,
|
||||
visibility_required=False,
|
||||
validate_parameter_rows=False,
|
||||
):
|
||||
entry_line = start + 1 # 1-based signature line, used for entry-level errors
|
||||
body = scan_body(lines, start, end)
|
||||
|
||||
@@ -155,10 +183,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
RETURN_RE.match(raw.strip())
|
||||
for _, raw, in_fence in body if not in_fence
|
||||
)
|
||||
if not has_return:
|
||||
if returns_required and not has_return:
|
||||
findings.append((md_display, entry_line, "error", "return",
|
||||
f"`{sig}` 缺少 `返回:类型` 行"))
|
||||
|
||||
visibilities = []
|
||||
for lineno, raw, in_fence in body:
|
||||
if in_fence:
|
||||
continue
|
||||
match = VISIBILITY_RE.match(raw.strip())
|
||||
if match:
|
||||
visibilities.append((lineno, match.group(1)))
|
||||
if visibility_required and not visibilities:
|
||||
findings.append(
|
||||
(
|
||||
md_display,
|
||||
entry_line,
|
||||
"error",
|
||||
"visibility",
|
||||
f"`{sig}` 缺少 `可见性:public|protected` 行",
|
||||
)
|
||||
)
|
||||
for lineno, visibility in visibilities:
|
||||
if visibility == "private":
|
||||
findings.append(
|
||||
(
|
||||
md_display,
|
||||
lineno + 1,
|
||||
"error",
|
||||
"visibility",
|
||||
"private API 不得进入文档",
|
||||
)
|
||||
)
|
||||
|
||||
# parameter table ------------------------------------------------------
|
||||
table = find_table(body)
|
||||
wants_params = has_params(sig)
|
||||
@@ -176,6 +233,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
"param-header",
|
||||
f"参数表表头须为 {' / '.join(PARAM_HEADER)},"
|
||||
f"实为 {' / '.join(header_cells) or '(空)'}"))
|
||||
elif validate_parameter_rows:
|
||||
actual_names = []
|
||||
rows_valid = True
|
||||
for lineno, cells in data_rows:
|
||||
if len(cells) != 3 or any(not cell.strip() for cell in cells):
|
||||
rows_valid = False
|
||||
findings.append(
|
||||
(
|
||||
md_display,
|
||||
lineno + 1,
|
||||
"error",
|
||||
"param-row",
|
||||
"参数表每行都必须包含非空的参数名、类型和说明",
|
||||
)
|
||||
)
|
||||
continue
|
||||
name_cell = cells[0]
|
||||
if name_cell.startswith("`") and name_cell.endswith("`"):
|
||||
name_cell = name_cell[1:-1]
|
||||
actual_names.append(name_cell.strip())
|
||||
expected_names = signature_params(sig)
|
||||
if rows_valid and [name.casefold() for name in actual_names] != [
|
||||
name.casefold() for name in expected_names
|
||||
]:
|
||||
findings.append(
|
||||
(
|
||||
md_display,
|
||||
header_lineno + 1,
|
||||
"error",
|
||||
"param-names",
|
||||
f"参数表名称/顺序必须与 `{sig}` 一致",
|
||||
)
|
||||
)
|
||||
# soft: optional-parameter wording
|
||||
for lineno, cells in data_rows:
|
||||
if len(cells) < 3:
|
||||
@@ -195,14 +285,211 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
||||
"空的 tags 行;填入关键词或删除"))
|
||||
|
||||
|
||||
def check_api_subheadings(md_display, lines, entry, findings):
|
||||
"""Enforce the fixed API child-heading levels and labels."""
|
||||
heading = entry.heading
|
||||
|
||||
allowed_titles = set()
|
||||
if heading.level == 2 and heading.root_kind == "function":
|
||||
allowed_titles.add("示例")
|
||||
allow_values = False
|
||||
elif heading.kind in {"function", "method"}:
|
||||
allowed_titles.add("示例")
|
||||
allow_values = True
|
||||
elif heading.kind == "property":
|
||||
allow_values = True
|
||||
else:
|
||||
allow_values = False
|
||||
expected_level = heading.level + 1
|
||||
|
||||
for lineno, raw, in_fence in scan_body(lines, entry.start, entry.end):
|
||||
if in_fence:
|
||||
continue
|
||||
match = HEADING_RE.match(raw.strip())
|
||||
if not match:
|
||||
continue
|
||||
level = len(match.group(1))
|
||||
title = match.group(2)
|
||||
title_allowed = title in allowed_titles or bool(
|
||||
allow_values and VALUE_HEADING_RE.match(title)
|
||||
)
|
||||
if level == expected_level and title_allowed:
|
||||
continue
|
||||
if not allow_values and not allowed_titles:
|
||||
message = f"{heading.kind} `{heading.signature}` 不得包含子标题"
|
||||
else:
|
||||
message = (
|
||||
f"{heading.kind} `{heading.signature}` 的子标题必须位于 "
|
||||
f"H{expected_level},且只能使用参数取值"
|
||||
+ ("或示例" if "示例" in allowed_titles else "")
|
||||
)
|
||||
findings.append(
|
||||
(md_display, lineno + 1, "error", "subheading", message)
|
||||
)
|
||||
|
||||
|
||||
def lint_file(md, root, findings):
|
||||
try:
|
||||
display = md.relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
display = str(md)
|
||||
lines = md.read_text(encoding="utf-8").splitlines()
|
||||
for start, end, sig in iter_entries(lines):
|
||||
check_entry(display, lines, start, end, sig, findings)
|
||||
for entry in iter_api_entries(lines):
|
||||
heading = entry.heading
|
||||
if not heading.valid:
|
||||
findings.append(
|
||||
(
|
||||
display,
|
||||
entry.start + 1,
|
||||
"error",
|
||||
"heading",
|
||||
heading.error,
|
||||
)
|
||||
)
|
||||
body = scan_body(lines, entry.start, entry.end)
|
||||
for lineno, raw, in_fence in body:
|
||||
if in_fence:
|
||||
continue
|
||||
visibility = VISIBILITY_RE.match(raw.strip())
|
||||
if visibility and visibility.group(1) == "private":
|
||||
findings.append(
|
||||
(
|
||||
display,
|
||||
lineno + 1,
|
||||
"error",
|
||||
"visibility",
|
||||
"private API 不得进入文档",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
kind = heading.kind
|
||||
check_api_subheadings(display, lines, entry, findings)
|
||||
if kind == "function":
|
||||
check_entry(
|
||||
display,
|
||||
lines,
|
||||
entry.start,
|
||||
entry.end,
|
||||
heading.signature,
|
||||
findings,
|
||||
validate_parameter_rows=heading.root_kind != "function",
|
||||
)
|
||||
continue
|
||||
if kind == "method":
|
||||
check_entry(
|
||||
display,
|
||||
lines,
|
||||
entry.start,
|
||||
entry.end,
|
||||
heading.signature,
|
||||
findings,
|
||||
returns_required=False,
|
||||
visibility_required=True,
|
||||
validate_parameter_rows=True,
|
||||
)
|
||||
continue
|
||||
if kind == "property":
|
||||
check_entry(
|
||||
display,
|
||||
lines,
|
||||
entry.start,
|
||||
entry.end,
|
||||
heading.signature,
|
||||
findings,
|
||||
returns_required=False,
|
||||
visibility_required=True,
|
||||
validate_parameter_rows=True,
|
||||
)
|
||||
body = scan_body(lines, entry.start, entry.end)
|
||||
empty_types = [
|
||||
lineno
|
||||
for lineno, raw, fenced in body
|
||||
if not fenced
|
||||
and (match := TYPE_LINE_RE.fullmatch(raw.strip()))
|
||||
and not match.group(1).strip()
|
||||
]
|
||||
for lineno in empty_types:
|
||||
findings.append(
|
||||
(
|
||||
display,
|
||||
lineno + 1,
|
||||
"error",
|
||||
"type",
|
||||
f"`{heading.signature}` 的类型不能为空",
|
||||
)
|
||||
)
|
||||
if not any(
|
||||
ACCESS_RE.match(raw.strip())
|
||||
for _, raw, fenced in body
|
||||
if not fenced
|
||||
):
|
||||
findings.append(
|
||||
(
|
||||
display,
|
||||
entry.start + 1,
|
||||
"error",
|
||||
"access",
|
||||
f"`{heading.signature}` 缺少访问方式",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
body = scan_body(lines, entry.start, entry.end)
|
||||
found, offending = find_description(body)
|
||||
if not found:
|
||||
line = offending + 1 if offending is not None else entry.start + 1
|
||||
findings.append(
|
||||
(
|
||||
display,
|
||||
line,
|
||||
"error",
|
||||
"description",
|
||||
f"`{heading.signature}` 缺少描述",
|
||||
)
|
||||
)
|
||||
|
||||
if kind in {"class", "unit"}:
|
||||
continue
|
||||
|
||||
visibility_required = heading.binding != "unit"
|
||||
visibilities = [
|
||||
(lineno, match.group(1))
|
||||
for lineno, raw, fenced in body
|
||||
if not fenced and (match := VISIBILITY_RE.match(raw.strip()))
|
||||
]
|
||||
if visibility_required and not visibilities:
|
||||
findings.append(
|
||||
(
|
||||
display,
|
||||
entry.start + 1,
|
||||
"error",
|
||||
"visibility",
|
||||
f"`{heading.signature}` 缺少 `可见性:public|protected` 行",
|
||||
)
|
||||
)
|
||||
for lineno, visibility in visibilities:
|
||||
if visibility == "private":
|
||||
findings.append(
|
||||
(
|
||||
display,
|
||||
lineno + 1,
|
||||
"error",
|
||||
"visibility",
|
||||
"private API 不得进入文档",
|
||||
)
|
||||
)
|
||||
|
||||
has_type = any(TYPE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
|
||||
has_value = any(VALUE_RE.match(raw.strip()) for _, raw, fenced in body if not fenced)
|
||||
if kind in {"field", "variable"} and not has_type:
|
||||
findings.append(
|
||||
(display, entry.start + 1, "error", "type", f"`{heading.signature}` 缺少类型")
|
||||
)
|
||||
if kind == "constant" and not has_value:
|
||||
findings.append(
|
||||
(display, entry.start + 1, "error", "value", f"`{heading.signature}` 缺少值")
|
||||
)
|
||||
|
||||
|
||||
def gather_targets(paths, root):
|
||||
@@ -219,7 +506,16 @@ def gather_targets(paths, root):
|
||||
def main(argv=None):
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
parser = argparse.ArgumentParser(description="校验 Markdown 文件或目录")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="校验 Markdown 文件或目录",
|
||||
add_help=False,
|
||||
allow_abbrev=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--help",
|
||||
action="help",
|
||||
help="显示本帮助并退出(不提供 -h 短选项)",
|
||||
)
|
||||
target_group = parser.add_mutually_exclusive_group(required=True)
|
||||
target_group.add_argument("--file", help="要校验的单个 Markdown 文件")
|
||||
target_group.add_argument("--dir", help="要递归校验的目录")
|
||||
|
||||
@@ -33,8 +33,9 @@ class FunctionIndexTest(unittest.TestCase):
|
||||
leaf.write_text(
|
||||
"# Builtin - 基础 / 数组\n\n"
|
||||
"## `demo()`\n\n"
|
||||
"<!-- tags: 数组 列表 -->\n\n"
|
||||
"声明:function\n\n"
|
||||
"返回示例值。\n\n"
|
||||
"<!-- tags: 数组 列表 -->\n\n"
|
||||
"返回:integer\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -50,6 +51,12 @@ class FunctionIndexTest(unittest.TestCase):
|
||||
result = self.module.main(["--skill-dir", str(self.skill_dir), *args])
|
||||
return result, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def write_page(self, relative_path, text):
|
||||
page = self.codegen_root / relative_path
|
||||
page.parent.mkdir(parents=True, exist_ok=True)
|
||||
page.write_text(text, encoding="utf-8")
|
||||
return page
|
||||
|
||||
def test_rebuild_writes_only_tsv(self):
|
||||
result, _, _ = self.run_main()
|
||||
|
||||
@@ -64,6 +71,294 @@ class FunctionIndexTest(unittest.TestCase):
|
||||
self.assertEqual("数组 列表", row["tags"])
|
||||
self.assertEqual("返回示例值。", row["summary"])
|
||||
|
||||
def test_declaration_does_not_become_a_missing_description_summary(self):
|
||||
self.write_page(
|
||||
"project/missing_description.md",
|
||||
"# Missing\n\n"
|
||||
"## `Missing()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"返回:nil\n",
|
||||
)
|
||||
|
||||
rows = {
|
||||
row[0]: dict(zip(self.module.HEADER, row))
|
||||
for row in self.module.build_rows(self.codegen_root)
|
||||
}
|
||||
|
||||
self.assertEqual("", rows["Missing"]["summary"])
|
||||
|
||||
def test_legacy_function_anchor_and_first_eight_columns_are_unchanged(self):
|
||||
rows = self.module.build_rows(self.codegen_root)
|
||||
row = dict(zip(self.module.HEADER, rows[0]))
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
"name",
|
||||
"scope",
|
||||
"module",
|
||||
"signature",
|
||||
"page",
|
||||
"anchor",
|
||||
"tags",
|
||||
"summary",
|
||||
"kind",
|
||||
"binding",
|
||||
"visibility",
|
||||
"owner",
|
||||
"qualified_name",
|
||||
],
|
||||
self.module.HEADER,
|
||||
)
|
||||
self.assertEqual(
|
||||
[
|
||||
"demo",
|
||||
"builtin",
|
||||
"base",
|
||||
"demo()",
|
||||
"builtin/base/array.md",
|
||||
"demo",
|
||||
"数组 列表",
|
||||
"返回示例值。",
|
||||
],
|
||||
rows[0][:8],
|
||||
)
|
||||
self.assertEqual("function", row["kind"])
|
||||
self.assertEqual("", row["binding"])
|
||||
self.assertEqual("", row["visibility"])
|
||||
self.assertEqual("", row["owner"])
|
||||
self.assertEqual("demo", row["qualified_name"])
|
||||
|
||||
def test_mixed_page_resets_owner_for_every_h2(self):
|
||||
self.write_page(
|
||||
"project/mixed/api.md",
|
||||
"# Mixed\n\n"
|
||||
"## `Widget`\n\n声明:class\n\n组件。\n\n"
|
||||
"### `Open()`\n\n声明:function\n\n打开。\n\n可见性:public\n\n"
|
||||
"## `Parse()`\n\n声明:function\n\n解析。\n\n返回:Widget\n\n"
|
||||
"## `Runtime`\n\n声明:unit\n\n接口。\n\n"
|
||||
"### `Document`\n\n声明:class\n\n文档。\n\n"
|
||||
"#### `Save()`\n\n声明:function\n\n保存。\n\n可见性:public\n",
|
||||
)
|
||||
|
||||
rows = {
|
||||
(row[0], row[3]): dict(zip(self.module.HEADER, row))
|
||||
for row in self.module.build_rows(self.codegen_root)
|
||||
}
|
||||
|
||||
self.assertEqual("", rows[("Widget", "Widget")]["owner"])
|
||||
self.assertEqual("Widget", rows[("Open", "Open()")]["owner"])
|
||||
self.assertEqual("", rows[("Parse", "Parse()")]["owner"])
|
||||
self.assertEqual("", rows[("Runtime", "Runtime")]["owner"])
|
||||
self.assertEqual("Runtime", rows[("Document", "Document")]["owner"])
|
||||
self.assertEqual(
|
||||
"Runtime.Document", rows[("Save", "Save()")]["owner"]
|
||||
)
|
||||
|
||||
def test_class_page_maps_members_to_unified_index_columns(self):
|
||||
self.write_page(
|
||||
"project/widgets/widget.md",
|
||||
"# Project / Widgets\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"组件。\n\n"
|
||||
"<!-- tags: 组件 -->\n\n"
|
||||
"### `Open(path)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开组件。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"### `Create()`\n\n"
|
||||
"声明:class function\n\n"
|
||||
"创建组件。\n\n"
|
||||
"可见性:`protected`\n\n"
|
||||
"### `Count`\n\n"
|
||||
"声明:static field\n\n"
|
||||
"组件数量。\n\n"
|
||||
"可见性:`protected`\n\n"
|
||||
"类型:integer\n\n"
|
||||
"### `Items(index)`\n\n"
|
||||
"声明:property\n\n"
|
||||
"按索引读取组件。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"类型:Widget\n\n"
|
||||
"访问:read\n\n"
|
||||
"### `DefaultName`\n\n"
|
||||
"声明:const\n\n"
|
||||
"默认名称。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"值:`'widget'`\n\n"
|
||||
"### `Maximum`\n\n"
|
||||
"声明:static const\n\n"
|
||||
"最大数量。\n\n"
|
||||
"可见性:`protected`\n\n"
|
||||
"值:`100`\n",
|
||||
)
|
||||
|
||||
rows = {
|
||||
row[self.module.HEADER.index("qualified_name")]: dict(
|
||||
zip(self.module.HEADER, row)
|
||||
)
|
||||
for row in self.module.build_rows(self.codegen_root)
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
"name": "Widget",
|
||||
"scope": "project",
|
||||
"module": "widgets",
|
||||
"signature": "Widget",
|
||||
"page": "project/widgets/widget.md",
|
||||
"anchor": "widget",
|
||||
"tags": "组件",
|
||||
"summary": "组件。",
|
||||
"kind": "class",
|
||||
"binding": "",
|
||||
"visibility": "",
|
||||
"owner": "",
|
||||
"qualified_name": "Widget",
|
||||
},
|
||||
rows["Widget"],
|
||||
)
|
||||
self.assertEqual("method", rows["Widget.Open"]["kind"])
|
||||
self.assertEqual("instance", rows["Widget.Open"]["binding"])
|
||||
self.assertEqual("public", rows["Widget.Open"]["visibility"])
|
||||
self.assertEqual("Widget", rows["Widget.Open"]["owner"])
|
||||
self.assertEqual("openpath", rows["Widget.Open"]["anchor"])
|
||||
self.assertEqual("class", rows["Widget.Create"]["binding"])
|
||||
self.assertEqual("create", rows["Widget.Create"]["anchor"])
|
||||
self.assertEqual("field", rows["Widget.Count"]["kind"])
|
||||
self.assertEqual("static", rows["Widget.Count"]["binding"])
|
||||
self.assertEqual("count", rows["Widget.Count"]["anchor"])
|
||||
self.assertEqual("property", rows["Widget.Items"]["kind"])
|
||||
self.assertEqual("instance", rows["Widget.Items"]["binding"])
|
||||
self.assertEqual("Items(index)", rows["Widget.Items"]["signature"])
|
||||
self.assertEqual("constant", rows["Widget.DefaultName"]["kind"])
|
||||
self.assertEqual("instance", rows["Widget.DefaultName"]["binding"])
|
||||
self.assertEqual("constant", rows["Widget.Maximum"]["kind"])
|
||||
self.assertEqual("static", rows["Widget.Maximum"]["binding"])
|
||||
self.assertEqual("maximum", rows["Widget.Maximum"]["anchor"])
|
||||
|
||||
def test_unit_page_derives_nested_owner_and_public_interface_visibility(self):
|
||||
self.write_page(
|
||||
"dotnet/runtime/demo_unit.md",
|
||||
"# Dotnet / Runtime\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"接口。\n\n"
|
||||
"### `Open()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开。\n\n"
|
||||
"返回:Document\n\n"
|
||||
"### `Current`\n\n"
|
||||
"声明:var\n\n"
|
||||
"当前文档。\n\n"
|
||||
"类型:Document\n\n"
|
||||
"### `DefaultName`\n\n"
|
||||
"声明:const\n\n"
|
||||
"默认名称。\n\n"
|
||||
"值:`'demo'`\n\n"
|
||||
"### `Document`\n\n"
|
||||
"声明:class\n\n"
|
||||
"文档对象。\n\n"
|
||||
"#### `Save()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"保存。\n\n"
|
||||
"可见性:`protected`\n",
|
||||
)
|
||||
|
||||
rows = {
|
||||
row[self.module.HEADER.index("qualified_name")]: dict(
|
||||
zip(self.module.HEADER, row)
|
||||
)
|
||||
for row in self.module.build_rows(self.codegen_root)
|
||||
}
|
||||
|
||||
self.assertEqual("unit", rows["DemoUnit"]["kind"])
|
||||
self.assertEqual("", rows["DemoUnit"]["owner"])
|
||||
self.assertEqual("unit", rows["DemoUnit.Open"]["binding"])
|
||||
self.assertEqual("public", rows["DemoUnit.Open"]["visibility"])
|
||||
self.assertEqual("DemoUnit", rows["DemoUnit.Open"]["owner"])
|
||||
self.assertEqual("variable", rows["DemoUnit.Current"]["kind"])
|
||||
self.assertEqual("constant", rows["DemoUnit.DefaultName"]["kind"])
|
||||
self.assertEqual("unit", rows["DemoUnit.DefaultName"]["binding"])
|
||||
self.assertEqual("public", rows["DemoUnit.DefaultName"]["visibility"])
|
||||
self.assertEqual("unit", rows["DemoUnit.Document"]["binding"])
|
||||
self.assertEqual("public", rows["DemoUnit.Document"]["visibility"])
|
||||
self.assertEqual("DemoUnit.Document", rows["DemoUnit.Document.Save"]["owner"])
|
||||
self.assertEqual("method", rows["DemoUnit.Document.Save"]["kind"])
|
||||
self.assertEqual("instance", rows["DemoUnit.Document.Save"]["binding"])
|
||||
self.assertEqual("protected", rows["DemoUnit.Document.Save"]["visibility"])
|
||||
|
||||
def test_class_overloads_share_qualified_name_but_have_distinct_anchors(self):
|
||||
self.write_page(
|
||||
"project/widgets/overloads.md",
|
||||
"# Project / Widgets\n\n"
|
||||
"## `Overloads`\n\n"
|
||||
"声明:class\n\n"
|
||||
"重载示例。\n\n"
|
||||
"### `Open(path)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"按路径打开。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"### `Open(mode, flags)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"按模式打开。\n\n"
|
||||
"可见性:`public`\n",
|
||||
)
|
||||
|
||||
rows = [
|
||||
dict(zip(self.module.HEADER, row))
|
||||
for row in self.module.build_rows(self.codegen_root)
|
||||
if row[0] == "Open"
|
||||
]
|
||||
|
||||
self.assertEqual(2, len(rows))
|
||||
self.assertEqual({"Overloads.Open"}, {row["qualified_name"] for row in rows})
|
||||
self.assertEqual(
|
||||
{"openpath", "openmodeflags"},
|
||||
{row["anchor"] for row in rows},
|
||||
)
|
||||
self.assertEqual(
|
||||
2,
|
||||
len({f"{row['page']}#{row['anchor']}" for row in rows}),
|
||||
)
|
||||
|
||||
def test_top_level_overloads_and_cross_kind_names_get_unique_anchors(self):
|
||||
page = "project/mixed/overloads.md"
|
||||
self.write_page(
|
||||
page,
|
||||
"# Project / Mixed\n\n"
|
||||
"## `Open(path)`\n\n声明:function\n\n按路径打开。\n\n"
|
||||
"返回:nil\n\n"
|
||||
"## `Open(mode)`\n\n声明:function\n\n按模式打开。\n\n"
|
||||
"返回:nil\n\n"
|
||||
"## `Open`\n\n声明:class\n\n打开器。\n\n"
|
||||
"## `Open`\n\n声明:unit\n\n打开接口。\n",
|
||||
)
|
||||
|
||||
rows = [
|
||||
dict(zip(self.module.HEADER, row))
|
||||
for row in self.module.build_rows(self.codegen_root)
|
||||
if row[4] == page
|
||||
]
|
||||
by_identity = {
|
||||
(row["kind"], row["signature"]): row for row in rows
|
||||
}
|
||||
|
||||
self.assertEqual(4, len(rows))
|
||||
self.assertEqual({""}, {row["owner"] for row in rows})
|
||||
self.assertEqual({"Open"}, {row["qualified_name"] for row in rows})
|
||||
self.assertEqual(
|
||||
"open", by_identity[("function", "Open(path)")]["anchor"]
|
||||
)
|
||||
self.assertEqual(
|
||||
"open-1", by_identity[("function", "Open(mode)")]["anchor"]
|
||||
)
|
||||
self.assertEqual("open-2", by_identity[("class", "Open")]["anchor"])
|
||||
self.assertEqual("open-3", by_identity[("unit", "Open")]["anchor"])
|
||||
self.assertEqual(
|
||||
4, len({f"{row['page']}#{row['anchor']}" for row in rows})
|
||||
)
|
||||
|
||||
def test_check_does_not_require_index_pages(self):
|
||||
self.data_dir.mkdir(parents=True)
|
||||
rows = self.module.build_rows(self.codegen_root)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -6,7 +7,22 @@ from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "lint.py"
|
||||
VALID_PAGE = "# 项目 / 示例\n\n## `demo()`\n\n示例函数\n\n返回:nil\n"
|
||||
API_MARKDOWN = Path(__file__).parents[1] / "scripts" / "api_markdown.py"
|
||||
VALID_PAGE = (
|
||||
"# 项目 / 示例\n\n"
|
||||
"## `demo()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"示例函数\n\n"
|
||||
"返回:nil\n"
|
||||
)
|
||||
|
||||
|
||||
def load_api_markdown():
|
||||
spec = importlib.util.spec_from_file_location("tsl_api_markdown", API_MARKDOWN)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class DocLintCliTest(unittest.TestCase):
|
||||
@@ -38,6 +54,781 @@ class DocLintCliTest(unittest.TestCase):
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("1 files", result.stderr)
|
||||
|
||||
def test_accepts_mixed_typed_h2_declarations(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 混合\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"组件。\n\n"
|
||||
"### `Open()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"## `Parse()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"解析。\n\n"
|
||||
"返回:Widget\n\n"
|
||||
"### 示例\n\n"
|
||||
"```tsl\nreturn Parse();\n```\n\n"
|
||||
"## `Runtime`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"运行时接口。\n\n"
|
||||
"### `Document`\n\n"
|
||||
"声明:class\n\n"
|
||||
"文档。\n\n"
|
||||
"#### `Save()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"保存。\n\n"
|
||||
"可见性:`public`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page, "--strict")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_declaration_must_use_exact_canonical_line(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 函数\n\n"
|
||||
"## `Open()`\n\n"
|
||||
"声明:Function\n\n"
|
||||
"打开。\n\n"
|
||||
"返回:nil\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[heading]", result.stdout)
|
||||
self.assertIn("声明:function", result.stdout)
|
||||
|
||||
def test_business_type_metadata_is_not_a_declaration_marker(self):
|
||||
module = load_api_markdown()
|
||||
lines = [
|
||||
"## `Account`",
|
||||
"",
|
||||
"声明:function",
|
||||
"",
|
||||
"远程服务器账户。",
|
||||
"",
|
||||
"类型:读写",
|
||||
"",
|
||||
"返回:nil",
|
||||
]
|
||||
|
||||
entries = list(module.iter_api_entries(lines))
|
||||
|
||||
self.assertEqual(1, len(entries))
|
||||
self.assertTrue(entries[0].heading.valid)
|
||||
self.assertEqual("function", entries[0].heading.root_kind)
|
||||
|
||||
self.page.write_text(
|
||||
"# 示例 / 遗留属性\n\n" + "\n".join(lines) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = self.run_cli("--file", self.page, "--strict")
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_declaration_must_be_first_non_empty_line_after_heading(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 函数\n\n"
|
||||
"## `Open()`\n\n"
|
||||
"打开。\n\n"
|
||||
"声明:function\n\n"
|
||||
"返回:nil\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[heading]", result.stdout)
|
||||
self.assertIn("第一条非空正文", result.stdout)
|
||||
|
||||
def test_tags_must_follow_description(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 函数\n\n"
|
||||
"## `Open()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"<!-- tags: 示例 -->\n\n"
|
||||
"打开。\n\n"
|
||||
"返回:nil\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[description]", result.stdout)
|
||||
|
||||
def test_property_type_is_optional_but_access_is_required(self):
|
||||
page_without_type = (
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Title`\n\n"
|
||||
"声明:property\n\n"
|
||||
"组件标题。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"访问:read\n"
|
||||
)
|
||||
self.page.write_text(page_without_type, encoding="utf-8")
|
||||
|
||||
valid = self.run_cli("--file", self.page, "--strict")
|
||||
|
||||
self.assertEqual(0, valid.returncode, valid.stdout + valid.stderr)
|
||||
|
||||
self.page.write_text(
|
||||
page_without_type.replace("\n访问:read\n", "\n"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
invalid = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, invalid.returncode)
|
||||
self.assertIn("[access]", invalid.stdout)
|
||||
|
||||
def test_accepts_class_page_with_optional_method_return(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Close()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"关闭组件。\n\n"
|
||||
"可见性:`public`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page, "--strict")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_class_member_requires_visibility(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Count`\n\n"
|
||||
"声明:field\n\n"
|
||||
"组件数量。\n\n"
|
||||
"类型:integer\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[visibility]", result.stdout)
|
||||
|
||||
def test_parameterized_property_requires_the_fixed_parameter_table(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Items(index)`\n\n"
|
||||
"声明:property\n\n"
|
||||
"按索引读取组件。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"类型:Widget\n\n"
|
||||
"访问:read\n\n"
|
||||
"| 名称 | 类型 | 说明 |\n"
|
||||
"| --- | --- | --- |\n"
|
||||
"| `index` | integer | 索引 |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[param-header]", result.stdout)
|
||||
|
||||
def test_nullary_property_rejects_a_parameter_table(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Title`\n\n"
|
||||
"声明:property\n\n"
|
||||
"组件标题。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"类型:string\n\n"
|
||||
"访问:read\n\n"
|
||||
"| 参数 | 类型 | 说明 |\n"
|
||||
"| --- | --- | --- |\n"
|
||||
"| `unused` | integer | 不应存在 |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[param-table]", result.stdout)
|
||||
|
||||
def test_class_method_allows_h4_value_and_example_subheadings(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Open(mode)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开组件。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"| 参数 | 类型 | 说明 |\n"
|
||||
"| --- | --- | --- |\n"
|
||||
"| `mode` | integer | 打开模式 |\n\n"
|
||||
"#### `mode` 取值\n\n"
|
||||
"- `0` — 默认模式\n\n"
|
||||
"#### 示例\n\n"
|
||||
"```tsl\nreturn Open(0);\n```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page, "--strict")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_property_rejects_example_subheading(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Title`\n\n"
|
||||
"声明:property\n\n"
|
||||
"组件标题。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"类型:string\n\n"
|
||||
"访问:read\n\n"
|
||||
"#### 示例\n\n"
|
||||
"```tsl\nreturn Widget.Title;\n```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[subheading]", result.stdout)
|
||||
self.assertIn("property", result.stdout)
|
||||
|
||||
def test_accepts_unit_page_with_nested_class_member(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / Unit\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"提供接口。\n\n"
|
||||
"### `DefaultSize`\n\n"
|
||||
"声明:const\n\n"
|
||||
"默认大小。\n\n"
|
||||
"值:`100`\n\n"
|
||||
"### `Open()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开。\n\n"
|
||||
"返回:Document\n\n"
|
||||
"### `Document`\n\n"
|
||||
"声明:class\n\n"
|
||||
"文档对象。\n\n"
|
||||
"#### `Save()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"保存。\n\n"
|
||||
"可见性:`protected`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page, "--strict")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_unit_class_method_allows_h5_value_and_example_subheadings(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / Unit\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"提供接口。\n\n"
|
||||
"### `Document`\n\n"
|
||||
"声明:class\n\n"
|
||||
"文档对象。\n\n"
|
||||
"#### `Save(mode)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"保存。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"| 参数 | 类型 | 说明 |\n"
|
||||
"| --- | --- | --- |\n"
|
||||
"| `mode` | integer | 保存模式 |\n\n"
|
||||
"##### `mode` 取值\n\n"
|
||||
"- `0` — 默认模式\n\n"
|
||||
"##### 示例\n\n"
|
||||
"```tsl\nreturn Save(0);\n```\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page, "--strict")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
|
||||
def test_unit_function_requires_return(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / Unit\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"提供接口。\n\n"
|
||||
"### `Open()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开。\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[return]", result.stdout)
|
||||
|
||||
def test_unit_function_rejects_an_empty_return_type(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / Unit\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"提供接口。\n\n"
|
||||
"### `Open()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开。\n\n"
|
||||
"返回:\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[return]", result.stdout)
|
||||
|
||||
def test_empty_type_and_value_lines_are_rejected(self):
|
||||
cases = {
|
||||
"property": (
|
||||
"# 示例 / 类\n\n## `Widget`\n\n声明:class\n\n组件。\n\n"
|
||||
"### `Title`\n\n声明:property\n\n标题。\n\n可见性:`public`\n\n"
|
||||
"类型:\n\n访问:read\n",
|
||||
"[type]",
|
||||
),
|
||||
"field": (
|
||||
"# 示例 / 类\n\n## `Widget`\n\n声明:class\n\n组件。\n\n"
|
||||
"### `Count`\n\n声明:field\n\n数量。\n\n可见性:`public`\n\n类型:\n",
|
||||
"[type]",
|
||||
),
|
||||
"variable": (
|
||||
"# 示例 / Unit\n\n## `DemoUnit`\n\n声明:unit\n\n接口。\n\n"
|
||||
"### `Current`\n\n声明:var\n\n当前值。\n\n类型:\n",
|
||||
"[type]",
|
||||
),
|
||||
"constant": (
|
||||
"# 示例 / Unit\n\n## `DemoUnit`\n\n声明:unit\n\n接口。\n\n"
|
||||
"### `Default`\n\n声明:const\n\n默认值。\n\n值:\n",
|
||||
"[value]",
|
||||
),
|
||||
}
|
||||
for name, (text, expected) in cases.items():
|
||||
with self.subTest(name=name):
|
||||
self.page.write_text(text, encoding="utf-8")
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn(expected, result.stdout)
|
||||
|
||||
def test_class_parent_metadata_does_not_count_as_description(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"父类:`BaseWidget`\n\n"
|
||||
"",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[description]", result.stdout)
|
||||
|
||||
def test_class_parameter_rows_must_match_signature(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Open(mode)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开组件。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"| 参数 | 类型 | 说明 |\n"
|
||||
"| --- | --- | --- |\n"
|
||||
"| `other` | integer | 错误参数 |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[param-names]", result.stdout)
|
||||
|
||||
def test_class_parameter_rows_require_type_and_description(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Open(mode)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开组件。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"| 参数 | 类型 | 说明 |\n"
|
||||
"| --- | --- | --- |\n"
|
||||
"| `mode` | | |\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[param-row]", result.stdout)
|
||||
|
||||
def test_unit_class_member_requires_visibility(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / Unit\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"提供接口。\n\n"
|
||||
"### `Document`\n\n"
|
||||
"声明:class\n\n"
|
||||
"文档对象。\n\n"
|
||||
"#### `Name`\n\n"
|
||||
"声明:field\n\n"
|
||||
"文档名称。\n\n"
|
||||
"类型:string\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[visibility]", result.stdout)
|
||||
|
||||
def test_class_and_unit_member_data_lines_are_required(self):
|
||||
cases = {
|
||||
"property": (
|
||||
"# 示例 / 类\n\n## `Widget`\n\n声明:class\n\n组件。\n\n"
|
||||
"### `Title`\n\n声明:property\n\n标题。\n\n可见性:`public`\n",
|
||||
"[access]",
|
||||
),
|
||||
"field": (
|
||||
"# 示例 / 类\n\n## `Widget`\n\n声明:class\n\n组件。\n\n"
|
||||
"### `Count`\n\n声明:field\n\n数量。\n\n可见性:`public`\n",
|
||||
"[type]",
|
||||
),
|
||||
"variable": (
|
||||
"# 示例 / Unit\n\n## `DemoUnit`\n\n声明:unit\n\n接口。\n\n"
|
||||
"### `Current`\n\n声明:var\n\n当前值。\n",
|
||||
"[type]",
|
||||
),
|
||||
"constant": (
|
||||
"# 示例 / Unit\n\n## `DemoUnit`\n\n声明:unit\n\n接口。\n\n"
|
||||
"### `Default`\n\n声明:const\n\n默认值。\n",
|
||||
"[value]",
|
||||
),
|
||||
}
|
||||
for name, (text, expected) in cases.items():
|
||||
with self.subTest(name=name):
|
||||
self.page.write_text(text, encoding="utf-8")
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn(expected, result.stdout)
|
||||
|
||||
def test_class_legacy_typed_api_heading_is_rejected(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### function `Open()`\n\n"
|
||||
"打开组件。\n\n"
|
||||
"可见性:`public`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[heading]", result.stdout)
|
||||
self.assertIn("标题只写名称或调用签名", result.stdout)
|
||||
|
||||
def test_unit_legacy_typed_api_heading_is_rejected(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / Unit\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"提供接口。\n\n"
|
||||
"### function `Open()`\n\n"
|
||||
"打开。\n\n"
|
||||
"返回:Document\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[heading]", result.stdout)
|
||||
self.assertIn("标题只写名称或调用签名", result.stdout)
|
||||
|
||||
def test_class_member_at_h4_is_rejected(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"#### `Open()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开组件。\n\n"
|
||||
"可见性:`public`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[heading]", result.stdout)
|
||||
self.assertIn("H3", result.stdout)
|
||||
|
||||
def test_unit_class_member_at_h5_is_rejected(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / Unit\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"提供接口。\n\n"
|
||||
"### `Document`\n\n"
|
||||
"声明:class\n\n"
|
||||
"文档对象。\n\n"
|
||||
"##### `Save()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"保存。\n\n"
|
||||
"可见性:`public`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("[heading]", result.stdout)
|
||||
self.assertIn("H4", result.stdout)
|
||||
|
||||
def test_rejects_static_function_and_private_api(self):
|
||||
self.page.write_text(
|
||||
"# 示例 / 类\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"表示组件。\n\n"
|
||||
"### `Create()`\n\n"
|
||||
"声明:static function\n\n"
|
||||
"创建。\n\n"
|
||||
"可见性:`private`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = self.run_cli("--file", self.page)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("static function", result.stdout)
|
||||
self.assertIn("private", result.stdout)
|
||||
|
||||
def test_mixed_h2_declarations_reset_nested_context(self):
|
||||
module = load_api_markdown()
|
||||
lines = [
|
||||
"# Demo",
|
||||
"",
|
||||
"## `Widget`",
|
||||
"",
|
||||
"声明:class",
|
||||
"",
|
||||
"组件。",
|
||||
"",
|
||||
"### `Open()`",
|
||||
"",
|
||||
"声明:function",
|
||||
"",
|
||||
"打开。",
|
||||
"",
|
||||
"可见性:public",
|
||||
"",
|
||||
"## `Parse(text)`",
|
||||
"",
|
||||
"声明:function",
|
||||
"",
|
||||
"解析。",
|
||||
"",
|
||||
"返回:Widget",
|
||||
"",
|
||||
"## `DemoUnit`",
|
||||
"",
|
||||
"声明:unit",
|
||||
"",
|
||||
"接口。",
|
||||
"",
|
||||
"### `Document`",
|
||||
"",
|
||||
"声明:class",
|
||||
"",
|
||||
"文档。",
|
||||
"",
|
||||
"#### `Save()`",
|
||||
"",
|
||||
"声明:function",
|
||||
"",
|
||||
"保存。",
|
||||
"",
|
||||
"可见性:public",
|
||||
]
|
||||
|
||||
headings = [entry.heading for entry in module.iter_api_entries(lines)]
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
(2, "class", "class", "Widget"),
|
||||
(3, "method", "class", "Open"),
|
||||
(2, "function", "function", "Parse"),
|
||||
(2, "unit", "unit", "DemoUnit"),
|
||||
(3, "class", "unit", "Document"),
|
||||
(4, "method", "unit", "Save"),
|
||||
],
|
||||
[
|
||||
(item.level, item.kind, item.root_kind, item.name)
|
||||
for item in headings
|
||||
],
|
||||
)
|
||||
|
||||
def test_missing_duplicate_and_unknown_declarations_are_errors(self):
|
||||
module = load_api_markdown()
|
||||
cases = {
|
||||
"missing": (
|
||||
["## `Open()`", "", "打开。", "", "返回:nil"],
|
||||
"第一条非空正文",
|
||||
),
|
||||
"duplicate": (
|
||||
[
|
||||
"## `Widget`",
|
||||
"",
|
||||
"声明:class",
|
||||
"",
|
||||
"组件。",
|
||||
"",
|
||||
"声明:unit",
|
||||
],
|
||||
"必须且只能包含一行",
|
||||
),
|
||||
"unknown": (
|
||||
["## `Runtime`", "", "声明:module", "", "接口。"],
|
||||
"必须是 function、class 或 unit",
|
||||
),
|
||||
}
|
||||
for name, (lines, expected) in cases.items():
|
||||
with self.subTest(name=name):
|
||||
headings = [
|
||||
entry.heading for entry in module.iter_api_entries(lines)
|
||||
]
|
||||
|
||||
self.assertEqual(1, len(headings))
|
||||
self.assertFalse(headings[0].valid)
|
||||
self.assertIn(expected, headings[0].error)
|
||||
|
||||
def test_plain_h2_ends_api_body_and_resets_nested_context(self):
|
||||
module = load_api_markdown()
|
||||
lines = [
|
||||
"# Demo",
|
||||
"",
|
||||
"## `Parse()`",
|
||||
"",
|
||||
"声明:function",
|
||||
"",
|
||||
"解析。",
|
||||
"",
|
||||
"返回:nil",
|
||||
"",
|
||||
"## 其他说明",
|
||||
"",
|
||||
"### function `NotAMember()`",
|
||||
]
|
||||
|
||||
entries = list(module.iter_api_entries(lines))
|
||||
|
||||
self.assertEqual(["Parse"], [entry.heading.name for entry in entries])
|
||||
self.assertEqual(10, entries[0].end)
|
||||
|
||||
def test_parameter_value_and_example_headings_are_not_api_entries(self):
|
||||
module = load_api_markdown()
|
||||
lines = [
|
||||
"# Demo",
|
||||
"",
|
||||
"## `Widget`",
|
||||
"",
|
||||
"声明:class",
|
||||
"",
|
||||
"表示组件。",
|
||||
"",
|
||||
"### `Open(mode)`",
|
||||
"",
|
||||
"声明:function",
|
||||
"",
|
||||
"打开。",
|
||||
"",
|
||||
"可见性:`public`",
|
||||
"",
|
||||
"#### `mode` 取值",
|
||||
"",
|
||||
"#### 示例",
|
||||
"",
|
||||
"## `Parse(mode)`",
|
||||
"",
|
||||
"声明:function",
|
||||
"",
|
||||
"解析。",
|
||||
"",
|
||||
"| 参数 | 类型 | 说明 |",
|
||||
"| --- | --- | --- |",
|
||||
"| `mode` | integer | 模式 |",
|
||||
"",
|
||||
"**mode 取值**",
|
||||
"",
|
||||
"### 示例",
|
||||
"",
|
||||
"## `DemoUnit`",
|
||||
"",
|
||||
"声明:unit",
|
||||
"",
|
||||
"接口。",
|
||||
"",
|
||||
"### `Load(mode)`",
|
||||
"",
|
||||
"声明:function",
|
||||
"",
|
||||
"加载。",
|
||||
"",
|
||||
"#### `mode` 取值",
|
||||
"",
|
||||
"#### 示例",
|
||||
]
|
||||
|
||||
entries = list(module.iter_api_entries(lines))
|
||||
|
||||
self.assertEqual(
|
||||
["Widget", "Open", "Parse", "DemoUnit", "Load"],
|
||||
[entry.heading.name for entry in entries],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import io
|
||||
import importlib.util
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -10,6 +15,7 @@ SCRIPT = (
|
||||
/ "scripts"
|
||||
/ "lookup.py"
|
||||
)
|
||||
SKILL_MD = SCRIPT.parents[1] / "SKILL.md"
|
||||
|
||||
|
||||
def load_script():
|
||||
@@ -20,6 +26,752 @@ def load_script():
|
||||
|
||||
|
||||
class LookupTest(unittest.TestCase):
|
||||
def write_unified_api_fixture(self, skill_root):
|
||||
data_dir = skill_root / "data"
|
||||
codegen_dir = skill_root / "references" / "codegen"
|
||||
data_dir.mkdir()
|
||||
(codegen_dir / "project").mkdir(parents=True)
|
||||
|
||||
header = [
|
||||
"name",
|
||||
"scope",
|
||||
"module",
|
||||
"signature",
|
||||
"page",
|
||||
"anchor",
|
||||
"tags",
|
||||
"summary",
|
||||
"kind",
|
||||
"binding",
|
||||
"visibility",
|
||||
"owner",
|
||||
"qualified_name",
|
||||
]
|
||||
|
||||
def row(
|
||||
name,
|
||||
signature,
|
||||
page,
|
||||
anchor,
|
||||
summary,
|
||||
kind,
|
||||
binding="",
|
||||
visibility="",
|
||||
owner="",
|
||||
qualified_name="",
|
||||
):
|
||||
return [
|
||||
name,
|
||||
"project",
|
||||
"demo",
|
||||
signature,
|
||||
page,
|
||||
anchor,
|
||||
"",
|
||||
summary,
|
||||
kind,
|
||||
binding,
|
||||
visibility,
|
||||
owner,
|
||||
qualified_name or name,
|
||||
]
|
||||
|
||||
rows = [
|
||||
row(
|
||||
"DemoUnit",
|
||||
"DemoUnit",
|
||||
"project/demo_unit.md",
|
||||
"demounit",
|
||||
"接口。",
|
||||
"unit",
|
||||
),
|
||||
row(
|
||||
"Open",
|
||||
"Open()",
|
||||
"project/demo_unit.md",
|
||||
"open",
|
||||
"打开。",
|
||||
"function",
|
||||
"unit",
|
||||
"public",
|
||||
"DemoUnit",
|
||||
"DemoUnit.Open",
|
||||
),
|
||||
row(
|
||||
"Document",
|
||||
"Document",
|
||||
"project/demo_unit.md",
|
||||
"document",
|
||||
"文档。",
|
||||
"class",
|
||||
"unit",
|
||||
"public",
|
||||
"DemoUnit",
|
||||
"DemoUnit.Document",
|
||||
),
|
||||
row(
|
||||
"Save",
|
||||
"Save(path)",
|
||||
"project/demo_unit.md",
|
||||
"savepath",
|
||||
"按路径保存。",
|
||||
"method",
|
||||
"instance",
|
||||
"public",
|
||||
"DemoUnit.Document",
|
||||
"DemoUnit.Document.Save",
|
||||
),
|
||||
row(
|
||||
"Save",
|
||||
"Save(mode)",
|
||||
"project/demo_unit.md",
|
||||
"savemode",
|
||||
"按模式保存。",
|
||||
"method",
|
||||
"instance",
|
||||
"protected",
|
||||
"DemoUnit.Document",
|
||||
"DemoUnit.Document.Save",
|
||||
),
|
||||
row(
|
||||
"Name",
|
||||
"Name",
|
||||
"project/demo_unit.md",
|
||||
"name",
|
||||
"文档名称。",
|
||||
"field",
|
||||
"instance",
|
||||
"public",
|
||||
"DemoUnit.Document",
|
||||
"DemoUnit.Document.Name",
|
||||
),
|
||||
row(
|
||||
"Other",
|
||||
"Other",
|
||||
"project/demo_unit.md",
|
||||
"other",
|
||||
"其他对象。",
|
||||
"class",
|
||||
"unit",
|
||||
"public",
|
||||
"DemoUnit",
|
||||
"DemoUnit.Other",
|
||||
),
|
||||
row(
|
||||
"Save",
|
||||
"Save()",
|
||||
"project/demo_unit.md",
|
||||
"save",
|
||||
"保存其他对象。",
|
||||
"method",
|
||||
"instance",
|
||||
"public",
|
||||
"DemoUnit.Other",
|
||||
"DemoUnit.Other.Save",
|
||||
),
|
||||
row(
|
||||
"Widget",
|
||||
"Widget",
|
||||
"project/widget.md",
|
||||
"widget",
|
||||
"组件。",
|
||||
"class",
|
||||
),
|
||||
row(
|
||||
"Create",
|
||||
"Create()",
|
||||
"project/widget.md",
|
||||
"create",
|
||||
"创建组件。",
|
||||
"method",
|
||||
"class",
|
||||
"public",
|
||||
"Widget",
|
||||
"Widget.Create",
|
||||
),
|
||||
row(
|
||||
"Count",
|
||||
"Count",
|
||||
"project/widget.md",
|
||||
"count",
|
||||
"组件数量。",
|
||||
"field",
|
||||
"static",
|
||||
"protected",
|
||||
"Widget",
|
||||
"Widget.Count",
|
||||
),
|
||||
]
|
||||
index_text = "\n".join(
|
||||
["\t".join(header), *("\t".join(values) for values in rows)]
|
||||
)
|
||||
tsv_path = data_dir / "function_index.tsv"
|
||||
tsv_path.write_text(index_text + "\n", encoding="utf-8")
|
||||
|
||||
(codegen_dir / "project" / "demo_unit.md").write_text(
|
||||
"# Demo / Unit\n\n"
|
||||
"## `DemoUnit`\n\n"
|
||||
"声明:unit\n\n"
|
||||
"接口。\n\n"
|
||||
"### `Open()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"打开。\n\n"
|
||||
"返回:Document\n\n"
|
||||
"### `Document`\n\n"
|
||||
"声明:class\n\n"
|
||||
"文档。\n\n"
|
||||
"#### `Save(path)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"按路径保存。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"#### `Save(mode)`\n\n"
|
||||
"声明:function\n\n"
|
||||
"按模式保存。\n\n"
|
||||
"可见性:`protected`\n\n"
|
||||
"#### `Name`\n\n"
|
||||
"声明:field\n\n"
|
||||
"文档名称。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"类型:string\n\n"
|
||||
"### `Other`\n\n"
|
||||
"声明:class\n\n"
|
||||
"其他对象。\n\n"
|
||||
"#### `Save()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"保存其他对象。\n\n"
|
||||
"可见性:`public`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(codegen_dir / "project" / "widget.md").write_text(
|
||||
"# Demo / Class\n\n"
|
||||
"## `Widget`\n\n"
|
||||
"声明:class\n\n"
|
||||
"组件。\n\n"
|
||||
"### `Create()`\n\n"
|
||||
"声明:class function\n\n"
|
||||
"创建组件。\n\n"
|
||||
"可见性:`public`\n\n"
|
||||
"### `Count`\n\n"
|
||||
"声明:static field\n\n"
|
||||
"组件数量。\n\n"
|
||||
"可见性:`protected`\n\n"
|
||||
"类型:integer\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return tsv_path
|
||||
|
||||
def write_mixed_api_fixture(self, skill_root):
|
||||
data_dir = skill_root / "data"
|
||||
codegen_dir = skill_root / "references" / "codegen" / "project"
|
||||
data_dir.mkdir()
|
||||
codegen_dir.mkdir(parents=True)
|
||||
header = [
|
||||
"name",
|
||||
"scope",
|
||||
"module",
|
||||
"signature",
|
||||
"page",
|
||||
"anchor",
|
||||
"tags",
|
||||
"summary",
|
||||
"kind",
|
||||
"binding",
|
||||
"visibility",
|
||||
"owner",
|
||||
"qualified_name",
|
||||
]
|
||||
page = "project/mixed.md"
|
||||
rows = [
|
||||
[
|
||||
"Widget",
|
||||
"project",
|
||||
"mixed",
|
||||
"Widget",
|
||||
page,
|
||||
"widget",
|
||||
"",
|
||||
"组件。",
|
||||
"class",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Widget",
|
||||
],
|
||||
[
|
||||
"Open",
|
||||
"project",
|
||||
"mixed",
|
||||
"Open()",
|
||||
page,
|
||||
"open",
|
||||
"",
|
||||
"打开。",
|
||||
"method",
|
||||
"instance",
|
||||
"public",
|
||||
"Widget",
|
||||
"Widget.Open",
|
||||
],
|
||||
[
|
||||
"Parse",
|
||||
"project",
|
||||
"mixed",
|
||||
"Parse(text)",
|
||||
page,
|
||||
"parse",
|
||||
"",
|
||||
"解析。",
|
||||
"function",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Parse",
|
||||
],
|
||||
[
|
||||
"Runtime",
|
||||
"project",
|
||||
"mixed",
|
||||
"Runtime",
|
||||
page,
|
||||
"runtime",
|
||||
"",
|
||||
"接口。",
|
||||
"unit",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Runtime",
|
||||
],
|
||||
[
|
||||
"Document",
|
||||
"project",
|
||||
"mixed",
|
||||
"Document",
|
||||
page,
|
||||
"document",
|
||||
"",
|
||||
"文档。",
|
||||
"class",
|
||||
"unit",
|
||||
"public",
|
||||
"Runtime",
|
||||
"Runtime.Document",
|
||||
],
|
||||
[
|
||||
"Save",
|
||||
"project",
|
||||
"mixed",
|
||||
"Save()",
|
||||
page,
|
||||
"save",
|
||||
"",
|
||||
"保存。",
|
||||
"method",
|
||||
"instance",
|
||||
"public",
|
||||
"Runtime.Document",
|
||||
"Runtime.Document.Save",
|
||||
],
|
||||
[
|
||||
"Other",
|
||||
"project",
|
||||
"mixed",
|
||||
"Other",
|
||||
page,
|
||||
"other",
|
||||
"",
|
||||
"其他组件。",
|
||||
"class",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"Other",
|
||||
],
|
||||
]
|
||||
tsv_path = data_dir / "function_index.tsv"
|
||||
tsv_path.write_text(
|
||||
"\n".join(
|
||||
["\t".join(header), *("\t".join(row) for row in rows)]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(codegen_dir / "mixed.md").write_text(
|
||||
"# Mixed\n\n"
|
||||
"## `Widget`\n\n声明:class\n\n组件。\n\n"
|
||||
"### `Open()`\n\n声明:function\n\n打开。\n\n可见性:public\n\n"
|
||||
"## `Parse(text)`\n\n声明:function\n\n解析。\n\n"
|
||||
"| 参数 | 类型 | 说明 |\n"
|
||||
"| --- | --- | --- |\n"
|
||||
"| `text` | string | 文本 |\n\n"
|
||||
"返回:Widget\n\n"
|
||||
"### 示例\n\n"
|
||||
"```markdown\n## `NotBoundary`\n```\n\n"
|
||||
"## `Runtime`\n\n声明:unit\n\n接口。\n\n"
|
||||
"### `Document`\n\n声明:class\n\n文档。\n\n"
|
||||
"#### `Save()`\n\n声明:function\n\n保存。\n\n可见性:public\n\n"
|
||||
"## `Other`\n\n声明:class\n\n其他组件。\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return tsv_path
|
||||
|
||||
def run_lookup(self, tsv_path, *args):
|
||||
return subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
*args,
|
||||
"--tsv",
|
||||
str(tsv_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
|
||||
def test_tsv_without_tags_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = Path(temp_dir) / "function_index.tsv"
|
||||
tsv_path.write_text(
|
||||
"name\tscope\tmodule\tsignature\tpage\tanchor\tsummary\n"
|
||||
"foo\tbuiltin\tmisc\tfoo()\tmisc.md\tfoo\tLegacy entry\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--kw",
|
||||
"Legacy",
|
||||
"--tsv",
|
||||
str(tsv_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertEqual("", result.stdout)
|
||||
self.assertIn("missing required columns: tags", result.stderr)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
|
||||
def test_missing_required_tsv_columns_are_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = Path(temp_dir) / "function_index.tsv"
|
||||
tsv_path.write_text("name\nfoo\n", encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--name",
|
||||
"foo",
|
||||
"--tsv",
|
||||
str(tsv_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("missing required columns", result.stderr)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
|
||||
def test_tsv_row_width_is_validated(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = Path(temp_dir) / "function_index.tsv"
|
||||
tsv_path.write_text(
|
||||
"name\tscope\tmodule\tsignature\tpage\tanchor\ttags\tsummary\n"
|
||||
"foo\tbuiltin\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--name",
|
||||
"foo",
|
||||
"--tsv",
|
||||
str(tsv_path),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertIn("line 2 has 2 columns; expected 8", result.stderr)
|
||||
self.assertNotIn("Traceback", result.stderr)
|
||||
|
||||
def test_missing_entry_body_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
skill_root = Path(temp_dir)
|
||||
data_dir = skill_root / "data"
|
||||
codegen_dir = skill_root / "references" / "codegen"
|
||||
data_dir.mkdir()
|
||||
codegen_dir.mkdir(parents=True)
|
||||
(data_dir / "function_index.tsv").write_text(
|
||||
"name\tscope\tmodule\tsignature\tpage\tanchor\ttags\tsummary\n"
|
||||
"foo\tbuiltin\tmisc\tfoo()\tmissing.md\tfoo\t\tFallback only\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--name",
|
||||
"foo",
|
||||
"--tsv",
|
||||
str(data_dir / "function_index.tsv"),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(1, result.returncode)
|
||||
self.assertEqual("", result.stdout)
|
||||
self.assertIn("entry body not found", result.stderr)
|
||||
self.assertIn("missing.md#foo", result.stderr)
|
||||
|
||||
def test_empty_queries_are_rejected(self):
|
||||
for option in ("--name", "--kw"):
|
||||
with self.subTest(option=option):
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(SCRIPT), option, ""],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(2, result.returncode)
|
||||
self.assertIn("must not be empty", result.stderr)
|
||||
|
||||
def test_non_positive_limit_is_rejected(self):
|
||||
for limit in ("0", "-1"):
|
||||
with self.subTest(limit=limit):
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(SCRIPT),
|
||||
"--kw",
|
||||
"数组",
|
||||
"--limit",
|
||||
limit,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
self.assertEqual(2, result.returncode)
|
||||
self.assertIn("--limit must be >= 1", result.stderr)
|
||||
|
||||
def test_exact_search_uses_anchor_for_duplicate_signatures(self):
|
||||
module = load_script()
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
skill_root = Path(temp_dir)
|
||||
data_dir = skill_root / "data"
|
||||
codegen_dir = skill_root / "references" / "codegen"
|
||||
data_dir.mkdir()
|
||||
codegen_dir.mkdir(parents=True)
|
||||
|
||||
header = [
|
||||
"name",
|
||||
"scope",
|
||||
"module",
|
||||
"signature",
|
||||
"page",
|
||||
"anchor",
|
||||
"tags",
|
||||
"summary",
|
||||
]
|
||||
rows = [
|
||||
[
|
||||
"CAPA",
|
||||
"builtin",
|
||||
"language",
|
||||
"CAPA()",
|
||||
"objects.md",
|
||||
"capa",
|
||||
"",
|
||||
"IMAP summary",
|
||||
],
|
||||
[
|
||||
"CAPA",
|
||||
"builtin",
|
||||
"language",
|
||||
"CAPA()",
|
||||
"objects.md",
|
||||
"capa-1",
|
||||
"",
|
||||
"Pop3 summary",
|
||||
],
|
||||
]
|
||||
index_text = "\n".join(
|
||||
["\t".join(header), *("\t".join(row) for row in rows)]
|
||||
)
|
||||
(data_dir / "function_index.tsv").write_text(
|
||||
index_text + "\n", encoding="utf-8"
|
||||
)
|
||||
(codegen_dir / "objects.md").write_text(
|
||||
"# Objects\n\n"
|
||||
"## `CAPA()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"对象:IMAP\n\n"
|
||||
"## `CAPA()`\n\n"
|
||||
"声明:function\n\n"
|
||||
"对象:Pop3\n\n"
|
||||
"",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with redirect_stdout(stdout):
|
||||
status = module.main(
|
||||
[
|
||||
"--name",
|
||||
"CAPA",
|
||||
"--tsv",
|
||||
str(data_dir / "function_index.tsv"),
|
||||
]
|
||||
)
|
||||
|
||||
output = stdout.getvalue()
|
||||
self.assertEqual(0, status)
|
||||
self.assertEqual(1, output.count("对象:IMAP"))
|
||||
self.assertEqual(1, output.count("对象:Pop3"))
|
||||
self.assertIn("objects.md#capa -->", output)
|
||||
self.assertIn("objects.md#capa-1 -->", output)
|
||||
|
||||
def test_exact_qualified_member_query_returns_only_member_overloads(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_unified_api_fixture(Path(temp_dir))
|
||||
|
||||
result = self.run_lookup(
|
||||
tsv_path, "--name", "demounit.document.save"
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual(2, result.stdout.count("#### `Save("))
|
||||
self.assertNotIn("#### `Name`", result.stdout)
|
||||
self.assertNotIn("### `Other`", result.stdout)
|
||||
|
||||
def test_exact_unit_class_query_returns_its_h4_subtree_only(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_unified_api_fixture(Path(temp_dir))
|
||||
|
||||
result = self.run_lookup(
|
||||
tsv_path, "--name", "DemoUnit.Document"
|
||||
)
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("### `Document`", result.stdout)
|
||||
self.assertIn("#### `Save(path)`", result.stdout)
|
||||
self.assertIn("#### `Name`", result.stdout)
|
||||
self.assertNotIn("### `Other`", result.stdout)
|
||||
|
||||
def test_exact_unit_query_returns_the_complete_unit_subtree(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_unified_api_fixture(Path(temp_dir))
|
||||
|
||||
result = self.run_lookup(tsv_path, "--name", "DemoUnit")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("## `DemoUnit`", result.stdout)
|
||||
self.assertIn("### `Open()`", result.stdout)
|
||||
self.assertIn("### `Document`", result.stdout)
|
||||
self.assertIn("#### `Save(path)`", result.stdout)
|
||||
self.assertIn("### `Other`", result.stdout)
|
||||
self.assertNotIn("## `Widget`", result.stdout)
|
||||
|
||||
def test_exact_class_query_returns_all_class_members(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_unified_api_fixture(Path(temp_dir))
|
||||
|
||||
result = self.run_lookup(tsv_path, "--name", "widget")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("## `Widget`", result.stdout)
|
||||
self.assertIn("### `Create()`\n\n声明:class function", result.stdout)
|
||||
self.assertIn("### `Count`\n\n声明:static field", result.stdout)
|
||||
self.assertNotIn("## `DemoUnit`", result.stdout)
|
||||
|
||||
def test_exact_class_in_mixed_page_stops_at_next_h2(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_mixed_api_fixture(Path(temp_dir))
|
||||
|
||||
result = self.run_lookup(tsv_path, "--name", "Widget")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("### `Open()`", result.stdout)
|
||||
self.assertNotIn("## `Parse(text)`", result.stdout)
|
||||
|
||||
def test_exact_function_in_mixed_page_keeps_h3_example_only(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_mixed_api_fixture(Path(temp_dir))
|
||||
|
||||
result = self.run_lookup(tsv_path, "--name", "Parse")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("### 示例", result.stdout)
|
||||
self.assertIn("## `NotBoundary`", result.stdout)
|
||||
self.assertNotIn("## `Runtime`", result.stdout)
|
||||
|
||||
def test_exact_unit_in_mixed_page_stops_before_following_class(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_mixed_api_fixture(Path(temp_dir))
|
||||
|
||||
result = self.run_lookup(tsv_path, "--name", "Runtime")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertIn("#### `Save()`", result.stdout)
|
||||
self.assertNotIn("## `Other`", result.stdout)
|
||||
|
||||
def test_unqualified_member_query_returns_all_owners(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_unified_api_fixture(Path(temp_dir))
|
||||
|
||||
result = self.run_lookup(tsv_path, "--name", "Save")
|
||||
|
||||
self.assertEqual(0, result.returncode, result.stderr)
|
||||
self.assertEqual(3, result.stdout.count("#### `Save("))
|
||||
self.assertIn("按路径保存。", result.stdout)
|
||||
self.assertIn("按模式保存。", result.stdout)
|
||||
self.assertIn("保存其他对象。", result.stdout)
|
||||
|
||||
def test_keyword_search_includes_unified_fields_and_declaration_term(self):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
tsv_path = self.write_unified_api_fixture(Path(temp_dir))
|
||||
|
||||
qualified = self.run_lookup(
|
||||
tsv_path, "--kw", "Widget.Create"
|
||||
)
|
||||
visibility = self.run_lookup(
|
||||
tsv_path, "--kw", "protected", "method"
|
||||
)
|
||||
|
||||
self.assertEqual(0, qualified.returncode, qualified.stderr)
|
||||
self.assertIn("Widget.Create\tclass function\tCreate()", qualified.stdout)
|
||||
self.assertNotIn("static function", qualified.stdout)
|
||||
self.assertEqual(0, visibility.returncode, visibility.stderr)
|
||||
self.assertIn("DemoUnit.Document.Save", visibility.stdout)
|
||||
|
||||
def test_skill_documents_qualified_queries_and_legacy_index_compatibility(self):
|
||||
text = SKILL_MD.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("完全限定名称", text)
|
||||
self.assertIn("qualified_name", text)
|
||||
self.assertIn("owner、kind、binding、visibility", text)
|
||||
self.assertIn("class function", text)
|
||||
self.assertIn("旧 8 列", text)
|
||||
|
||||
def test_keyword_search_includes_tags_and_summary(self):
|
||||
module = load_script()
|
||||
rows = [
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
yaml = None
|
||||
|
||||
|
||||
TOOL_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPO_ROOT = TOOL_ROOT.parents[1]
|
||||
CONVERT = TOOL_ROOT / "scripts" / "convert_tsf.py"
|
||||
GENERATE = TOOL_ROOT / "scripts" / "generate.py"
|
||||
LINT = TOOL_ROOT / "scripts" / "lint.py"
|
||||
BUILD_INDEX = TOOL_ROOT / "scripts" / "build_index.py"
|
||||
LOOKUP = REPO_ROOT / "skills" / "tsl-api-reference" / "scripts" / "lookup.py"
|
||||
STANDARD = TOOL_ROOT / "STANDARD.md"
|
||||
README = TOOL_ROOT / "README.md"
|
||||
EXAMPLE_JSON = TOOL_ROOT / "examples" / "example.json"
|
||||
EXAMPLE_YAML = TOOL_ROOT / "examples" / "example.yaml"
|
||||
SKILL = REPO_ROOT / "skills" / "tsl-api-reference" / "SKILL.md"
|
||||
|
||||
|
||||
class UnifiedPipelineTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp_dir.name)
|
||||
self.skill_dir = self.root / "skills" / "tsl-api-reference"
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_dir.cleanup()
|
||||
|
||||
def run_command(self, *args, cwd=None):
|
||||
return subprocess.run(
|
||||
[sys.executable, *map(str, args)],
|
||||
cwd=cwd or REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
|
||||
def run_pipeline(self, sources, queries):
|
||||
tsf_paths = []
|
||||
for name, source in sources:
|
||||
tsf_path = self.root / f"{name}.tsf"
|
||||
tsf_path.write_text(
|
||||
textwrap.dedent(source).lstrip(), encoding="utf-8"
|
||||
)
|
||||
tsf_paths.append(tsf_path)
|
||||
|
||||
page_path = "base/mixed"
|
||||
recordings = {}
|
||||
recording_paths = {}
|
||||
for output_format in ("json", "yaml"):
|
||||
recording = self.root / f"mixed.{output_format}"
|
||||
convert = self.run_command(
|
||||
CONVERT,
|
||||
*tsf_paths,
|
||||
"--format",
|
||||
output_format,
|
||||
"--module",
|
||||
"示例 / 混合 API",
|
||||
"--path",
|
||||
page_path,
|
||||
"--output",
|
||||
recording,
|
||||
)
|
||||
self.assertEqual(0, convert.returncode, convert.stderr)
|
||||
recording_paths[output_format] = recording
|
||||
text = recording.read_text(encoding="utf-8")
|
||||
recordings[output_format] = (
|
||||
json.loads(text)
|
||||
if output_format == "json"
|
||||
else yaml.safe_load(text)
|
||||
)
|
||||
self.assertEqual(recordings["json"], recordings["yaml"])
|
||||
|
||||
markdown = (
|
||||
self.skill_dir
|
||||
/ "references"
|
||||
/ "codegen"
|
||||
/ "project"
|
||||
/ f"{page_path}.md"
|
||||
)
|
||||
generated_markdown = {}
|
||||
for output_format, recording in recording_paths.items():
|
||||
generate = self.run_command(GENERATE, recording, cwd=self.root)
|
||||
self.assertEqual(0, generate.returncode, generate.stderr)
|
||||
self.assertTrue(markdown.is_file())
|
||||
generated_markdown[output_format] = markdown.read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
lint = self.run_command(LINT, "--file", markdown, "--strict")
|
||||
self.assertEqual(0, lint.returncode, lint.stdout + lint.stderr)
|
||||
self.assertEqual(
|
||||
generated_markdown["json"], generated_markdown["yaml"]
|
||||
)
|
||||
|
||||
build = self.run_command(BUILD_INDEX, "--skill-dir", self.skill_dir)
|
||||
self.assertEqual(0, build.returncode, build.stderr)
|
||||
check = self.run_command(
|
||||
BUILD_INDEX, "--skill-dir", self.skill_dir, "--check"
|
||||
)
|
||||
self.assertEqual(0, check.returncode, check.stderr)
|
||||
|
||||
lookup_outputs = {}
|
||||
for query in queries:
|
||||
lookup = self.run_command(
|
||||
LOOKUP,
|
||||
"--name",
|
||||
query,
|
||||
"--tsv",
|
||||
self.skill_dir / "data" / "function_index.tsv",
|
||||
)
|
||||
self.assertEqual(0, lookup.returncode, lookup.stderr)
|
||||
lookup_outputs[query] = lookup.stdout
|
||||
return (
|
||||
recordings["json"],
|
||||
generated_markdown["json"],
|
||||
lookup_outputs,
|
||||
)
|
||||
|
||||
@unittest.skipUnless(yaml is not None, "pyyaml is not installed")
|
||||
def test_mixed_pipeline_convert_generate_lint_index_lookup(self):
|
||||
recording, markdown, lookups = self.run_pipeline(
|
||||
sources=[
|
||||
(
|
||||
"Widget",
|
||||
"""
|
||||
type Widget = class
|
||||
/// 组件
|
||||
public
|
||||
/// 重命名组件
|
||||
/// @param: name 组件名称
|
||||
function Rename(name: string): string;
|
||||
/// 创建组件
|
||||
class function Create(): Widget;
|
||||
/// 组件标题
|
||||
property Title: string read title_ write title_;
|
||||
/// 原始标题
|
||||
property RawTitle read title_;
|
||||
/// 标题存储
|
||||
title_: string;
|
||||
/// 组件数量
|
||||
static Count: integer;
|
||||
/// 默认类型
|
||||
const DefaultKind = 1;
|
||||
/// 最大数量
|
||||
static const Maximum = 100;
|
||||
end;
|
||||
""",
|
||||
),
|
||||
(
|
||||
"OpenWidget",
|
||||
"""
|
||||
function OpenWidget(path: string): Widget;
|
||||
begin
|
||||
/// 打开组件
|
||||
/// @param: path 组件路径
|
||||
return nil;
|
||||
end;
|
||||
""",
|
||||
),
|
||||
(
|
||||
"DemoUnit",
|
||||
"""
|
||||
unit DemoUnit;
|
||||
/// 接口
|
||||
interface
|
||||
/// 默认大小
|
||||
const DefaultSize = 100;
|
||||
/// 当前文档
|
||||
var CurrentDocument: Document;
|
||||
/// 打开文档
|
||||
/// @param: path 文档路径
|
||||
function OpenDocument(path: string): Document;
|
||||
type Document = class
|
||||
/// 文档
|
||||
public
|
||||
/// 保存
|
||||
function Save(): boolean;
|
||||
/// 创建文档
|
||||
class function Create(): Document;
|
||||
/// 文档标题
|
||||
property Title: string read title_ write title_;
|
||||
private
|
||||
title_: string;
|
||||
end;
|
||||
implementation
|
||||
end.
|
||||
""",
|
||||
),
|
||||
],
|
||||
queries=("Widget", "OpenWidget", "DemoUnit.Document"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
["class", "function", "unit"],
|
||||
[item["kind"] for item in recording["declarations"]],
|
||||
)
|
||||
widget_members = recording["declarations"][0]["members"]
|
||||
raw_title = next(
|
||||
item for item in widget_members if item["name"] == "RawTitle"
|
||||
)
|
||||
self.assertEqual("", raw_title["type"])
|
||||
self.assertEqual([], raw_title["params"])
|
||||
self.assertIn("## `Widget`\n\n声明:class", markdown)
|
||||
self.assertIn("## `OpenWidget(path)`\n\n声明:function", markdown)
|
||||
self.assertIn("## `DemoUnit`\n\n声明:unit", markdown)
|
||||
self.assertIn(
|
||||
"### `Create()`\n\n声明:class function", lookups["Widget"]
|
||||
)
|
||||
self.assertIn(
|
||||
"### `Rename(name)`\n\n声明:function", lookups["Widget"]
|
||||
)
|
||||
self.assertNotIn("## `OpenWidget()`", lookups["Widget"])
|
||||
self.assertIn("声明:function", lookups["OpenWidget"])
|
||||
self.assertNotIn("## `DemoUnit`", lookups["OpenWidget"])
|
||||
self.assertIn(
|
||||
"#### `Save()`\n\n声明:function", lookups["DemoUnit.Document"]
|
||||
)
|
||||
self.assertIn(
|
||||
"#### `Create()`\n\n声明:class function",
|
||||
lookups["DemoUnit.Document"],
|
||||
)
|
||||
|
||||
def test_standard_and_readme_describe_unified_declarations(self):
|
||||
standard = STANDARD.read_text(encoding="utf-8")
|
||||
readme = README.read_text(encoding="utf-8")
|
||||
skill = SKILL.read_text(encoding="utf-8")
|
||||
|
||||
self.assertTrue(standard.startswith("# TSL API 文档标准\n"))
|
||||
self.assertNotIn("根字段 `functions`、`class`、`unit`", standard)
|
||||
self.assertNotIn("每个 markdown 叶子页只记录一种页面类型", standard)
|
||||
for fragment in (
|
||||
"declarations",
|
||||
"声明:function",
|
||||
"声明:class",
|
||||
"声明:unit",
|
||||
"顶级 function",
|
||||
"顶级 class",
|
||||
"顶级 unit",
|
||||
"综合示例",
|
||||
):
|
||||
self.assertIn(fragment, standard)
|
||||
for heading in (
|
||||
"### function\n",
|
||||
"### class\n",
|
||||
"### unit\n",
|
||||
"### 综合示例\n",
|
||||
):
|
||||
self.assertIn(heading, standard)
|
||||
for heading in (
|
||||
"### 顶级 function\n",
|
||||
"### 顶级 class\n",
|
||||
"### 顶级 unit\n",
|
||||
"### 混合页面示例\n",
|
||||
):
|
||||
self.assertNotIn(heading, standard)
|
||||
for fragment in (
|
||||
"declarations",
|
||||
"混合输入",
|
||||
"OpenXmlAttribute.tsf",
|
||||
"OpenXmlRuntime.tsf",
|
||||
"13 列",
|
||||
"property 类型可选",
|
||||
):
|
||||
self.assertIn(fragment, readme)
|
||||
self.assertIn(
|
||||
"## `OpenXmlAttribute`\n\n声明:class", readme
|
||||
)
|
||||
self.assertIn(
|
||||
"### `CreateVirtual(position, row_index, _story)`\n\n"
|
||||
"声明:class function",
|
||||
readme,
|
||||
)
|
||||
self.assertNotIn("property/field/variable 缺少类型", readme)
|
||||
self.assertIn("混合页面", skill)
|
||||
self.assertIn("page#anchor", skill)
|
||||
|
||||
def test_standard_class_methods_reuse_top_level_function_structure(self):
|
||||
standard = STANDARD.read_text(encoding="utf-8")
|
||||
class_section = standard.split("### class\n", 1)[1].split(
|
||||
"### unit\n", 1
|
||||
)[0]
|
||||
normalized_class = " ".join(class_section.split())
|
||||
normalized_standard = " ".join(standard.split())
|
||||
|
||||
self.assertIn("正文结构与顶级 function 相同", normalized_class)
|
||||
self.assertIn("实例 function 和 class function", normalized_class)
|
||||
self.assertIn("标题及其子标题整体下移一级", normalized_class)
|
||||
self.assertEqual(
|
||||
2,
|
||||
class_section.count("<!-- tags: OpenXml 属性 创建 -->"),
|
||||
)
|
||||
self.assertIn(
|
||||
"所有 class member 都可以包含可选的 `tags`",
|
||||
normalized_standard,
|
||||
)
|
||||
self.assertIn(
|
||||
"### `CreateVirtual(position, row_index, _story)`",
|
||||
class_section,
|
||||
)
|
||||
self.assertIn("声明:class function", class_section)
|
||||
self.assertIn("返回:ParagraphSegment", class_section)
|
||||
|
||||
@unittest.skipUnless(yaml is not None, "pyyaml is not installed")
|
||||
def test_json_and_yaml_examples_use_the_same_complete_structure(self):
|
||||
json_data = json.loads(EXAMPLE_JSON.read_text(encoding="utf-8"))
|
||||
yaml_data = yaml.safe_load(EXAMPLE_YAML.read_text(encoding="utf-8"))
|
||||
|
||||
expected_root = {"module", "path", "declarations"}
|
||||
self.assertEqual(expected_root, set(json_data))
|
||||
self.assertEqual(expected_root, set(yaml_data))
|
||||
self.assertEqual(json_data, yaml_data)
|
||||
|
||||
declarations = {
|
||||
item["kind"]: item for item in json_data["declarations"]
|
||||
}
|
||||
self.assertEqual({"function", "class", "unit"}, set(declarations))
|
||||
self.assertEqual(
|
||||
["class", "function", "unit"],
|
||||
[item["kind"] for item in json_data["declarations"]],
|
||||
)
|
||||
|
||||
class_members = declarations["class"]["members"]
|
||||
self.assertEqual(
|
||||
[
|
||||
"create",
|
||||
"CreateVirtual",
|
||||
"Prefix",
|
||||
"NamespaceUri",
|
||||
"value_",
|
||||
"Count",
|
||||
"DefaultPrefix",
|
||||
"MaximumAttributes",
|
||||
],
|
||||
[member["name"] for member in class_members],
|
||||
)
|
||||
self.assertIn("class", [m.get("binding") for m in class_members])
|
||||
self.assertEqual(
|
||||
"string",
|
||||
next(m for m in class_members if m["name"] == "Prefix")["type"],
|
||||
)
|
||||
self.assertNotIn(
|
||||
"type",
|
||||
next(m for m in class_members if m["name"] == "NamespaceUri"),
|
||||
)
|
||||
self.assertEqual(
|
||||
{"method", "property", "field", "constant"},
|
||||
{member["kind"] for member in class_members},
|
||||
)
|
||||
self.assertTrue(
|
||||
next(m for m in class_members if m["name"] == "Count")["static"]
|
||||
)
|
||||
self.assertTrue(
|
||||
next(
|
||||
m
|
||||
for m in class_members
|
||||
if m["name"] == "MaximumAttributes"
|
||||
)["static"]
|
||||
)
|
||||
|
||||
unit_members = declarations["unit"]["members"]
|
||||
self.assertEqual(
|
||||
{"constant", "variable", "function", "class"},
|
||||
{member["kind"] for member in unit_members},
|
||||
)
|
||||
document = next(
|
||||
member for member in unit_members if member["kind"] == "class"
|
||||
)
|
||||
self.assertEqual(
|
||||
{("method", "instance"), ("method", "class"), ("property", None)},
|
||||
{(m["kind"], m.get("binding")) for m in document["members"]},
|
||||
)
|
||||
|
||||
page = (
|
||||
self.skill_dir
|
||||
/ "references"
|
||||
/ "codegen"
|
||||
/ "project"
|
||||
/ f"{json_data['path']}.md"
|
||||
)
|
||||
markdown_by_format = {}
|
||||
for output_format, recording in (
|
||||
("json", EXAMPLE_JSON),
|
||||
("yaml", EXAMPLE_YAML),
|
||||
):
|
||||
generate = self.run_command(GENERATE, recording, cwd=self.root)
|
||||
self.assertEqual(0, generate.returncode, generate.stderr)
|
||||
markdown_by_format[output_format] = page.read_bytes()
|
||||
lint = self.run_command(LINT, "--file", page, "--strict")
|
||||
self.assertEqual(0, lint.returncode, lint.stdout + lint.stderr)
|
||||
self.assertEqual(markdown_by_format["json"], markdown_by_format["yaml"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user