✨ feat(tsl-codegen): support unified API declarations
Add TSF conversion and shared Markdown recognition for mixed function, class, and unit declarations. Extend generation, linting, indexing, examples, and tests around the unified declaration model.
This commit is contained in:
+192
-56
@@ -1,21 +1,24 @@
|
|||||||
# TSL Codegen Toolkit
|
# TSL Codegen Toolkit
|
||||||
|
|
||||||
本工具把 YAML/JSON 录入文件转换为 TSL API skill 使用的 Markdown 函数文档,
|
本工具把一个或多个 tsf function、独立 class、完整 unit 转换为统一
|
||||||
并根据 Markdown 重建 `function_index.tsv`
|
`declarations` json/yaml 录入文件,再生成 TSL API skill 使用的 markdown 文档,
|
||||||
|
并根据 markdown 重建统一 API 索引 `function_index.tsv`。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
tools/tsl-codegen/
|
tools/tsl-codegen/
|
||||||
├─ README.md 使用说明
|
├─ README.md 使用说明
|
||||||
├─ STANDARD.md 函数文档与录入格式标准
|
├─ STANDARD.md API 文档与录入格式标准
|
||||||
├─ examples/
|
├─ examples/
|
||||||
│ ├─ example.yaml YAML 录入例子
|
│ ├─ example.yaml yaml 录入例子
|
||||||
│ └─ example.json JSON 录入例子
|
│ └─ example.json json 录入例子
|
||||||
├─ scripts/
|
├─ scripts/
|
||||||
│ ├─ generate.py YAML/JSON → Markdown
|
│ ├─ convert_tsf.py tsf → json/yaml
|
||||||
│ ├─ lint.py Markdown 格式校验
|
│ ├─ generate.py yaml/json → markdown
|
||||||
│ └─ build_index.py 重建 function_index.tsv
|
│ ├─ lint.py markdown 格式校验
|
||||||
|
│ ├─ api_markdown.py lint/index 共用标题模型
|
||||||
|
│ └─ build_index.py 重建 13 列 function_index.tsv
|
||||||
└─ tests/ 工具测试
|
└─ tests/ 工具测试
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -29,38 +32,165 @@ tools/tsl-codegen/
|
|||||||
cd /path/to/playbook
|
cd /path/to/playbook
|
||||||
```
|
```
|
||||||
|
|
||||||
JSON 使用 Python 标准库,不需要额外安装解析包。YAML 需要安装 `pyyaml`:
|
json 使用 Python 标准库,不需要额外安装解析包。yaml 需要安装 `pyyaml`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m pip install pyyaml
|
python -m pip install pyyaml
|
||||||
```
|
```
|
||||||
|
|
||||||
|
markdown 生成器会强制使用仓库锁定版本的 Prettier。首次使用前安装 Node.js,并在
|
||||||
|
仓库根目录安装依赖:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
### 2. 阅读标准
|
### 2. 阅读标准
|
||||||
|
|
||||||
先阅读 [`STANDARD.md`](STANDARD.md)。其中定义:
|
先阅读 [`STANDARD.md`](STANDARD.md)。其中定义:
|
||||||
|
|
||||||
- Markdown 函数条目的固定结构
|
- function、class、unit 同级 H2 顶级声明和混合页面的固定层级
|
||||||
- YAML/JSON 录入字段
|
- 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.yaml`](examples/example.yaml)
|
||||||
- [`examples/example.json`](examples/example.json)
|
- [`examples/example.json`](examples/example.json)
|
||||||
|
|
||||||
例子中的函数是格式示例,不是真实 TSL API。复制例子到自己的工作目录,再修改
|
两份完整例子深度等价,同时包含顶级 function、完整 class、class function、
|
||||||
`module`、`path` 和 `functions`。例如:
|
typed/untyped property、字段、常量、完整 unit 和 interface class 成员
|
||||||
|
|
||||||
```text
|
例子中的 API 是格式示例,不是真实 TSL API。复制例子到自己的工作目录,再修改
|
||||||
tmp/my-functions.yaml
|
`module`、`path` 和非空有序 `declarations`。录入根只允许这三个字段;旧
|
||||||
tmp/my-functions.json
|
`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 中的目标位置
|
### 4. 选择 Skill 中的目标位置
|
||||||
@@ -82,18 +212,18 @@ skills/tsl-api-reference/
|
|||||||
└─ lookup.py
|
└─ lookup.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Markdown 目标路径固定为:
|
markdown 目标路径固定为:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
skills/tsl-api-reference/references/codegen/<scope>/<module-dir>/<page>.md
|
skills/tsl-api-reference/references/codegen/<scope>/<module-dir>/<page>.md
|
||||||
```
|
```
|
||||||
|
|
||||||
- `<scope>`:用户文档默认使用 `project`,也可以自定义单级目录名。`builtin` 和
|
- `<scope>`:用户文档默认使用 `project`,也可以自定义单级目录名。`builtin` 和
|
||||||
`dotnet` 由 playbook 项目维护,不应用于存放用户自己的函数文档
|
`dotnet` 由 playbook 项目维护,不应用于存放用户自己的 API 文档
|
||||||
- `<module-dir>`:功能分类目录,例如 `base`、`runtime`、`document`
|
- `<module-dir>`:功能分类目录,例如 `base`、`runtime`、`document`
|
||||||
- `<page>.md`:同类函数的叶子文档,例如 `array.md`、`string.md`
|
- `<page>.md`:相关 API 的叶子文档,例如 `array.md`、`elements.md`
|
||||||
|
|
||||||
录入文件的 `module` 是 Markdown 一级标题,不是目录名。例如:
|
录入文件的 `module` 是 markdown 一级标题,不是目录名。例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
module: 我的项目 / 数组
|
module: 我的项目 / 数组
|
||||||
@@ -107,63 +237,60 @@ path: base/array
|
|||||||
rg --files skills/tsl-api-reference/references/codegen/project
|
rg --files skills/tsl-api-reference/references/codegen/project
|
||||||
```
|
```
|
||||||
|
|
||||||
生成后可以直接打开目标 Markdown 手动阅读。例如:
|
生成后可以直接打开目标 markdown 手动阅读。例如:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
skills/tsl-api-reference/references/codegen/project/base/array.md
|
skills/tsl-api-reference/references/codegen/project/base/array.md
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 生成 Markdown
|
### 5. 生成 markdown
|
||||||
|
|
||||||
#### 新建叶子页
|
#### 新建叶子页
|
||||||
|
|
||||||
目标文件不存在时,可以直接生成到 skill。例如:
|
目标文件不存在时,可以直接生成到 skill。例如:
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
python tools/tsl-codegen/scripts/generate.py tmp/my-functions.json
|
python tools/tsl-codegen/scripts/generate.py tmp/my-api.json
|
||||||
```
|
```
|
||||||
|
|
||||||
生成器读取录入文件中的 `path`,默认写入
|
生成器读取录入文件中的 `path`,默认写入
|
||||||
`skills/tsl-api-reference/references/codegen/project/<path>.md`。不指定
|
`skills/tsl-api-reference/references/codegen/project/<path>.md`。不指定
|
||||||
`--scope` 时,scope 就是 `project`
|
`--scope` 时,scope 就是 `project`
|
||||||
|
|
||||||
|
写入前会自动使用仓库的 `.prettierrc.json` 格式化 markdown,使新页面与现有
|
||||||
|
builtin 页面保持一致。未安装 Prettier 或格式化失败时,生成器会停止且不写目标文件
|
||||||
|
|
||||||
需要使用自定义 scope 时,通过 `--scope` 指定单级目录名:
|
需要使用自定义 scope 时,通过 `--scope` 指定单级目录名:
|
||||||
|
|
||||||
```bash
|
```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` 对应的页面。只有录入文件包含该页面的全部函数时才运行
|
生成器会整体覆盖 `path` 对应的页面。只有录入文件包含该页面的全部 API 时才运行
|
||||||
生成器。只修改现有页面中的少量函数时,应按照 `STANDARD.md` 直接编辑 Markdown
|
生成器。只修改现有页面中的少量条目时,应按照 `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,并在
|
通过 `generate.py` 生成的页面已经完成 Prettier 格式化,不需要再次处理。直接手工
|
||||||
仓库根目录安装 `prettier`:
|
编辑 markdown 后,可以单独格式化目标文件:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install --save-dev prettier
|
npx --no-install prettier --write skills/tsl-api-reference/references/codegen/project/base/my_api.md
|
||||||
```
|
|
||||||
|
|
||||||
然后格式化目标文件:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prettier --write skills/tsl-api-reference/references/codegen/project/base/my_functions.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 6.2 校验
|
#### 6.2 校验
|
||||||
@@ -171,7 +298,7 @@ npx prettier --write skills/tsl-api-reference/references/codegen/project/base/my
|
|||||||
校验目标文件:
|
校验目标文件:
|
||||||
|
|
||||||
```bash
|
```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 行为空
|
- tags 行为空
|
||||||
|
|
||||||
### 7. 更新并验证函数索引
|
### 7. 更新并验证统一 API 索引
|
||||||
|
|
||||||
Markdown 确认无误后,重建 TSV:
|
markdown 确认无误后,重建 TSV:
|
||||||
|
|
||||||
索引会分别保存函数的 tags 和描述。关键词检索会同时匹配函数名、签名、模块、
|
索引固定为 13 列:保留原 8 列并追加 kind、binding、visibility、owner、
|
||||||
tags 和描述
|
qualified_name。关键词检索会同时匹配原有字段和这些扩展字段。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python tools/tsl-codegen/scripts/build_index.py --skill-dir skills/tsl-api-reference
|
python tools/tsl-codegen/scripts/build_index.py --skill-dir skills/tsl-api-reference
|
||||||
```
|
```
|
||||||
|
|
||||||
检查 TSV 是否与 Markdown 一致:
|
检查 TSV 是否与 markdown 一致:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python tools/tsl-codegen/scripts/build_index.py --skill-dir skills/tsl-api-reference --check
|
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-dir>/data/function_index.tsv`
|
||||||
|
|
||||||
最后使用函数名验证 skill 可以检索到新条目。把 `myFunction` 替换为真实函数名:
|
最后使用简单名称或完全限定名称验证 skill 可以检索到新条目:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python skills/tsl-api-reference/scripts/lookup.py --name myFunction
|
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`
|
- `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": "示例 / 数组",
|
"module": "OfficeXml / OpenXml",
|
||||||
"path": "base/example",
|
"path": "officexml/openxml/elements",
|
||||||
"functions": [
|
"declarations": [
|
||||||
{
|
{
|
||||||
"signature": "demoNow()",
|
"kind": "class",
|
||||||
"desc": "返回示例值。",
|
"name": "OpenXmlAttribute",
|
||||||
"returns": "integer",
|
"desc": "表示 OpenXml 属性",
|
||||||
"example": "return demoNow();\n// 输出:1"
|
"tags": ["OpenXml", "XML", "属性"],
|
||||||
},
|
"members": [
|
||||||
{
|
|
||||||
"signature": "demoFn(src, mode, factor, ...)",
|
|
||||||
"tags": ["示例", "数组"],
|
|
||||||
"desc": "按指定模式处理数组并返回结果。",
|
|
||||||
"params": [
|
|
||||||
{
|
{
|
||||||
"name": "src",
|
"kind": "method",
|
||||||
"type": "array",
|
"name": "create",
|
||||||
"desc": "待处理数组"
|
"visibility": "public",
|
||||||
},
|
"binding": "instance",
|
||||||
{
|
"signature": "create(_prefix, _local_name)",
|
||||||
"name": "mode",
|
"desc": "创建 OpenXml 属性",
|
||||||
"type": "integer",
|
"tags": ["OpenXml", "属性", "创建"],
|
||||||
"desc": "处理模式,取值见下。",
|
"params": [
|
||||||
"values": [
|
|
||||||
{
|
{
|
||||||
"value": 0,
|
"name": "_prefix",
|
||||||
"desc": "原样返回"
|
"type": "string",
|
||||||
|
"desc": "命名空间前缀"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"value": 1,
|
"name": "_local_name",
|
||||||
"desc": "去重"
|
"type": "string",
|
||||||
|
"desc": "本地名称"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "factor",
|
"kind": "method",
|
||||||
"type": "float",
|
"name": "CreateVirtual",
|
||||||
"optional": true,
|
"visibility": "public",
|
||||||
"desc": "默认 1.0,结果乘以该系数。"
|
"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": "...",
|
"kind": "property",
|
||||||
"type": "nil|array",
|
"name": "Prefix",
|
||||||
"optional": true,
|
"visibility": "public",
|
||||||
"desc": "需要追加处理的其他数组。"
|
"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",
|
"returns": "OpenXmlElement",
|
||||||
"example": "src := array(1, 1, 2);\nreturn demoFn(src, 1, 2.0);\n// 输出:array(2,4)"
|
"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: 示例 / 数组
|
module: OfficeXml / OpenXml
|
||||||
path: base/example
|
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:
|
- kind: method
|
||||||
- signature: demoNow()
|
name: CreateVirtual
|
||||||
desc: 返回示例值。
|
visibility: public
|
||||||
returns: integer
|
binding: class
|
||||||
example: |
|
signature: CreateVirtual(position, row_index, _story)
|
||||||
return demoNow();
|
desc: 创建虚段落
|
||||||
// 输出:1
|
params:
|
||||||
|
- name: position
|
||||||
|
type: integer
|
||||||
|
desc: 段落位置
|
||||||
|
- name: row_index
|
||||||
|
type: integer
|
||||||
|
desc: 行索引
|
||||||
|
- name: _story
|
||||||
|
type: StoryNode
|
||||||
|
desc: 故事节点
|
||||||
|
returns: ParagraphSegment
|
||||||
|
|
||||||
- signature: demoFn(src, mode, factor, ...)
|
- kind: property
|
||||||
tags: [示例, 数组]
|
name: Prefix
|
||||||
desc: 按指定模式处理数组并返回结果。
|
visibility: public
|
||||||
params:
|
desc: 命名空间前缀
|
||||||
- name: src
|
type: string
|
||||||
type: array
|
access: readwrite
|
||||||
desc: 待处理数组
|
|
||||||
- name: mode
|
- 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
|
type: integer
|
||||||
desc: 处理模式,取值见下。
|
static: true
|
||||||
values:
|
|
||||||
- value: 0
|
- kind: constant
|
||||||
desc: 原样返回
|
name: DefaultPrefix
|
||||||
- value: 1
|
visibility: public
|
||||||
desc: 去重
|
desc: 默认命名空间前缀
|
||||||
- name: factor
|
type: string
|
||||||
type: float
|
value: xml
|
||||||
optional: true
|
|
||||||
desc: 默认 1.0,结果乘以该系数。
|
- kind: constant
|
||||||
- name: "..."
|
name: MaximumAttributes
|
||||||
type: nil|array
|
visibility: public
|
||||||
optional: true
|
desc: 最大属性数量
|
||||||
desc: 需要追加处理的其他数组。
|
type: integer
|
||||||
returns: array
|
value: 256
|
||||||
example: |
|
static: true
|
||||||
src := array(1, 1, 2);
|
|
||||||
return demoFn(src, 1, 2.0);
|
- kind: function
|
||||||
// 输出:array(2,4)
|
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
|
#!/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
|
The markdown tree is the source of truth. The TSV is a derived product;
|
||||||
is one function entry. The TSV is a derived product; regenerate it whenever the
|
regenerate it whenever the leaf Markdown changes rather than editing it by
|
||||||
leaf Markdown changes rather than editing it by hand.
|
hand.
|
||||||
|
|
||||||
Columns (tab-separated, LF line endings, UTF-8):
|
Columns (tab-separated, LF line endings, UTF-8):
|
||||||
name scope module signature page anchor tags summary
|
name scope module signature page anchor tags summary
|
||||||
|
kind binding visibility owner qualified_name
|
||||||
- name: signature text up to the first '('
|
- name: signature text up to the first '('
|
||||||
- scope: first path segment under the codegen root (for example project)
|
- scope: first path segment under the codegen root (for example project)
|
||||||
- module: second path segment for nested pages, else the flat file stem
|
- module: second path segment for nested pages, else the flat file stem
|
||||||
- signature: verbatim from the heading, backticks stripped
|
- signature: verbatim from the heading, backticks stripped
|
||||||
- page: POSIX path relative to the codegen root
|
- page: POSIX path relative to the codegen root
|
||||||
- anchor: GitHub-style slug of the name (lowercased, chars outside
|
- anchor: top-level declarations use the historic name slug; typed members
|
||||||
[a-z0-9_] removed); per-page duplicate slugs get -1/-2 suffixes
|
slug the complete visible API title. Per-page duplicate slugs get
|
||||||
in document order, matching the rendered heading anchors.
|
-1/-2 suffixes in document order.
|
||||||
- tags: space-separated keywords from `<!-- tags: ... -->`
|
- tags: space-separated keywords from `<!-- tags: ... -->`
|
||||||
- summary: first prose line under the entry heading, empty for table,
|
- summary: first prose line under the entry heading
|
||||||
heading, or standalone return-type lines
|
- 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):
|
Usage (run from repo root; --skill-dir is required):
|
||||||
SKILL=skills/tsl-api-reference
|
SKILL=skills/tsl-api-reference
|
||||||
@@ -31,9 +36,22 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
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"^返回[::]")
|
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*-->$")
|
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
|
||||||
|
VISIBILITY_RE = re.compile(
|
||||||
|
r"^可见性[::]\s*`?(public|protected|private)`?\s*$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
HEADER = [
|
HEADER = [
|
||||||
"name",
|
"name",
|
||||||
"scope",
|
"scope",
|
||||||
@@ -43,29 +61,56 @@ HEADER = [
|
|||||||
"anchor",
|
"anchor",
|
||||||
"tags",
|
"tags",
|
||||||
"summary",
|
"summary",
|
||||||
|
"kind",
|
||||||
|
"binding",
|
||||||
|
"visibility",
|
||||||
|
"owner",
|
||||||
|
"qualified_name",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def slug(name):
|
def extract_metadata(lines, heading_idx, end_idx=None):
|
||||||
"""GitHub-style anchor slug: lowercase, keep [a-z0-9_], drop the rest."""
|
"""Return tags and the first prose line under one API heading."""
|
||||||
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."""
|
|
||||||
tags = ""
|
tags = ""
|
||||||
for line in lines[heading_idx + 1:]:
|
summary = ""
|
||||||
|
summary_open = True
|
||||||
|
for line in lines[heading_idx + 1:end_idx]:
|
||||||
text = line.strip()
|
text = line.strip()
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
|
if DECLARATION_LINE_RE.fullmatch(text):
|
||||||
|
continue
|
||||||
tag_match = TAGS_RE.match(text)
|
tag_match = TAGS_RE.match(text)
|
||||||
if tag_match:
|
if tag_match:
|
||||||
tags = " ".join(tag_match.group(1).split()).replace("\t", " ")
|
tags = " ".join(tag_match.group(1).split()).replace("\t", " ")
|
||||||
continue
|
continue
|
||||||
if text.startswith("|") or text.startswith("#") or RETURN_RE.match(text):
|
if summary:
|
||||||
return tags, ""
|
continue
|
||||||
return tags, text.replace("\t", " ")
|
if (
|
||||||
return tags, ""
|
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):
|
def parse_page(codegen_root, md):
|
||||||
@@ -75,18 +120,69 @@ def parse_page(codegen_root, md):
|
|||||||
seen = {}
|
seen = {}
|
||||||
rows = []
|
rows = []
|
||||||
lines = md.read_text(encoding="utf-8").splitlines()
|
lines = md.read_text(encoding="utf-8").splitlines()
|
||||||
for idx, line in enumerate(lines):
|
entries = list(iter_api_entries(lines))
|
||||||
m = ENTRY_RE.match(line)
|
root_kind = ""
|
||||||
if not m:
|
root_name = ""
|
||||||
|
unit_class_owner = ""
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
heading = entry.heading
|
||||||
|
if not heading.valid:
|
||||||
continue
|
continue
|
||||||
sig = m.group(1)
|
|
||||||
name = sig.split("(", 1)[0]
|
if heading.level == 2:
|
||||||
base = slug(name)
|
root_kind = heading.kind
|
||||||
n = seen.get(base, 0)
|
root_name = heading.name
|
||||||
seen[base] = n + 1
|
unit_class_owner = ""
|
||||||
anchor = base if n == 0 else f"{base}-{n}"
|
owner = ""
|
||||||
tags, summary = extract_metadata(lines, idx)
|
anchor_base = slug(heading.name)
|
||||||
rows.append([name, scope, module, sig, page, anchor, tags, summary])
|
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
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +216,16 @@ def read_tsv(tsv_path):
|
|||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
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(
|
parser.add_argument(
|
||||||
"--skill-dir",
|
"--skill-dir",
|
||||||
required=True,
|
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.
|
"""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
|
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
|
an ordered `declarations` list containing function, class, or unit entries. This
|
||||||
stores, matching tools/tsl-codegen/STANDARD.md.
|
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
|
Rendered Markdown is passed through the repository-pinned Prettier before it is
|
||||||
used optionally to align columns.
|
written, keeping generated pages consistent with the existing codegen tree.
|
||||||
|
|
||||||
Input dispatch is by extension: .json parses with the stdlib (keeping the
|
Input dispatch is by extension: .json parses with the stdlib (keeping the
|
||||||
toolchain dependency-free); .yml/.yaml needs pyyaml. If pyyaml is missing the
|
toolchain dependency-free); .yml/.yaml needs pyyaml. If pyyaml is missing the
|
||||||
script says so and points at the JSON path.
|
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
|
signature required verbatim, underscores/case untouched
|
||||||
desc required description; may contain multiple lines
|
desc required description; may contain multiple lines
|
||||||
tags optional list of Chinese keywords -> `<!-- tags: ... -->`
|
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 \
|
python tools/tsl-codegen/scripts/generate.py entry.json \
|
||||||
--scope my-project
|
--scope my-project
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
PRETTIER_CONFIG = REPO_ROOT / ".prettierrc.json"
|
||||||
|
|
||||||
|
|
||||||
def die(msg):
|
def die(msg):
|
||||||
print(f"ERROR: {msg}", file=sys.stderr)
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
@@ -53,10 +65,7 @@ def resolve_format(path, fmt):
|
|||||||
return "json"
|
return "json"
|
||||||
if suffix in (".yml", ".yaml"):
|
if suffix in (".yml", ".yaml"):
|
||||||
return "yaml"
|
return "yaml"
|
||||||
die(
|
die(f"cannot infer format from extension '{suffix}'; " f"pass --format json|yaml")
|
||||||
f"cannot infer format from extension '{suffix}'; "
|
|
||||||
f"pass --format json|yaml"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_entries(path, fmt=None):
|
def load_entries(path, fmt=None):
|
||||||
@@ -96,6 +105,322 @@ def require(cond, msg):
|
|||||||
die(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):
|
def param_desc(param, where):
|
||||||
"""Description column text: prepend `可选。` for optional params."""
|
"""Description column text: prepend `可选。` for optional params."""
|
||||||
desc = param.get("desc")
|
desc = param.get("desc")
|
||||||
@@ -120,14 +445,17 @@ def render_param_table(params, where):
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def render_enum_sections(params):
|
def render_enum_sections(params, heading_level=None):
|
||||||
"""`**name 取值**` sections for every param carrying a `values` list."""
|
"""Render value sections, preserving legacy function-page headings."""
|
||||||
lines = []
|
lines = []
|
||||||
for param in params:
|
for param in params:
|
||||||
values = param.get("values")
|
values = param.get("values")
|
||||||
if not values:
|
if not values:
|
||||||
continue
|
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("")
|
lines.append("")
|
||||||
for item in values:
|
for item in values:
|
||||||
lines.append(f"- `{item['value']}` — {item['desc']}")
|
lines.append(f"- `{item['value']}` — {item['desc']}")
|
||||||
@@ -135,23 +463,89 @@ def render_enum_sections(params):
|
|||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def render_function(fn, index):
|
def render_examples(fn, heading_level):
|
||||||
"""Render one function entry to a list of lines (no trailing blank)."""
|
lines = []
|
||||||
where = f"functions[{index}]"
|
if "examples" in fn:
|
||||||
sig = fn.get("signature")
|
lines.extend([f"{'#' * heading_level} 示例", ""])
|
||||||
require(sig, f"{where}: missing 'signature'")
|
for index, example in enumerate(fn["examples"], start=1):
|
||||||
desc = fn.get("desc")
|
lines.append(f"范例{index:02d}:{example['desc']}")
|
||||||
require(desc, f"{where} ({sig}): missing 'desc'")
|
lines.append("")
|
||||||
returns = fn.get("returns")
|
lines.append("```tsl")
|
||||||
require(returns, f"{where} ({sig}): missing 'returns'")
|
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:
|
if tags:
|
||||||
lines.append(f"<!-- tags: {' '.join(str(t) for t in tags)} -->")
|
lines.extend([f"<!-- tags: {' '.join(tags)} -->", ""])
|
||||||
lines.append("")
|
return lines
|
||||||
lines.append(desc)
|
|
||||||
lines.append("")
|
|
||||||
|
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 []
|
params = fn.get("params") or []
|
||||||
if params:
|
if params:
|
||||||
@@ -159,34 +553,168 @@ def render_function(fn, index):
|
|||||||
lines.append("")
|
lines.append("")
|
||||||
lines.extend(render_enum_sections(params))
|
lines.extend(render_enum_sections(params))
|
||||||
|
|
||||||
lines.append(f"返回:{returns}")
|
lines.append(f"返回:{fn['returns']}")
|
||||||
|
examples = render_examples(fn, 3)
|
||||||
example = fn.get("example")
|
if examples:
|
||||||
if example:
|
lines.extend(["", *examples])
|
||||||
lines.append("")
|
|
||||||
lines.append("### 示例")
|
|
||||||
lines.append("")
|
|
||||||
lines.append("```tsl")
|
|
||||||
lines.extend(example.rstrip("\n").split("\n"))
|
|
||||||
lines.append("```")
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
def render_page(data):
|
def render_class_member(member, level, where):
|
||||||
"""Render a whole leaf page: H1 + every function entry."""
|
kind = member["kind"]
|
||||||
require(isinstance(data, dict), "input root must be a mapping")
|
if kind == "method":
|
||||||
module = data.get("module")
|
declaration = (
|
||||||
require(module, "input missing 'module'")
|
"class function"
|
||||||
functions = data.get("functions")
|
if member["binding"] == "class"
|
||||||
require(functions, "input missing non-empty 'functions'")
|
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):
|
def render_class(cls, level, where, *, page_root):
|
||||||
out.extend(render_function(fn, index))
|
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("")
|
out.append("")
|
||||||
return "\n".join(out).rstrip("\n") + "\n"
|
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):
|
def output_path(data, scope):
|
||||||
"""Build the leaf-page destination from the recording file's relative path."""
|
"""Build the leaf-page destination from the recording file's relative path."""
|
||||||
relative = data.get("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):
|
def main(argv=None):
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
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(
|
parser.add_argument(
|
||||||
"input",
|
"input",
|
||||||
metavar="INPUT_FILE",
|
metavar="INPUT_FILE",
|
||||||
@@ -231,10 +786,9 @@ def main(argv=None):
|
|||||||
die(f"input not found: {in_path}")
|
die(f"input not found: {in_path}")
|
||||||
data = load_entries(in_path, args.format)
|
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 = output_path(data, args.scope)
|
||||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
atomic_write(out_path, text)
|
||||||
out_path.write_text(text, encoding="utf-8", newline="\n")
|
|
||||||
print(
|
print(
|
||||||
f"wrote {out_path}",
|
f"wrote {out_path}",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
|
|||||||
@@ -1,17 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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.
|
The standard lives in tools/tsl-codegen/STANDARD.md.
|
||||||
Each `## `sig`` / `### `sig`` heading starts one function entry. Rules split
|
Each typed H2 starts one top-level declaration; typed H3/H4 headings describe
|
||||||
into hard errors (CI-blocking) and soft warnings (style
|
class or unit members. Rules split into hard errors (CI-blocking) and soft
|
||||||
convergence over the ~12k existing entries).
|
warnings (style convergence over the ~12k existing entries).
|
||||||
|
|
||||||
Hard errors:
|
Hard errors include incomplete descriptions/metadata/parameter tables and
|
||||||
- missing/empty description (first prose line after the signature)
|
invalid class/unit API headings, visibility, or child-heading levels.
|
||||||
- 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 参数 / 类型 / 说明 三列
|
|
||||||
|
|
||||||
Soft warnings:
|
Soft warnings:
|
||||||
- optional-parameter wording not starting with `可选。`
|
- optional-parameter wording not starting with `可选。`
|
||||||
@@ -29,12 +25,26 @@ import re
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Entry heading: `## `sig`` or `### `sig``. Matches the index generator's rule
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||||
# so the linter and the tsv agree on what a function entry is.
|
if str(SCRIPT_DIR) not in sys.path:
|
||||||
ENTRY_RE = re.compile(r"^(#{2,3})(?!#)\s+`(.+?)`\s*$")
|
sys.path.insert(0, str(SCRIPT_DIR))
|
||||||
RETURN_RE = re.compile(r"^返回[::]")
|
|
||||||
|
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*$")
|
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->\s*$")
|
||||||
FENCE_RE = re.compile(r"^(```|~~~)")
|
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"可选|可省略|省略")
|
OPTIONAL_HINT_RE = re.compile(r"可选|可省略|省略")
|
||||||
# Split a table row on unescaped pipes so `nil\|array` stays one cell.
|
# Split a table row on unescaped pipes so `nil\|array` stays one cell.
|
||||||
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
CELL_SPLIT_RE = re.compile(r"(?<!\\)\|")
|
||||||
@@ -43,20 +53,6 @@ SEP_CELL_RE = re.compile(r"^:?-+:?$")
|
|||||||
PARAM_HEADER = ["参数", "类型", "说明"]
|
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):
|
def scan_body(lines, start, end):
|
||||||
"""Return [(lineno, raw, in_fence)] for the entry body (excludes the
|
"""Return [(lineno, raw, in_fence)] for the entry body (excludes the
|
||||||
signature line). Fence delimiter lines are marked in_fence so callers
|
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())
|
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):
|
def split_row(text):
|
||||||
"""Split a markdown table row into trimmed cells, honoring `\\|` escapes."""
|
"""Split a markdown table row into trimmed cells, honoring `\\|` escapes."""
|
||||||
parts = CELL_SPLIT_RE.split(text.strip())
|
parts = CELL_SPLIT_RE.split(text.strip())
|
||||||
@@ -130,16 +136,38 @@ def find_description(body):
|
|||||||
stripped = raw.strip()
|
stripped = raw.strip()
|
||||||
if not stripped or in_fence:
|
if not stripped or in_fence:
|
||||||
continue
|
continue
|
||||||
if stripped.startswith("<!--"): # tags or other comment: skip
|
if DECLARATION_LINE_RE.fullmatch(stripped):
|
||||||
continue
|
continue
|
||||||
if stripped.startswith("|") or stripped.startswith("#") \
|
if (
|
||||||
or RETURN_RE.match(stripped):
|
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 False, lineno
|
||||||
return True, lineno
|
return True, lineno
|
||||||
return False, None
|
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
|
entry_line = start + 1 # 1-based signature line, used for entry-level errors
|
||||||
body = scan_body(lines, start, end)
|
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())
|
RETURN_RE.match(raw.strip())
|
||||||
for _, raw, in_fence in body if not in_fence
|
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",
|
findings.append((md_display, entry_line, "error", "return",
|
||||||
f"`{sig}` 缺少 `返回:类型` 行"))
|
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 ------------------------------------------------------
|
# parameter table ------------------------------------------------------
|
||||||
table = find_table(body)
|
table = find_table(body)
|
||||||
wants_params = has_params(sig)
|
wants_params = has_params(sig)
|
||||||
@@ -176,6 +233,39 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
|||||||
"param-header",
|
"param-header",
|
||||||
f"参数表表头须为 {' / '.join(PARAM_HEADER)},"
|
f"参数表表头须为 {' / '.join(PARAM_HEADER)},"
|
||||||
f"实为 {' / '.join(header_cells) or '(空)'}"))
|
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
|
# soft: optional-parameter wording
|
||||||
for lineno, cells in data_rows:
|
for lineno, cells in data_rows:
|
||||||
if len(cells) < 3:
|
if len(cells) < 3:
|
||||||
@@ -195,14 +285,211 @@ def check_entry(md_display, lines, start, end, sig, findings):
|
|||||||
"空的 tags 行;填入关键词或删除"))
|
"空的 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):
|
def lint_file(md, root, findings):
|
||||||
try:
|
try:
|
||||||
display = md.relative_to(root).as_posix()
|
display = md.relative_to(root).as_posix()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
display = str(md)
|
display = str(md)
|
||||||
lines = md.read_text(encoding="utf-8").splitlines()
|
lines = md.read_text(encoding="utf-8").splitlines()
|
||||||
for start, end, sig in iter_entries(lines):
|
for entry in iter_api_entries(lines):
|
||||||
check_entry(display, lines, start, end, sig, findings)
|
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):
|
def gather_targets(paths, root):
|
||||||
@@ -219,7 +506,16 @@ def gather_targets(paths, root):
|
|||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
if hasattr(sys.stdout, "reconfigure"):
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
sys.stdout.reconfigure(encoding="utf-8")
|
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 = parser.add_mutually_exclusive_group(required=True)
|
||||||
target_group.add_argument("--file", help="要校验的单个 Markdown 文件")
|
target_group.add_argument("--file", help="要校验的单个 Markdown 文件")
|
||||||
target_group.add_argument("--dir", help="要递归校验的目录")
|
target_group.add_argument("--dir", help="要递归校验的目录")
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ class FunctionIndexTest(unittest.TestCase):
|
|||||||
leaf.write_text(
|
leaf.write_text(
|
||||||
"# Builtin - 基础 / 数组\n\n"
|
"# Builtin - 基础 / 数组\n\n"
|
||||||
"## `demo()`\n\n"
|
"## `demo()`\n\n"
|
||||||
"<!-- tags: 数组 列表 -->\n\n"
|
"声明:function\n\n"
|
||||||
"返回示例值。\n\n"
|
"返回示例值。\n\n"
|
||||||
|
"<!-- tags: 数组 列表 -->\n\n"
|
||||||
"返回:integer\n",
|
"返回:integer\n",
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
@@ -50,6 +51,12 @@ class FunctionIndexTest(unittest.TestCase):
|
|||||||
result = self.module.main(["--skill-dir", str(self.skill_dir), *args])
|
result = self.module.main(["--skill-dir", str(self.skill_dir), *args])
|
||||||
return result, stdout.getvalue(), stderr.getvalue()
|
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):
|
def test_rebuild_writes_only_tsv(self):
|
||||||
result, _, _ = self.run_main()
|
result, _, _ = self.run_main()
|
||||||
|
|
||||||
@@ -64,6 +71,294 @@ class FunctionIndexTest(unittest.TestCase):
|
|||||||
self.assertEqual("数组 列表", row["tags"])
|
self.assertEqual("数组 列表", row["tags"])
|
||||||
self.assertEqual("返回示例值。", row["summary"])
|
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):
|
def test_check_does_not_require_index_pages(self):
|
||||||
self.data_dir.mkdir(parents=True)
|
self.data_dir.mkdir(parents=True)
|
||||||
rows = self.module.build_rows(self.codegen_root)
|
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 subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -6,7 +7,22 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
SCRIPT = Path(__file__).parents[1] / "scripts" / "lint.py"
|
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):
|
class DocLintCliTest(unittest.TestCase):
|
||||||
@@ -38,6 +54,781 @@ class DocLintCliTest(unittest.TestCase):
|
|||||||
self.assertEqual(result.returncode, 0, result.stderr)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
self.assertIn("1 files", 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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user