📦 deps(tsl): sync tsl-playbook from f90faa10

Source-Commit: f90faa10c3
This commit is contained in:
ci[bot]
2026-08-13 15:37:54 +08:00
parent 85a9d8ee40
commit 2c38c5fc06
21 changed files with 721 additions and 1825 deletions
+7 -2
View File
@@ -58,9 +58,9 @@ python <this-skill-dir>/scripts/lookup.py --section "syntax-05-004" "syntax-02-0
- 中文口语词自动扩展到 TSL 术语(如"打印"→输出/writeLn"列表"→数组,
"程序慢"→性能分析)。保留提取词的原写法即可。
- `tsl``tsf``tinysoft``program``debug``please` 这几个词不参与逐词匹配,
- `tsl``tsf``tinysoft``debug``please` 这几个词不参与逐词匹配,
但可能整体把查询导向某个专题页。无结果时靠加这类词补救没有用,改为换更具体的
语法要素名。
语法要素名。`program` 是真实 TSL 关键字,会参与精确匹配。
## 弱命中视同无匹配
@@ -93,6 +93,9 @@ Section ID 抄自 `--section` 输出首部的 `Section ID:` 行。多个要素
写不出某个要素的 Section ID,说明该要素还没有取回:先补齐检索,再给代码。
不含代码块的语法解释同样要在相关结论后列出依据的 Section ID;多个结论来自不同
章节时逐项对应,不用一个 Section ID 为整段未取回的结论背书。
## 代码块身份
`--section` 正文里每个代码围栏前有一行 `代码块身份:`。身份决定这段代码能否进入
@@ -115,6 +118,8 @@ Section ID 抄自 `--section` 输出首部的 `Section ID:` 行。多个要素
## 缺口时停止
**🔴 CHECKPOINT · 🛑 STOP:确认以下缺口后停止生成,不输出含缺口要素的代码。**
出现下列任一情形,即为事实缺口:
**检索缺口** —— `--query` 改进查询词后仍无匹配;lookup 返回非零状态;`--section`
+37 -9
View File
@@ -31,23 +31,42 @@
"函数指针释放": ["DeleteInstance", "MakeInstance"],
"Linux 动态加载": ["dlopen", "dlsym", ".so"],
"远程调用客户端": ["RDo", "RDo2", "客户端远程调用"],
"本地弹窗": ["RDo2", "InputQuery", "客户端远程调用"]
"本地弹窗": ["RDo2", "InputQuery", "客户端远程调用"],
"全局变量": ["global", "跨函数共享变量"],
"静态计算": ["static", "表达式缓存"],
"只计算一次": ["static", "表达式缓存"],
"指定系统函数": ["system", "系统函数限定"],
"内存上限": ["_maxMem_", "最大内存"],
"每列聚集": ["avgof", "多字段聚集"],
"按行广播": ["二维矩阵", "一维数组", "行广播"]
},
"page_intent_aliases": {
"01_quickstart.md": ["最简单能跑", "最简单的脚本", "天软脚本", "tinysoft"],
"02_core_model.md": [
"脚本和可复用",
"可复用函数文件",
"声明函数后面写代码"
"声明函数后面写代码",
"完整 program 入口",
"echo 输出多个值"
],
"03_values_and_literals.md": [
"字符串和数组下标",
"下标从几开始",
"下标起点"
],
"04_variables_and_constants.md": ["常量怎么声明", "变量能不能直接赋值"],
"05_functions_and_calls.md": ["默认参数", "函数怎么带"],
"06_expressions_and_operators.md": ["赋值和相等比较"],
"04_variables_and_constants.md": [
"常量怎么声明",
"变量能不能直接赋值",
"全局变量",
"运行时常量"
],
"05_functions_and_calls.md": ["默认参数", "函数怎么带", "指定系统函数"],
"06_expressions_and_operators.md": [
"赋值和相等比较",
"运算符优先级",
"静态计算",
"只计算一次"
],
"07_control_flow.md": [
"跳出去",
"循环里满足条件",
@@ -59,7 +78,12 @@
"08_objects_and_classes.md": ["定义类", "创建对象"],
"09_units_and_scope.md": ["多个文件", "复用一组函数"],
"10_runtime_context_and_with.md": ["临时切换系统参数"],
"11_matrix_and_collections.md": ["某行存在", "二维数组怎么判断"],
"11_matrix_and_collections.md": [
"某行存在",
"二维数组怎么判断",
"按行广播",
"二维矩阵乘一维数组"
],
"12_resultset_and_filters.md": ["保留匹配行", "按某一列"],
"13_ts_sql.md": [
"左连接",
@@ -67,7 +91,9 @@
"左联接",
"数据库",
"分组排序",
"聚合排序"
"聚合排序",
"JOIN 第二张表当前行",
"每列聚集"
],
"14_debug_and_profiler.md": [
"程序慢",
@@ -77,11 +103,13 @@
"执行了多久",
"执行时间",
"耗时",
"debug"
"debug",
"内存上限"
],
"15_lexical_structure_and_compile_options.md": [
"变量名区分大小写",
"注释怎么写"
"注释怎么写",
"嵌套注释"
],
"16_types_and_conversions.md": ["字符串转整数", "类型转换"],
"17_external_calls_and_threads.md": [
@@ -6,7 +6,7 @@
<!-- section-id: syntax-02-001 -->
回答“目标文件到底是 `.tsl` 脚本还是 `.tsf` 可复用声明文件,以及 `.tsl` 里的哪些内容会顺序执行、哪些内容只是后置声明”。
回答“目标文件到底是 `.tsl` 脚本还是 `.tsf` 可复用声明文件,`.tsl` 里的哪些内容会顺序执行、哪些内容只是后置声明,以及何时使用完整 `program ... begin ... end.` 入口形态”。
本页是文件模型的唯一事实源:后缀判断、语句区 / 声明区顺序、`.tsf` 顶层声明形态和文件名约束都在这里收口。函数体、类体、`unit` 内部的语法外形由各自专题页拥有。
@@ -33,6 +33,7 @@
- `uses` 可以出现在顶层,但这里只把它当成辅助语句,不把它当成主体声明;函数体和类定义体里的位置限制见 [09_units_and_scope.md](09_units_and_scope.md)。
-`class Name` 不作为类定义写法使用。
-`.tsl` 中,不要在声明区之后继续追加脚本语句。
- `program Name; ... begin ... end.` 是完整程序入口形态;它可以在主 `begin` 前声明子函数,但不是普通 `.tsl` 脚本的默认起手式。
- `unit` 默认先按完整形态理解;它也可以省略 `interface` / `implementation` 写成简写形态,见 [09_units_and_scope.md](09_units_and_scope.md)。
- 不要把 `.tsl` 写成只有顶层函数的模块;如果用户要通用可复用函数,优先写 `.tsf`
- 不要把 `.tsf` 写成会直接执行脚本语句的入口;如果用户要顺序执行入口,优先写 `.tsl`
@@ -184,6 +185,64 @@ end.
1
```
### 完整 `program` 入口形态
<!-- section-id: syntax-02-009 -->
<!-- tags: PROGRAM 入口, 完整程序, 独立脚本入口, CGI 入口, 主 begin 块 -->
普通 `.tsl` 默认仍使用前面的松散语句区模型。只有用户明确要求完整程序入口、独立脚本 / CGI 兼容形态,或现有代码已经使用 `program` 时,才写成下面的结构:
代码块身份:可直接照写示例
```tsl
program DemoProgram;
function DoubleValue(value);
begin
return value * 2;
end;
begin
echo "result:", DoubleValue(3);
end.
```
代码块身份:输出片段
```text
result:6
```
说明:
- `program Name;` 位于文件开头
- 子函数声明位于主 `begin ... end.` 之前
- 完整程序以 `end.` 结束,而普通函数声明仍以 `end;` 结束
- 不要把这套结构与“松散语句区在前、声明区在后”的默认 `.tsl` 模型混写
### `echo` 输出语句
<!-- section-id: syntax-02-010 -->
<!-- tags: echo 输出, 打印表达式, 输出多个值, 逗号输出, 控制台输出 -->
`echo expr1, expr2, ...;` 会按从左到右的顺序输出表达式列表。简单值直接输出;数组、对象等复杂值可能只显示类型摘要。具体输出目标由宿主环境决定,可能是控制台、Web 响应或客户端输出窗口。
代码块身份:可直接照写示例
```tsl
echo "A=", 1, ",B=", 2;
```
代码块身份:输出片段
```text
A=1,B=2
```
不要把 `echo` 的宿主输出位置或复杂对象的展示格式当成跨环境固定结果。
### 文件模型反例
<!-- section-id: syntax-02-006 -->
@@ -262,5 +321,6 @@ function:__main__:line 9: invalid statement
-`.tsl` 当成 `.tsf` 来写,只给一个顶层函数,不写任何会执行的脚本语句。
-`.tsf` 当成 `.tsl` 来写,在模块文件里直接堆顺序执行的脚本语句。
-`program ... begin ... end.` 与松散 `.tsl` 语句区 / 后置声明区混成一套文件结构。
-`uses` 当成主体声明,而不是辅助组织语句。
- `.tsf` 文件名与顶层声明不一致:`UserAccount.tsf` 中写 `function GetUser``type Customer = class``unit CustomerModule` 会导致加载失败或检索混乱。
@@ -24,6 +24,7 @@
<!-- quickstart-rule: index-origins -->
- `array(...)` 既可以写顺序数组,也可以写字符串键表;顺序数组和 `binary(...)` 二进制缓冲区下标从 `0` 开始,字符串下标从 `1` 开始。
- 数组元素可以是混合类型;给不存在的下标赋值会扩张数组。显式整数键可写成 `array(0: value0, 1: value1)`
- `s[0]` 在运行时会越界,不要把字符串当成 0 基下标。
- 字符串取子串用 `s[start:end]`,并且 `end` 是包含在结果里的。
- 字符串替换子串用 `s[start:end] := "..."`
@@ -36,6 +37,8 @@
- `U""` 不是宽串;需要在 UTF8、宽串、普通串之间显式转换时,继续用 `utf8ToUnicode(...)``utf8ToAnsi(...)``ansiToUtf8(...)``unicodeToUtf8(...)``string(...)``wideString(...)`
- `#number` 可以直接把字符码拼进字符串。
- `\0``#0` 都能把 ASCII `0` 放进字符串,并且不会把字符串截断。
- 字符串 `like` 的右侧按正则表达式解释,不是 SQL `%` / `_` 通配,也不要把正则里的 `*` 当成独立的 glob 通配符;默认匹配不区分大小写。
- 数值 `like` 表示近似相等;`likeEps``likeEpsRate` 分别控制绝对误差与相对误差阈值,默认值都是 `1e-6`
## 可直接照写示例
@@ -170,6 +173,33 @@ C
代码块说明:顺序数组 `items``0` 开始,字符串键表 `row` 用字符串键访问,字符串 `s``1` 开始。
数组按写入下标扩张:
代码块身份:可直接照写示例
```tsl
items := array(1);
items[3] := 4;
keyed := array(0: 2, 1: 3);
writeLn(length(items));
writeLn(items[1] = nil);
writeLn(items[3]);
writeLn(keyed[0]);
writeLn(keyed[1]);
```
代码块身份:输出片段
```text
2
1
4
2
3
```
说明:`length(items)` 统计已有项数,不等于“最大整数键 + 1”;下标 `1` 没有写入,因此读取为 `nil`
### 字符串边界规则
<!-- section-id: syntax-03-004 -->
@@ -416,20 +446,20 @@ A=1 B=2.34 C=AAA
- 纯小数可能走科学计数法:`"_" $ 0.0000005 $ ","` 返回 `"_5E-7,"`
- 带整数部分的小数保留精度:`"_" $ 1.0000005 $ ","` 返回 `"_1.0000005,"`
### 字符串 `like` 模式匹配
### 字符串与数值 `like`
<!-- section-id: syntax-03-007 -->
<!-- tags: 通配符匹配, 正则匹配, 正则表达式, 模糊匹配, 判断是否符合模式 -->
<!-- tags: 正则匹配, 正则表达式, 模糊匹配, 判断是否符合模式, 默认不区分大小写, 数值近似相等, like 精度 -->
`like` 用于判断字符串是否符合指定模式(支持通配符和正则表达式)
字符串 `like` 的右侧是正则表达式;默认匹配不区分大小写
代码块身份:可直接照写示例
```tsl
result1 := "hello" like "he*";
result2 := "HELLO" like "he*";
result3 := "test@example.com" like "*@*";
result1 := "a" like "A";
result2 := "HELLO" like "hello";
result3 := "2009-1-1" like "\\d+-\\d+-\\d+";
writeLn("result1:", result1);
writeLn("result2:", result2);
writeLn("result3:", result3);
@@ -439,22 +469,49 @@ writeLn("result3:", result3);
```text
result1: 1
result2: 0
result2: 1
result3: 1
```
说明:
- `like` 大小写敏感,`"hello" like "he*"` 返回 `1`(真),`"HELLO" like "he*"` 返回 `0`
- `*` 是通配符,匹配任意字符序列
- `like` 也支持正则表达式模式(如 `"\\d{4}-\\d{2}-\\d{2}"` 匹配日期格式)
- 默认情况下,`"a" like "A"` `"HELLO" like "hello"` 返回 `1`
- `"\\d+-\\d+-\\d+"` 是正则表达式,匹配示例中的日期形态
- 正则量词 `*` 只修饰它前面的原子;不要把 `"he*"` 解释成“`he` 后面任意字符”的 glob 模式,需要任意字符序列时写成 `.*`
- 匹配控制标志可以改变大小写规则;控制标志的精确 API、参数与目标 scope 使用 `tsl-api-reference` skill 核对
数值 `like` 用于近似相等判断:
代码块身份:可直接照写示例
```tsl
old_abs_eps := likeEps;
old_rate_eps := likeEpsRate;
likeEps := 0.1;
likeEpsRate := 0;
writeLn(1.05 like 1);
likeEps := old_abs_eps;
likeEpsRate := old_rate_eps;
```
代码块身份:输出片段
```text
1
```
说明:
- `likeEps``likeEpsRate` 都可读写,默认值均为 `1e-6`
- 两数绝对差小于 `likeEps`,或“绝对差 / 两数绝对值的平均值”小于 `likeEpsRate` 时,数值 `like` 返回真
- 临时修改阈值后要恢复旧值,避免影响同一运行时中的后续比较
`not like` 是取反形式(TSL 2025/8 版本起支持):
代码块身份:可直接照写示例
```tsl
result := "abc" not like "xyz*";
result := "abc" not like "xyz.*";
writeLn(result);
```
@@ -547,6 +604,10 @@ items := array(1, 2, 3);
- 把普通字符串里的 `\uXXXX` 直接当成宽串单字符。
-`U""` 当成宽串;需要宽串时按本页转换链处理。
-`#0` / `\0` 当成 C 风格字符串终止符。
- 把字符串 `like` 误写成默认区分大小写;默认行为是不区分大小写。
-`like` 只理解成字符串模式匹配,遗漏数值近似相等语义。
- 修改 `likeEps` / `likeEpsRate` 后不恢复,导致后续代码继承意外的比较阈值。
- 以为给较大整数下标赋值后,中间所有位置都会自动生成实际元素;未写入的位置仍为 `nil`
代码块身份:反例 / 不可照写
@@ -6,7 +6,7 @@
<!-- section-id: syntax-04-001 -->
回答“普通变量怎样直接使用、`var` 在什么位置出现、常量必须怎样初始化、哪些名字一旦绑定就不能再赋值”。
回答“普通变量怎样直接使用、`var` 在什么位置出现、常量必须怎样初始化、`global` 怎样跨函数共享,以及运行时常量怎样冻结一次调用中的表达式结果”。
## 核心规则
@@ -27,6 +27,8 @@
- 右侧数组元素也可以是数组;拆出来的变量会直接得到对应子数组。
- 多参数赋值也可以出现在函数调用参数里。
- `{$explicit+}` 开启后,后续变量必须先用 `var` 声明;未声明变量会报 `variable not defined`
- `global x, y;` 声明当前任务中的全局变量;每个需要访问它的函数都要再次写 `global` 声明。
- `const name := expression;` 是运行时常量:进入所属函数时计算,当前调用内不可再次赋值;不要与编译时常量 `const name = expression;` 混写。
## 可直接照写示例
@@ -178,6 +180,71 @@ const kMaxRetries = 3 + 4;
value := kMaxRetries;
```
### 全局变量 `global`
<!-- section-id: syntax-04-009 -->
<!-- tags: 全局变量, 跨函数共享变量, 不同函数访问同一变量, global 声明 -->
`global` 让同一运行任务中的不同函数访问同一变量。每个需要读写该变量的函数都要声明它;漏写时,同名标识符会按局部变量处理。
代码块身份:可直接照写示例
```tsl
global shared_value;
shared_value := 37;
writeLn(ReadShared());
function ReadShared();
begin
global shared_value;
return shared_value;
end;
```
代码块身份:输出片段
```text
37
```
本节只拥有“当前任务内跨函数共享”和“每个引用函数都要声明”的规则;不要从中推断跨任务持久化、线程隔离或进程级生命周期。
### 运行时常量 `const name := expression`
<!-- section-id: syntax-04-010 -->
<!-- tags: 运行时常量, 表达式常量, 每次调用重新计算, 初始化后不能修改 -->
运行时常量使用 `:=` 初始化,可以依赖参数、变量或函数调用。它在每次进入所属函数时重新计算一次,随后在本次调用内不可修改。
代码块身份:可直接照写示例
```tsl
writeLn(FreezeValue(2));
writeLn(FreezeValue(5));
function FreezeValue(value);
begin
const fixed := value + 1;
return fixed;
end;
```
代码块身份:输出片段
```text
3
6
```
边界:
- 编译时常量写 `const name = expression;`;运行时常量写 `const name := expression;`
- 运行时常量声明放在函数 `begin ... end` 语句体中,不放在函数头后的 `const` 声明段
- 运行时常量不作为类成员写法
- 初始化后再次赋值会破坏常量约束,不要生成
### 多参数赋值
<!-- section-id: syntax-04-006 -->
@@ -331,6 +398,9 @@ items := array(1, 2, 3);
- 以为 `const` 可以只声明名字,不写初始化表达式。
- 以为单变量拆包可以写成 `[a] := array(...)`
- 以为 `{$explicit+}` 开启后仍然可以继续直接写未声明变量。
- 只在脚本顶层声明一次 `global`,却忘记在读取它的函数里再次声明。
- 把运行时常量 `const name := expression;` 写进函数头后的编译时 `const` 声明段。
- 把运行时常量当成可重新赋值的普通变量或类成员。
代码块身份:反例 / 不可照写
@@ -50,7 +50,8 @@
- 一旦某次调用里开始使用命名参数,后面的参数就不能再退回位置参数。
- 对二进制函数 / 系统函数直接使用命名参数,会报 `named parameter mode can't support here`;这类函数要先用 TSL 再封一层。
- 函数参数支持默认值。
- 普通函数的默认值规则不要直接等同到 `unit interface` 声明;跨 `unit` 的默认参数边界只照本页最小反例和 [09_units_and_scope.md](09_units_and_scope.md) 处理
- `unit interface` 中的默认参数可以引用该接口中可访问的 `const`;实现函数头不要重复声明默认值
- 默认参数表达式属于新一代 TSL 能力;表达式里引用变量时,该变量按 `0` 求值。面向旧运行时或版本不明时,默认只生成字面量 / 可访问常量默认值。
- 尾部 `...` 形式的可变参数属于文档明确写法。
- 在可变参数函数体里,`Params``ParamCount``RealParamCount` 都可用。
- 可变参数组可以通过 `...` 转发给另一个函数调用。
@@ -61,6 +62,7 @@
- 匿名函数和 TSL 函数值的稳定调用方式仍是 `call(f, ...)``##f(...)`
- `f(...)` 这种“函数变量直接调用”写法不作为可写事实;无论 `f` 是匿名函数、`findFunction(...)` 还是 `thisFunction(...)` 返回的函数指针,都不要默认写成直调。
- `::FuncName(...)` 可以指向全局/系统函数,用来绕过当前作用域里的同名局部函数。
- `system.FuncName(...)` 专门强制指定系统函数;它与 `::FuncName(...)` 的全局限定语义不要互相替代。
- `external`、原生函数指针包装、`makeInstance` / C 回调和线程调用的事实见 [17_external_calls_and_threads.md](17_external_calls_and_threads.md)。
- 不要在 `.tsl` 的函数声明区之后继续追加脚本语句。
@@ -502,7 +504,7 @@ begin
end;
```
默认值也可以写成表达式:
新一代 TSL 的默认值也可以写成表达式:
代码块身份:可直接照写示例
@@ -523,24 +525,56 @@ end;
- `Pack(a: 1)` 返回 `12`
- `ExprDefault()` 返回 `3`
`unit interface` 声明下的默认参数要单独看。普通函数默认参数可用,不等于跨 `unit` 声明边界也同样可靠。
表达式默认值的版本边界:
代码块身份:反例 / 不可照写
代码块身份:可直接照写示例
```text
unit UnitConst;
interface
const default_value = 888;
function F(a, b = 100, c = default_value);
```tsl
function RefDefault(a, b = a + 1);
begin
return b;
end;
```
边界说明:
- `F(1)` 输出 `101``F(1, 2)` 输出 `3`
- 同一组文件下,`UnitConst.default_value` 可读到 `888`,而 `F(1, 2, 3)` 输出 `6`
- 因此不要把“普通函数默认参数可用”直接泛化成“`unit interface` 里引用 `unit const` 的默认参数也同样可靠”
- 这类跨 `unit` 的声明边界事实见 [09_units_and_scope.md](09_units_and_scope.md)
- 在支持默认参数表达式的新一代 TSL 中,`RefDefault(5)` 返回 `1`因为默认表达式里的变量 `a` `0` 求值
- 这项能力自 2025-08-27 后的 NG 客户端 / 新一代 TSL 服务端提供;目标版本不明时不要生成变量参与的默认表达式
`unit interface` 可以使用接口中可访问的常量作为默认值:
代码块身份:配置片段 / 概念骨架
```text
// UnitDefaults.tsf
unit UnitDefaults;
interface
const default_value = 888;
function F(a, b = 100, c = default_value);
implementation
function F(a, b, c);
begin
return a + b + c;
end;
end.
// main.tsl
uses UnitDefaults;
echo F(1), ",", F(1, 2);
```
代码块身份:输出片段
```text
989,891
```
说明:默认值只在 `interface` 声明处写一次;实现函数头使用同一组形参,但不重复 `= ...`
### 可变参数 `...`
@@ -686,7 +720,12 @@ end;
<!-- section-id: syntax-05-011 -->
<!-- tags: lambda, 闭包, 函数当变量, 函数当参数, 回调, 函数指针, 把函数存起来 -->
<!-- tags: lambda, 闭包, 函数当变量, 函数当参数, 回调, 函数指针, 把函数存起来, 匿名函数作用域, 对象成员, 宿主函数, 临时变量, 捕获外层变量 -->
匿名函数作用域边界:
- 匿名函数定义在类的方法中时,可以访问当前对象的成员和方法。
- 匿名函数不能访问宿主函数的临时变量;不要把它当成会捕获外层临时变量的词法闭包。
匿名函数变量:
@@ -851,7 +890,26 @@ end;
<!-- section-id: syntax-05-013 -->
`external`、原生函数指针包装、`makeInstance` / C 回调和线程调用,统一见 [17_external_calls_and_threads.md](17_external_calls_and_threads.md)。这一篇只保留“普通函数怎样定义和调用”的主线。
<!-- tags: system 前缀, 指定系统函数, 绕过同名用户函数, 系统函数限定 -->
系统函数可能与用户定义函数同名。需要明确指定系统实现时,使用 `system.FuncName(...)`
代码块身份:可直接照写示例
```tsl
value := system.strToInt("123");
writeLn(value);
```
代码块身份:输出片段
```text
123
```
本节只拥有 `system.` 这一调用限定语法。示例中的真实函数名、签名和目标 scope 必须由 `tsl-api-reference` skill 核对;不要从本节推断任意系统 API。
`external`、原生函数指针包装、`makeInstance` / C 回调和线程调用,统一见 [17_external_calls_and_threads.md](17_external_calls_and_threads.md)。
## 默认生成模板
@@ -906,7 +964,10 @@ end;
-`a = 1` 这种比较表达式误当成命名参数调用。
- 以为默认值只能用于无类型参数。
-`const` 形参上直接赋值。
- 把普通函数的默认值规则原样套到 `unit interface` 里的 `const` 默认参数
- 在旧运行时或版本不明时生成默认参数表达式
- 以为默认参数表达式里的形参会取本次调用实参;其中变量按 `0` 求值。
-`unit interface``implementation` 的函数头上重复写默认值。
-`::FuncName(...)``system.FuncName(...)` 互相替代,而不区分全局限定与系统函数限定。
- 把匿名函数或 `findFunction(...)` 返回值默认写成 `f(...)` 直调。
- 把命名参数直接套到二进制函数或系统函数上。
- 在一次调用里先进入命名参数模式,后面又退回位置参数。
@@ -995,7 +1056,7 @@ const default_value = 888;
function F(a, b = 100, c = default_value);
```
不要把上面这种 `unit interface` 声明直接当成已经等价于普通函数默认参数规则。按文档结果,对应的 `F(1)` 输出是 `101`,不是按 `default_value = 888` 补成的结果;具体边界见 [09_units_and_scope.md](09_units_and_scope.md)
上面的片段缺少 `implementation``end.`,因此不能作为完整 `unit` 文件直接照写;它不是“接口常量不能作为默认值”的反例。完整可写结构见本页默认参数章节
代码块身份:反例 / 不可照写
@@ -101,6 +101,7 @@
- 连续标量比较才用 `:>``:<``:<>``:==``:>=``:<=`
- 数组逐元素链式比较才用 `::>``::<``::<>``::==``::>=``::<=`
- 混合两类以上运算符时,优先用括号明确分组,不依赖跨语言记忆里的优先级。
- TSL 的主要优先级从高到低是:成员/下标/调用,`not`、前置自增减与倒数/逆等一元运算,字符串 `$`,幂,乘除移位,加减(包括一元正负号)与集合/位运算,比较与 `in`/`like`/`is``and`/`or`,冒号,赋值,表达式前导 `@`;同级通常从左到右。
边界规则:
@@ -566,6 +567,113 @@ writeLn(if 2 > 1 then 2 else 1);
`if condition then true_value else false_value` 必须带 `else`,否则不是本页可照写的表达式形态。
### 运算符优先级
<!-- section-id: syntax-06-015 -->
<!-- tags: 运算符优先级, 谁先计算, 先乘除后加减, and or 优先级, 表达式加括号 -->
下表是面向当前正式文档已收录运算符的保守分组,数字越小优先级越高:
<!-- prettier-ignore-start -->
| 级别 | 主要形态 | 说明 |
| --- | --- | --- |
| 0 | `()``[]``.``?.`、函数调用 | 分组、访问、下标与调用最先结合。 |
| 1 | `not`、前置 `++` / `--``!``.!``.!!` | 逻辑非、前置自增减与倒数/逆等一元运算。 |
| 2 | `$` | 字符串连接。 |
| 3 | `^``~``:^` | 幂、开方与对应矩阵形态。 |
| 4 | `*``/``\``%``div``mod``shl``shr``rol``ror` | 乘除、取余与移位。 |
| 5 | `+``-`、一元正号 / 负号、集合运算、点前缀位/逻辑运算 | 加减、一元正负号及同组语言运算;因此幂先于一元正负号结合,例如 `-2 ^ 2` 等于 `-(2 ^ 2)`。 |
| 6 | 比较、`is``in``sqlin``like`、链式比较 | 关系判断。 |
| 7 | `and``or` | 低于比较,因此 `a > 1 and b < 2` 按两个比较再逻辑与理解。 |
| 8 | `:` | 冒号相关表达式形态。 |
| 9 | `:=` 与各类复合赋值 | 赋值接近最低优先级。 |
| 10 | `@` | 表达式对象前导最低。 |
<!-- prettier-ignore-end -->
同级运算通常从左到右求值。为了避免不同语言之间的优先级记忆混淆,混合两类以上运算符时仍推荐显式加括号:
代码块身份:可直接照写示例
```tsl
value := 1 + 2 * 3;
signedPower := -2 ^ 2;
flag := (2 > 1) and (1 < 2);
writeLn(value);
writeLn(signedPower);
writeLn(flag);
```
代码块身份:输出片段
```text
7
-4
1
```
稀有矩阵运算符的详细优先级以其专题页为准;不要用本表外推尚未写入正式文档的符号。
### 静态计算表达式 `static`
<!-- section-id: syntax-06-016 -->
<!-- tags: 静态计算, 只计算一次, 表达式缓存, static name, 按键缓存 -->
表达式前的 `static` 会缓存第一次计算结果。带 `name` 时,名称表达式是缓存键:同一键复用第一次结果,不同键分别计算。
代码块身份:可直接照写示例
```tsl
echo StaticValue(), ",", StaticValue();
function StaticValue();
begin
return static NextValue();
end;
function NextValue();
begin
global static_calls;
static_calls += 1;
return static_calls;
end;
```
代码块身份:输出片段
```text
1,1
```
按键分别缓存:
代码块身份:可直接照写示例
```tsl
echo NamedValue("A"), ",", NamedValue("A"), ",", NamedValue("B");
function NamedValue(key);
begin
return static NextValue() name key;
end;
function NextValue();
begin
global named_static_calls;
named_static_calls += 1;
return named_static_calls;
end;
```
代码块身份:输出片段
```text
1,1,2
```
这不是类成员的 `static` 字段;类静态成员见 [08_objects_and_classes.md](08_objects_and_classes.md)。缓存结果具有运行时状态,不要用它保存每次调用都必须重新计算的值。
### 表达式对象
<!-- section-id: syntax-06-007 -->
@@ -847,6 +955,8 @@ writeLn(value);
-`if` 表达式写成没有 `else` 的半句。
- 把本页明确的 `c?.a?.[1]` 外推成所有深链式空安全访问都可靠。
- 从其他语言推断 TSL 运算符能力。
- 把类成员 `static field;` 与表达式前导 `static Expression [name Key]` 当成同一种语法。
- 在复杂混合表达式里依赖其他语言的优先级记忆而省略括号。
代码块身份:反例 / 不可照写
@@ -16,6 +16,8 @@
- 块式分支内部的普通语句必须用分号结尾。
- 控制流块的 `begin ... end` 后可以加分号也可以不加(语法都允许)。
- `for` 支持 `to``downto`、可选 `step`,以及 `for i, v in array` 遍历。
- 计数 `for` 的初值、终值和步长确定后,循环次数随之固定;循环体内不要给控制变量赋值。
- `for i, v in array` 遍历期间,不要修改被遍历数组或其中元素。
- `while``repeat ... until` 都可直接使用;`repeat` 至少会先执行一轮再判断结束条件。
- `break` 会跳出当前最近一层循环,`continue` 会跳过当前轮剩余语句。
- `case ... of ... else ... end` 可作为语句形态生成;`end` 后可以加分号也可以不加。
@@ -154,6 +156,7 @@ for i, value in numbers do
- 依次输出 `10``120``230`
- 这说明 `for i, value in numbers` 里的 `i``0` 开始
- 遍历期间把 `numbers` 当成只读集合;需要修改时先遍历副本,或在循环结束后统一写回
代码块身份:输出片段
@@ -495,6 +498,8 @@ end
- 以为 `try ... finally` 会吞掉异常。
- 在还没搞清表达式规则前,先把复杂业务函数塞进条件里。
- 把控制流问题和函数文件模型问题混在一起排查。
- 在计数 `for` 循环体里给控制变量赋值。
- 在 `for ... in` 遍历期间修改被遍历数组或其中元素。
代码块身份:反例 / 不可照写
@@ -18,7 +18,7 @@
- `setSysParam(key, value)``getSysParam(key)` 可以直接用字符串键。
- `sysParams[key]` 可以直接读写这些运行时参数。
- 块环境语句可写成 `with *, sys_param_values do begin ... end``with **, sys_param_values do begin ... end`
- `with *` 会把提供的系统参数合并进当前运行时上下文;不要依赖它在块结束后自动恢复外层值
- `with *` 会把提供的系统参数合并进当前运行时上下文。普通自定义键可能在块后保留新值;特殊系统环境变量会在块结束后恢复,因此不要把两类键的恢复行为混为一谈
- `with **` 会用提供的系统参数建立隔离块环境;块结束后恢复外层系统参数。
- 后缀 `with` 形式写在函数文件调用后面:`#Func() with array(...)`
- `with array(...)` 只在该次调用里临时覆盖对应键,调用结束后会恢复外部原值。
@@ -110,7 +110,8 @@ writeLn(getSysParam("b"));
- 块内输出 `2``3`
- 块后输出 `2``3`
- 说明 `with *` 会把传入键合并进当前系统参数上下文;不要把它当成自动恢复外层值的隔离块
- 这组实验只证明普通自定义键 `"a"` / `"b"` 会合并并在块后保留新值
- 特殊系统环境变量在块结束后会恢复;不要用普通字符串键的结果外推股票、日期等特殊环境
`with *, SysParamArray do` 使用当前所有系统参数:
@@ -443,7 +444,7 @@ end;
- 把系统参数页直接写成金融函数页。
- 把 `#Func() with array(...)` 误判成也能直接套在本地函数 `Demo()` 后面。
- `with *` 误判成会自动恢复外层系统参数
- 以为 `with *` 对所有键都统一“不恢复”或统一“恢复”;普通自定义键与特殊系统环境变量的边界不同
- 以为 `with array(...)` 改的是全局永久值,不会恢复外层原环境。
- 把网格句柄直接当最终值用,而不做 `dupvalue(...)`
- 以为从全局缓存取出的值,本地写入后仍然保持缓存身份。
@@ -31,7 +31,7 @@
<!-- section-id: syntax-11-004 -->
<!-- tags: 建数组, 数组下标, 数组索引, array 下标, 字典, 键值对, 二维数组, 嵌套数组, 按名字取值 -->
<!-- tags: 建数组, 字典, 键值对, 二维数组, 嵌套数组, 按名字取值 -->
顺序数组与字符串键表:
@@ -299,7 +299,7 @@ writeLn("子集 (1,0):", subset[1][0]);
<!-- section-id: syntax-11-011 -->
<!-- tags: 长度不一致, 缺位补零, 标量广播, 数组和数字运算 -->
<!-- tags: 长度不一致, 缺位补零, 标量广播, 数组和数字运算, 按行广播, 二维矩阵乘一维数组 -->
基础算符作用于非完全矩阵(行长度不一致或字符串键不对齐的数组)时,对应位置不存在或为 `nil` 时**默认当 0 处理**
@@ -356,6 +356,32 @@ writeLn("(1,1):", result[1][1]);
- `matrix_value + 10` 每个元素都加 10
- 这些是逐元素运算(element-wise),区别于矩阵乘法 `:*`,见 [21_matrix_deep_dive.md](21_matrix_deep_dive.md)
二维矩阵与一维数组做基础算术时,一维数组按“行”广播;它的长度必须等于矩阵行数:
代码块身份:可直接照写示例
```tsl
matrix_value := array((1, 2, 3), (4, 5, 6));
row_factors := array(10, 100);
left_result := matrix_value * row_factors;
right_result := row_factors * matrix_value;
writeLn(left_result[0][0]);
writeLn(left_result[1][2]);
writeLn(right_result[0][1]);
writeLn(right_result[1][0]);
```
代码块身份:输出片段
```text
10
600
20
400
```
说明:第一行使用 `10`,第二行使用 `100`;左右操作数交换后仍按行广播。不要把这条规则误写成按列广播。
## 本页不生成的范围
<!-- section-id: syntax-11-012 -->
@@ -383,3 +409,4 @@ writeLn("(1,1):", result[1][1]);
- 不要把普通 `array(...)` 自动升级成 `FMArray``FMArray` 专属事实见 [22_fmarray.md](22_fmarray.md)。
- 不要把点前缀比较 `.>` 和矩阵链式比较 `::>` 混用;`.>` 返回逻辑数组,`::>` 是链式比较。
- 不要以为非完全矩阵缺位会报错;默认当 `0` 处理。
- 二维矩阵与一维数组运算时,不要把一维数组当成按列因子;它按行广播且长度要匹配行数。
@@ -19,8 +19,9 @@
- 在一维数组上做 TS-SQL 时,优先使用 `thisRow``thisRowIndex`
- `select` 返回二维结果,`sselect` 返回一维结果,`vselect` 返回单值,`mselect` 返回 `Matrix`
- `where``group by``order by` 可以直接接在 `from` 后面继续使用;`order by` 支持 `asc`/`desc` 与多列逗号分隔。
- 分组后按聚集条件筛选用 `having``where` 不能用聚集);`having``countof([字段])``countof(1)`,不要用 `countof(*)`
- 分组后按聚集条件筛选用 `having``where` 不能用聚集);计数可`countof([字段])``countof(1)``countof()`,也可用带空格的 `countof( * )`。无空格的 `countof(*)` 会与块注释起始符 `(*` 冲突
- 多表 `join` 时,字段访问应写成 `[表序号].["字段名"]``on` 可用 `and` 写多条件;`[表序号].*` 取整表列。
- 多表联接中,`thisRow(表序号)``thisRowIndex(表序号)` 分别取得指定来源表的当前整行与原始下标。
- 联接类型:`left join` 保留左表、`right join` 保留右表、`full join` 保留双方、`cross join` 笛卡尔积、逗号联接等价于 `cross join`;不匹配处用 `nil` 填充。
- `select` 列表支持 `distinct` 去重、`as 别名``as nil`(参与计算但不返回)、`起始列 to 结束列` 字段区间、`selectopt(位选项)``drange(区间/M of N)`
- 聚集函数统一形态 `Func(Expr[, Cond[, N[, MovingFirst[, CacheId]]]])`:条件聚集、移动聚集、多字段聚集、`refof(Expr, N)` 引用相对行;`aggof('名', Expr)` 调用自定义聚集回调。
@@ -233,6 +234,23 @@ writeLn(join_result[0]["V2"]);
100
```
联接上下文里的指定来源当前行:
代码块身份:可直接照写示例
```tsl
left_rows := array(("id": 1, "v": 10), ("id": 2, "v": 20));
right_rows := array(("id": 2, "v": 20), ("id": 3, "v": 30));
join_rows := select thisRow(1) as "LeftRow",
thisRow(2) as "RightRow",
thisRowIndex(1) as "LeftIndex",
thisRowIndex(2) as "RightIndex"
from left_rows join right_rows on [1].["id"] = [2].["id"]
end;
```
结果说明:唯一匹配行中,`LeftRow["v"] = 20``RightRow["v"] = 20``LeftIndex = 1``RightIndex = 0`
### `thisGroup`
<!-- section-id: syntax-13-011 -->
@@ -773,6 +791,29 @@ writeLn(ref_prev[1]["Expr1"]);
- 移动聚集:`avgof(表达式, 条件, N, MovingFirst)` 取当前行往前 N 条的滑动统计
- `refof(表达式, N)` 引用前 N 行的值(`N` 为负则往后);首行无前值时返回 `0`
`*` 作为聚集输入时必须和左括号留空格,避免 `(*` 被词法层识别为块注释:
代码块身份:可直接照写示例
```tsl
a := array(("x": 1, "y": 10), ("x": 3, "y": 20));
row_count := vselect countof( * ) from a end;
column_avg := select avgof( * ) from a end;
writeLn(row_count);
writeLn(column_avg[0]["Expr1"]);
writeLn(column_avg[0]["Expr2"]);
```
代码块身份:输出片段
```text
2
2.0
15.0
```
说明:`avgof( * )` 对每列分别聚集;这里两列平均值依次为 `2``15`
### `group by ... having`
<!-- section-id: syntax-13-024 -->
@@ -800,7 +841,7 @@ A
说明:
- `having 聚集条件` 在分组后筛选(上例只保留成员数大于 1 的 `A` 组)
- `having` 里的计数用 `countof([字段])``countof(1)`
- `having` 里的计数优先`countof([字段])``countof(1)``countof()`;确需星号时写成 `countof( * )`
代码块身份:反例 / 不可照写
@@ -808,7 +849,7 @@ A
having_rows := select ["cls"] from a group by ["cls"] having countof(*) > 1 end;
```
`countof(*)` 这种带 `*` 的写法不成立,会`CountOf ( not found`计数改用 `countof([字段])``countof(1)`
无空格的 `countof(*)` 会把 `(*` 词法组合解释成块注释开头,随后`CountOf ( not found`。改用 `countof( * )``countof()``countof([字段])``countof(1)`
### `thisOrder` 与多列 `order by`
@@ -967,7 +1008,7 @@ query_result := select * from source_rows end;
- 在 `left join` 时省略 `on` 子句或不用 `[表序号].["字段"]` 形式。
- 在 `insert` 时漏掉 `insertfields` 或字段数与值数不匹配。
- 期望 `update`/`delete` 返回新数组;它们直接修改原数组。
- `countof(*)` 数行数;`*` 星号形式不被支持,改用 `countof([字段])``countof(1)`
- 写无空格的 `countof(*)`,使 `(*` 与块注释起始符冲突;改用 `countof( * )``countof()``countof([字段])``countof(1)`
代码块身份:反例 / 不可照写
@@ -975,7 +1016,7 @@ query_result := select * from source_rows end;
n := vselect countof(*) from source_rows end;
```
`countof(*)` 会报 `CountOf ( not found`。数行数改用 `countof([字段])` `countof(1)`
`countof(*)` 会报 `CountOf ( not found`,原因是 `(*` 与块注释起始符冲突。星号写法加空格为 `countof( * )`,或改用 `countof()` / 明确表达式
代码块身份:反例 / 不可照写
@@ -6,7 +6,7 @@
<!-- section-id: syntax-14-001 -->
回答“`goto``debugReturn``debugRunEnv``mtic` / `mtoc``setProfiler``__line__``__stack_frame` 怎样写、会怎样表现”。
回答“`goto``debugReturn``debugRunEnv``mtic` / `mtoc``setProfiler`内存伪变量、`__line__``__stack_frame` 怎样写、会怎样表现”。
## 核心规则
@@ -24,6 +24,7 @@
- `setProfiler(7)` 配合 `getProfilerInfo(1)`,可以在不弹窗的情况下拿到性能分析器信息。
- `__line__` 会返回所在代码行号。
- `__stack_frame` 会返回调用栈帧数组;最小 `toStn(...)` 观察结果里,每一项是 `(line, "function")` 这一类二元组。
- `_myMem_` 表示应用当前已使用内存,`_maxMem_` 表示应用允许使用的最大内存;两者是只读数值伪变量。单位和 `_maxMem_` 的具体取值由宿主环境决定。
## 可直接照写示例
@@ -217,6 +218,32 @@ writeLn(length(info) > 0);
- 说明 `setProfiler(7)` 可以开启性能分析器统计
- 说明 `getProfilerInfo(1)` 会直接返回性能分析器信息,而且结果是非空数组
### 内存伪变量 `_myMem_` / `_maxMem_`
<!-- section-id: syntax-14-011 -->
<!-- tags: 已用内存, 内存上限, 最大内存, 内存伪变量, _myMem_, _maxMem_ -->
代码块身份:可直接照写示例
```tsl
writeLn(ifNumber(_myMem_));
writeLn(ifNumber(_maxMem_));
```
代码块身份:输出片段
```text
1
1
```
说明:
- `_myMem_` 是应用已使用内存的数值
- `_maxMem_` 是宿主允许应用使用的最大内存数值;某些环境可能返回 `0` 表示未给出可比较的上限
- 不要假设 `_maxMem_ >= _myMem_`,也不要在没有宿主文档时写死单位
### `__line__``__stack_frame`
<!-- section-id: syntax-14-009 -->
@@ -276,3 +303,4 @@ array(
- 不要假设 `goto` 可以跨函数、跨脚本体或跳到单独成行的 `label`
- 不要给计时或性能分析器调用补未写入文档参数。
- 不要把调试客户端副作用写成普通输出事实。
- 不要假定 `_maxMem_` 总是非零、总是大于 `_myMem_`,或擅自指定内存单位。
@@ -13,7 +13,7 @@
<!-- section-id: syntax-15-002 -->
- 标识符大小写无关;下划线可出现在标识符中。
- `//` 是行注释;首行 `#!` 可作为 CGI 风格注释;`{ ... }``(* ... *)` 是块注释。
- `//` 是行注释;首行 `#!` 可作为 CGI 风格注释;`{ ... }``(* ... *)` 是块注释。两种块注释可以交错嵌套,同类块注释不能嵌套。
- 条件编译指令使用 `{$define}``{$undef}``{$ifdef}``{$ifndef}``{$else}``{$endif}`
- 条件编译只编译命中的分支;未命中的分支不参与脚本编译。
- `{$explicit+}` 开启后,后续变量必须先用 `var` 声明;`{$explicit-}` 可以在同一源文件里重新关闭这个要求。
@@ -58,7 +58,7 @@ TSL 关键字大小写无关;本表统一按文档推荐写法展示。生成
<!-- section-id: syntax-15-005 -->
<!-- tags: 注释怎么写, 大小写敏感吗, 变量命名, 下划线 -->
<!-- tags: 注释怎么写, 大小写敏感吗, 变量命名, 下划线, 嵌套注释, 块注释 -->
大小写无关与下划线标识符:
@@ -112,6 +112,8 @@ writeLn(40);
- 依次输出 `1``10``30`
- 说明首行 `#!``//``{ ... }``(* ... *)` 都属于文档明确注释形态
- 说明外层 `{ ... }` 可以包含 `(* ... *)`;反向交错也可用
- 同类 `{ { ... } }``(* (* ... *) *)` 不构成嵌套注释,会产生语法错误
- 说明 `define` / `undef` / `ifdef` / `ifndef` / `else` / `endif` 这一组条件编译指令可以正常生效
### 显式变量声明开关
@@ -184,27 +186,13 @@ writeLn(1);
<!-- tags: 编译开关, 编译器选项, 改默认行为 -->
`{$CompileOption}` 用于设置编译期开关,改变编译器的默认行为
TSL 的编译选项使用 `{$Option+}` / `{$Option-}` 一类指令改变后续源码的编译方式。本页只拥有已经分别验证并有专题规则的选项
代码块身份:可直接照写示例
- `{$explicit+}` / `{$explicit-}`:切换变量是否必须预先声明,见上一节
- `{$varByRef+}` / `{$varByRef-}`:切换未修饰形参的默认传递方式,见下一节
- `{$ifdef ...}` 等条件编译指令:控制分支是否参与编译
```tsl
{$CompileOption optimize=1}
echo 1 + 1;
```
代码块身份:输出片段
```text
2
```
说明:
- `{$CompileOption optimize=1}` 开启优化
- 编译选项从出现位置开始生效,直到源文件结束或被其他选项覆盖
- 常见选项包括 `optimize``buffermode``DebugInfo`
- 编译选项细节以项目工具链和实际编译命令为准。
`optimize``buffermode``DebugInfo` 等没有在本 skill 中形成可验证语义,不作为正式可生成选项。不要用“脚本仍能输出结果”来证明某个未知编译选项确实生效。
### 参数默认传递开关
@@ -263,6 +251,8 @@ end;
<!-- section-id: syntax-15-010 -->
- 不要在 `{$explicit+}` 后继续直接使用未声明变量。
- 不要同类嵌套 `{ ... }``(* ... *)` 块注释;需要嵌套时交错使用两种定界符。
- 不要生成未在本页形成可验证规则的 `{$CompileOption optimize=...}``buffermode``DebugInfo`
- 不要把 `{$i ...}` / `{$include ...}` 包含文件写法当成可用能力。
- 不要把 `反例 / 不可照写` 代码块复制进正向示例。
@@ -22,7 +22,7 @@
- 完整 `unit` 形态可以包含 `interface``implementation``initialization``finalization`,并以 `end.` 结束。
- `initialization``unit` 第一次被实际使用时触发,不是只因为顶层写了 `uses` 就立刻执行。
- `finalization` 会在脚本结束前触发。
- 直接写 `DemoUnit.Member` 时,可以读到 `interface` `implementation` 里的常量、变量
- 跨版本安全边界只保证 `interface` 中声明的常量、变量和函数可由引用者访问;只在 `implementation` 中声明的成员按私有内容处理
- `findFunction("DemoUnit")` 拿到的是 `unit` 对象入口;本页只把它稳定暴露 `interface` 成员写成文档事实。
- `DemoUnit.var_name := value` 这种限定赋值不作为可写事实;如果要改 `unit` 状态,应导出函数或方法来改。
- `tslfilename()` 的参数规格使用 `tsl-api-reference` skill 按名查询;本页只保留它返回正在执行的 `.tsl` 主脚本完整路径这一行为事实。
@@ -111,7 +111,7 @@ FINAL
<!-- tags: 读模块常量, 访问 unit 成员, 限定名读取 -->
直接限定读取:
跨版本安全的限定读取:
代码块身份:配置片段 / 概念骨架
@@ -149,8 +149,6 @@ uses DemoUnit;
writeLn(DemoUnit.public_const);
writeLn(DemoUnit.public_var);
writeLn(DemoUnit.impl_const);
writeLn(DemoUnit.impl_var);
writeLn(PublicFunc());
```
@@ -158,10 +156,10 @@ writeLn(PublicFunc());
- `DemoUnit.public_const` 输出 `1`
- `DemoUnit.public_var` 输出 `3`
- `DemoUnit.impl_const` 输出 `2`
- `DemoUnit.impl_var` 输出 `4`
- `PublicFunc()` 输出 `10`
- 本页文档边界是:实现段函数仍私有,但实现段常量和变量可以通过 `DemoUnit.Member` 直接读取
- 本页的跨版本文档边界是:外部只依赖 `interface` 引出的成员;实现段里的常量、变量和函数都视为私有
部分新一代解释器允许用 `DemoUnit.impl_const` / `DemoUnit.impl_var` 限定读取实现段数据,但这与经典 `unit` 可见性规则冲突,不作为跨环境默认生成能力。若项目已经依赖该行为,必须先按目标解释器实测并记录版本。
实现段函数的外部调用反例:
@@ -491,7 +489,7 @@ writeLn(Hello());
<!-- section-id: syntax-18-011 -->
- 把 `DemoUnit.var_name := value` 当成可用的限定赋值。
- 以为 `implementation` 里的常量和变量一定都不能从 `DemoUnit.Member` 读到
- 默认从外部读取只在 `implementation` 中声明的成员;跨版本安全代码应通过 `interface` 导出
- 以为 `findFunction("DemoUnit")` 暴露的成员范围和 `DemoUnit.Member` 完全相同。
- 把脚本内的 `namespace "..."` 当成和 `tsl.conf` 里的 `Namespace=...` 叠加,而不是覆盖。
- 把 `-LIBPATH` 放在脚本文件名前面。
@@ -36,7 +36,7 @@
<!-- section-id: syntax-22-004 -->
<!-- tags: 建 FMArray, 高性能数组, 字面量写法 -->
<!-- tags: 建 FMArray, 高性能数组, 高性能矩阵, fmarray, 字面量写法 -->
代码块身份:可直接照写示例
+13 -12
View File
@@ -41,6 +41,7 @@ STRUCTURAL_METADATA_RE = re.compile(
r"<!--\s*(?:section-id|quickstart-rule)\s*:.*?-->",
re.DOTALL | re.IGNORECASE,
)
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
IDENTITY_PREFIX = "代码块身份:"
BLOCK_DESCRIPTION_PREFIX = "代码块说明:"
ALLOWED_IDENTITIES = {
@@ -156,7 +157,6 @@ CHINESE_QUERY_PARTICLES = ("的", "是", "吗", "呢", "吧")
ASCII_FILTER_STOP_TOKENS = {
"debug",
"please",
"program",
"tinysoft",
"tsl",
"tsf",
@@ -480,7 +480,7 @@ def load_sections(references_dir: Path = DEFAULT_REFERENCES_DIR) -> list[Section
raise ReferenceStructureError(f"重复 section ID{base_id}")
seen_ids.add(base_id)
tags = _section_tags(local_body)
searchable_body = STRUCTURAL_METADATA_RE.sub(" ", local_body)
searchable_body = HTML_COMMENT_RE.sub(" ", local_body)
searchable_text = normalize(
"\n".join((page.stem, page_title, *heading_path, *tags, searchable_body))
)
@@ -1122,9 +1122,9 @@ def _text_contains_exact_query(text: str, query: str) -> bool:
def _intent_score(section: Section, query: str) -> int:
aliases = PAGE_INTENT_ALIASES.get(section.page.name, ())
return PAGE_INTENT_SCORE * sum(
return PAGE_INTENT_SCORE if any(
_query_contains_phrase(query, alias) for alias in aliases
)
) else 0
def _has_chinese_context(section: Section, query: str) -> bool:
@@ -1161,7 +1161,9 @@ def _tag_matched_tokens(tags: tuple[str, ...], query_token_set: set[str]) -> int
def _code_text(body: str) -> str:
inline = INLINE_CODE_RE.findall(body)
fenced = FENCED_CODE_RE.findall(body)
return normalize("\n".join((*inline, *fenced)))
# 标识符信号只来自 ASCII 代码术语。中文散文会走标题、tag 和正文得分;
# 若把围栏里的“下标数组”等输出标签也当标识符,中文查询会被样例值劫持。
return normalize("\n".join(ASCII_TOKEN_RE.findall("\n".join((*inline, *fenced)))))
def _score_section(section: Section, query: str, mode: str) -> ScoreBreakdown:
@@ -1169,8 +1171,7 @@ def _score_section(section: Section, query: str, mode: str) -> ScoreBreakdown:
tokens = query_tokens(query)
heading_text = normalize("\n".join(section.heading_path))
page_title_text = normalize(section.page_title)
body_without_metadata = STRUCTURAL_METADATA_RE.sub(" ", section.local_body)
body_text = normalize(SECTION_TAG_RE.sub(" ", body_without_metadata))
body_text = normalize(HTML_COMMENT_RE.sub(" ", section.local_body))
tag_text = normalize("\n".join(section.tags))
term_text = _code_text(section.local_body)
expanded_only_tokens = _synonym_tokens(query) - _base_query_tokens(query)
@@ -1356,6 +1357,7 @@ def query_sections(
)
ranked.sort(
key=lambda match: (
match.weak,
-match.score,
*(-value for value in match.priority),
match.section.page.as_posix(),
@@ -1400,10 +1402,9 @@ def _safe_json_string(value: str) -> str:
def _plain_text_summary(body: str, limit: int = 180) -> str:
# 标签是检索元数据,不是事实正文;不能泄进候选摘要。
without_metadata = STRUCTURAL_METADATA_RE.sub(" ", body)
without_tags = SECTION_TAG_RE.sub(" ", without_metadata)
without_fences = FENCED_CODE_RE.sub(" ", without_tags)
# HTML 注释都是维护元数据,不是事实正文;不能泄进候选摘要。
without_comments = HTML_COMMENT_RE.sub(" ", body)
without_fences = FENCED_CODE_RE.sub(" ", without_comments)
without_links = re.sub(
r"!?\[([^\]]*)\]\([^)]+\)", lambda match: match.group(1), without_fences
)
@@ -1464,7 +1465,7 @@ def render_candidates(result: QueryResult) -> str:
def render_section(section: Section) -> str:
body = STRUCTURAL_METADATA_RE.sub("", section.body)
body = HTML_COMMENT_RE.sub("", section.body)
body = re.sub(r"\n{3,}", "\n\n", body).rstrip()
lines = [
"# TSL Syntax Section",
+13 -1
View File
@@ -277,7 +277,19 @@ python tools/tsl-codegen/scripts/generate.py --dir tmp/api-recordings
生成器读取录入文件中的 `path`,默认写入
`skills/tsl-api-reference/references/codegen/project/<path>.md`。不指定
`--scope` 时,scope 就是 `project`
`--scope` 时,scope 就是 `project`。该路径默认相对于当前工作目录
从其他目录运行生成器时,使用 `--root` 指定包含 `skills/` 的项目根目录。例如在
`tools/tsl-codegen` 目录中运行:
```bash
python scripts/generate.py --file ../../tmp/my-api.json --root ../..
```
此时仍会写入仓库根目录下的
`skills/tsl-api-reference/references/codegen/project/<path>.md`,不会在
`tools/tsl-codegen` 下创建新的 `skills/` 目录。`--root` 接受绝对路径;相对路径按
运行命令时的当前工作目录解析
写入前会自动使用仓库的 `.prettierrc.json` 格式化 markdown,使新页面与现有
builtin 页面保持一致。未安装 Prettier 或格式化失败时,生成器会停止且不写目标文件
@@ -1,905 +0,0 @@
#!/usr/bin/env python3
"""Conservatively enrich TSL API descriptions and search tags.
Markdown is the fact source. This tool never changes API headings, signatures,
parameter tables, return types, examples, or scope placement. It only updates
the first prose description line and the optional ``<!-- tags: ... -->`` line.
"""
from __future__ import annotations
import argparse
import csv
import html
import re
import sys
import unicodedata
from collections import defaultdict
from dataclasses import dataclass, field, replace
from pathlib import Path
from bs4 import BeautifulSoup
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
TAGS_RE = re.compile(r"^<!--\s*tags:\s*(.*?)\s*-->$")
ACCESS_RE = re.compile(r"^访问[:]\s*(.*?)\s*$", re.IGNORECASE)
H1_RE = re.compile(r"^#(?!#)\s+(.+?)\s*$")
TERMINAL_PUNCTUATION_RE = re.compile(r"[。..!??;;,:]+$")
MULTISPACE_RE = re.compile(r"\s+")
FORMULA_RE = re.compile(
r"^([^。;;,]{2,40}?)\s*(?<![<>=!])=(?!=)\s*(.+)$"
)
COMPARISON_RE = re.compile(r"<=|>=|==|!=|<|>")
RELATED_FUNCTION_RE = re.compile(
r"^([A-Za-z][A-Za-z0-9_]*)\s*相关函数$"
)
ACRONYM_RE = re.compile(r"(?<![A-Za-z0-9_])([A-Z][A-Z0-9_]{1,15})(?![A-Za-z0-9_])")
ACTION_CLAUSE_RE = re.compile(
r"^(?:用于|如果|由于|若(?!干)|支持|查询|输入|按照|按(?!钮)|与系统参数|"
r"对.+?(?:进行|使用)|从.+?(?:获取|读取|计算|返回)|"
r"由.+?(?:倒推|获取|计算|得到|生成))"
)
PROHIBITED_PROCESS_RE = re.compile(
r"Source\s*ID|来源[:]|已验证|验证通过|not[_ ]?found|"
r"测试过程|探测结果|官网定义",
re.IGNORECASE,
)
GENERIC_SUMMARIES = {
"函数",
"方法",
"属性",
"常量",
"接口",
"对象",
"",
"内置函数",
"内部函数",
"统计分布相关函数",
}
GENERIC_TAGS = {
"函数",
"方法",
"属性",
"常量",
"数据",
"tsl",
"builtin",
"dotnet",
"third",
"deprecated",
"function",
"method",
"property",
}
CLASS_DESCRIPTIONS = {
"imap": "用于连接 IMAP 服务器、管理邮箱并读取邮件的内置对象",
"mailmsg": "用于创建、解析、保存和发送邮件消息的内置对象",
"messagepart": "表示邮件正文、附件等消息分段的内置对象",
"pop3": "用于连接 POP3 服务器并读取或删除邮件的内置对象",
"smtp": "用于连接 SMTP 服务器并发送邮件的内置对象",
"tcipher": "提供对称加密和解密能力的内置对象",
"tcookie": "表示 HTTP Cookie 名称、值和属性的内置对象",
"tcookiecollection": "用于创建、查找和管理 HTTP Cookie 集合的内置对象",
"tfilestream": "以文件为存储后端并提供流式读写能力的内置对象",
"thandlestream": "封装系统句柄并提供流式读写能力的内置对象",
"thashedstringlist": "使用哈希索引加速查找的字符串列表内置对象",
"tinifile": "用于读取和写入 INI 配置文件的内置对象",
"tmeminifile": "在内存中读取、修改和保存 INI 配置的内置对象",
"tmemorystream": "以内存缓冲区保存和操作流数据的内置对象",
"tregistryinifile": "以系统注册表为存储后端的 INI 配置内置对象",
"trsa": "提供 RSA 非对称加密、解密和签名能力的内置对象",
"tsession": "表示服务端会话及其键值数据的内置对象",
"tsessionman": "用于创建、查找、维护和删除服务端会话的内置对象",
"tstream": "定义顺序读写、定位和复制操作的流基类",
"tstringlist": "提供字符串集合存储、查找、排序和名称值管理能力的内置对象",
"twebrequest": "封装 HTTP 请求头、请求参数、Cookie 和请求正文的内置对象",
"twebresponse": "用于设置 HTTP 状态、响应头、Cookie 和响应正文的内置对象",
}
GENERIC_DESCRIPTION_OVERRIDES = {
"unicodeesc2": "将字符串转换为 \\uxxxx 形式的 Unicode 编码串",
"sf_x2inv": "根据累计概率计算卡方分布的逆函数值",
"sf_tinv": "根据累计概率计算 t 分布的逆函数值",
"sf_sinx": "计算正弦积分",
"sf_normalinv2": "根据累计概率计算标准正态分布的逆函数值",
"sf_normalinv": "根据累计概率计算正态分布的逆函数值",
"sf_normal": "计算正态分布函数值",
"sf_gammainv": "根据累计概率计算标准 Gamma 分布的逆函数值",
"sf_gamma": "计算伽马函数值",
"sf_finv": "根据累计概率计算 F 分布的逆函数值",
"sf_ex": "计算指数积分",
"sf_error": "计算误差函数(概率函数)值",
"sf_cosx": "计算余弦积分",
"sf_betainv": "根据累计概率计算贝塔分布的逆函数值",
"sf_beta": "计算贝塔函数值",
"readfile": "读取本地文件中的数据",
"millisecondspan": "计算两个日期时间之间相差的毫秒数",
"importfile": "从文件中导入数据",
"getmsgdigest": "计算字符串或文件的摘要并返回摘要字符串",
"exportfile": "将数据导出到文件",
}
AWKWARD_DESCRIPTION_OVERRIDES = {
"encodegraph": "根据指定的类型、名称、数据和属性数组生成图形并返回",
"slndepreciation": (
"按直线折旧法计算每期折旧额,计算公式为"
"(资产原值 Cost - 资产残值 Salvage/ 折旧周期数 Life"
),
"format": "根据 formatstr 指定的格式将参数连接成字符串并返回",
"ansiformat": "根据 formatstr 指定的格式将参数连接成字符串并返回",
"dupestring": "将字符串 AText 重复 ACount 次并返回结果",
"stockpjcj": "返回区间平均成交价,计算公式为区间成交金额 / 区间成交量",
}
LEADING_VERBS = (
"返回",
"获取",
"读取",
"写入",
"设置",
"创建",
"删除",
"移除",
"清除",
"添加",
"插入",
"查找",
"搜索",
"判断",
"检查",
"验证",
"计算",
"转换",
"解析",
"格式化",
"比较",
"执行",
"调用",
"连接",
"关闭",
"打开",
"发送",
"接收",
"保存",
"加载",
"列出",
"表示",
"提供",
"释放",
"交换",
"排序",
"过滤",
"统计",
"控制",
"指示",
"提取",
"根据",
"",
"",
"生成",
"构造",
"采用",
"利用",
"输出",
"得到",
"获得",
"取得",
"新建",
)
@dataclass(frozen=True)
class SourceRecord:
corpus: str
page_id: str
title: str
page: Path
description: str = ""
@dataclass(frozen=True)
class EntryContext:
name: str
signature: str
kind: str
scope: str
page: str
summary: str
access: str = ""
owner: str = ""
tags: tuple[str, ...] = ()
@dataclass(frozen=True)
class PageContext:
title: str
path: str
@dataclass
class AuditChange:
page: str
name: str
kind: str
old_description: str
new_description: str
old_tags: tuple[str, ...]
new_tags: tuple[str, ...]
sources: tuple[str, ...]
@dataclass
class Audit:
entries: int = 0
source_matches: int = 0
ambiguous_sources: int = 0
changed_descriptions: int = 0
changed_tags: int = 0
changes: list[AuditChange] = field(default_factory=list)
def merge(self, other: "Audit") -> None:
self.entries += other.entries
self.source_matches += other.source_matches
self.ambiguous_sources += other.ambiguous_sources
self.changed_descriptions += other.changed_descriptions
self.changed_tags += other.changed_tags
self.changes.extend(other.changes)
def normalize_key(value: str) -> str:
return unicodedata.normalize("NFKC", value).casefold().strip()
def clean_text(value: str) -> str:
value = html.unescape(value).replace("\xa0", " ")
value = value.replace("", '"').replace("", '"')
value = value.replace("", "'").replace("", "'")
return MULTISPACE_RE.sub(" ", value).strip()
def strip_terminal_punctuation(value: str) -> str:
return TERMINAL_PUNCTUATION_RE.sub("", clean_text(value)).rstrip()
def safe_source_description(value: str) -> str:
value = strip_terminal_punctuation(value)
if not value or PROHIBITED_PROCESS_RE.search(value):
return ""
return value
def _extract_source_description(page: Path) -> str:
try:
soup = BeautifulSoup(page.read_text(encoding="utf-8"), "html.parser")
except (OSError, UnicodeError):
return ""
root = soup.select_one("#help_content") or soup
for expected in ("简述", "说明"):
for label in root.select(".DescriteTitle"):
if clean_text(label.get_text(" ", strip=True)) != expected:
continue
marker = label.parent if label.parent is not None else label
sibling = marker.find_next_sibling()
if sibling is None:
sibling = label.find_next_sibling()
if sibling is not None:
return safe_source_description(sibling.get_text(" ", strip=True))
return ""
class SourceCatalog:
PREFERENCES = {
"builtin": ("tsl_base", "net_function", "knowledge_base"),
"third": ("net_function", "tsl_base", "knowledge_base"),
"deprecated": ("net_function", "tsl_base", "knowledge_base"),
"dotnet": ("net_function", "tsl_base", "knowledge_base"),
}
def __init__(self, records: dict[str, list[SourceRecord]] | None = None):
self.records = records or {}
self._description_cache: dict[Path, str] = {}
@classmethod
def empty(cls) -> "SourceCatalog":
return cls({})
@classmethod
def from_docs_root(cls, docs_root: Path) -> "SourceCatalog":
records: dict[str, list[SourceRecord]] = defaultdict(list)
for corpus in ("net_function", "tsl_base", "knowledge_base"):
root = docs_root / corpus
manifest = root / "manifest.tsv"
if not manifest.is_file():
continue
with manifest.open(encoding="utf-8", newline="") as handle:
for row in csv.DictReader(handle, delimiter="\t"):
if row.get("status") != "ok" or not row.get("title"):
continue
record = SourceRecord(
corpus=corpus,
page_id=row.get("id", ""),
title=row["title"],
page=root / row.get("path", ""),
)
records[normalize_key(record.title)].append(record)
return cls(dict(records))
def _load_description(self, record: SourceRecord) -> SourceRecord:
if record.description:
return record
if record.page not in self._description_cache:
self._description_cache[record.page] = _extract_source_description(
record.page
)
return replace(
record, description=self._description_cache[record.page]
)
def match(self, entry: EntryContext) -> list[SourceRecord]:
matches = self.records.get(normalize_key(entry.name), [])
preference = self.PREFERENCES.get(
entry.scope,
("net_function", "tsl_base", "knowledge_base"),
)
rank = {name: index for index, name in enumerate(preference)}
def page_order(record: SourceRecord):
try:
page_id = (0, int(record.page_id))
except ValueError:
page_id = (1, record.page_id)
return rank.get(record.corpus, 99), page_id
return sorted(matches, key=page_order)
def describe(self, records: list[SourceRecord]) -> list[SourceRecord]:
return [self._load_description(record) for record in records]
def _starts_with_verb(value: str) -> bool:
return value.startswith(LEADING_VERBS) or bool(
ACTION_CLAUSE_RE.match(value)
)
def _choose_source_description(
current: str, sources: list[SourceRecord]
) -> str:
candidates = [
safe_source_description(record.description) for record in sources
]
candidates = [value for value in candidates if value]
if not candidates:
return current
if len(sources) == 1 and current in GENERIC_SUMMARIES:
candidate = candidates[0]
if candidate not in GENERIC_SUMMARIES and len(candidate) > len(current):
return candidate
if PROHIBITED_PROCESS_RE.search(current):
return candidates[0]
return current
def _property_description(value: str, access: str) -> str:
if _starts_with_verb(value):
return value
if (
len(value) > 30
or any(mark in value for mark in "。;,")
or value.startswith(("功能同", "只读", "用于"))
):
return value
normalized = normalize_key(access).replace(" ", "")
if value.startswith("是否"):
return (
f"控制{value}"
if "write" in normalized
else f"指示{value}"
)
if "read" in normalized and "write" in normalized:
return f"获取或设置{value}"
if "write" in normalized:
return f"设置{value}"
return f"获取{value}"
def _name_intent(name: str) -> str:
key = normalize_key(name)
rules = (
(("is", "if", "has", "can", "check", "valid"), "判断"),
(("get", "find", "query", "search"), "获取"),
(("read", "load"), "读取"),
(("write", "save", "export"), "写入"),
(("set",), "设置"),
(("create", "make", "new"), "创建"),
(("delete", "remove", "drop"), "删除"),
(("clear", "reset"), "清除"),
(("add", "append", "insert"), "添加"),
(("parse", "decode"), "解析"),
(("encode", "convert", "to"), "转换"),
(("format",), "格式化"),
(("calc", "compute"), "计算"),
(("open",), "打开"),
(("close",), "关闭"),
(("send", "post"), "发送"),
(("connect", "login"), "连接"),
(("list",), "列出"),
)
for prefixes, intent in rules:
if key.startswith(prefixes):
return intent
return ""
def improve_description(
entry: EntryContext, sources: list[SourceRecord]
) -> str:
current = strip_terminal_punctuation(entry.summary)
current = _choose_source_description(current, sources)
current = strip_terminal_punctuation(current)
if not current:
return entry.summary
if entry.kind == "class":
replacement = CLASS_DESCRIPTIONS.get(normalize_key(entry.name))
if replacement and re.fullmatch(
rf"{re.escape(entry.name)}\s*内置对象", current, re.IGNORECASE
):
return replacement
if entry.kind == "property":
return _property_description(current, entry.access)
key = normalize_key(entry.name)
generic_override = GENERIC_DESCRIPTION_OVERRIDES.get(key)
if generic_override and current.endswith("相关函数"):
return generic_override
awkward_override = AWKWARD_DESCRIPTION_OVERRIDES.get(key)
if awkward_override and current.startswith(
("返回根据", "返回将", "返回:", "返回:")
):
return awkward_override
current = re.sub(
r"^(?:该|本)函数(?:主要(?:是)?)?\s*", "", current
)
current = re.sub(r"^功能[:]\s*", "", current)
if current.startswith("是否"):
current = f"判断{current}"
for old, new in (
("读出", "读取"),
("取出", "获取"),
("得到", "获取"),
("获得", "获取"),
("取得", "获取"),
("新建", "创建"),
):
if current.startswith(old):
current = new + current[len(old):]
break
if current.startswith("") and not current.startswith("读取"):
current = "读取" + current[1:]
if current.startswith("") and not current.startswith("写入"):
current = "写入" + current[1:]
current = current.replace("读出", "读取")
current = current.replace("装载内容", "加载内容")
related_function = RELATED_FUNCTION_RE.fullmatch(current)
if related_function:
return f"返回 {related_function.group(1)} 对应的数据"
formula = FORMULA_RE.match(current)
page_key = normalize_key(entry.page)
if (
formula
and ("/financial/" in page_key or "/financial_report/" in page_key)
and not _starts_with_verb(current)
):
left, right = formula.groups()
return f"计算{left.strip()},公式为{right.strip()}"
if _starts_with_verb(current):
return current
if "/financial/" in page_key or "/financial_report/" in page_key:
if (
re.match(r"^\d+(?:\s|[..、)])", current)
or COMPARISON_RE.search(current)
):
return current
return f"返回{current}"
intent = _name_intent(entry.name)
if intent and len(current) <= 28 and not any(
verb in current[:16] for verb in LEADING_VERBS
):
return f"{intent}{current}"
return current
DOMAIN_RULES = (
(r"数组|列表|(?:^|[^a-z])(?:array|fmarray|list)(?:$|[^a-z])", ("数组", "列表", "array", "list")),
(r"矩阵|(?:^|[^a-z])matrix(?:$|[^a-z])|\bmt_", ("矩阵", "matrix")),
(r"字符串|文本|(?:^|[^a-z])(?:string|char|text)(?:$|[^a-z])", ("字符串", "文本", "string", "text")),
(r"日期|时间|(?:^|[^a-z])(?:datetime|date|time)(?:$|[^a-z])", ("日期时间", "日期", "时间", "datetime")),
(r"文件|目录|路径|(?:^|[^a-z])(?:file|folder|directory|path)(?:$|[^a-z])", ("文件", "目录", "路径", "file")),
(r"网络|(?:^|[^a-z])(?:http|https|cgi|cookie|url)(?:$|[^a-z])", ("网络", "HTTP", "请求", "network")),
(r"缓存|(?:^|[^a-z])cache(?:$|[^a-z])", ("缓存", "缓存管理", "cache")),
(r"数据库|(?:^|[^a-z])(?:sql|dbf|ini)(?:$|[^a-z])", ("数据库", "SQL", "database")),
(r"进程|线程|(?:^|[^a-z])(?:process|thread|pipe)(?:$|[^a-z])", ("进程", "线程", "process")),
(r"颜色|(?:^|[^a-z])(?:color|rgb|cmyk)(?:$|[^a-z])", ("颜色", "RGB", "color")),
(r"图形|图表|(?:^|[^a-z])(?:graph|chart)(?:$|[^a-z])", ("图形", "图表", "graph")),
(r"统计|概率|分布|(?:^|[^a-z])(?:statistics|cdf|pdf)(?:$|[^a-z])", ("统计", "概率", "statistics")),
(r"优化|线性规划|(?:^|[^a-z])optimization(?:$|[^a-z])", ("优化", "求解", "optimization")),
(r"数值|数学|(?:^|[^a-z])(?:numeric|math)(?:$|[^a-z])", ("数学", "数值", "numeric")),
(r"类型转换|(?:^|[^a-z])(?:convert|conversion)(?:$|[^a-z])", ("类型转换", "转换", "conversion")),
(r"对象|(?:^|[^a-z])(?:object|class)(?:$|[^a-z])", ("对象", "实例", "object")),
(r"(?:^|[^a-z])(?:com|ole)(?:$|[^a-z])|activex", ("COM", "OLE", "自动化")),
(r"(?:^|[^a-z])ftp(?:$|[^a-z])", ("FTP", "文件传输")),
(r"邮件|(?:^|[^a-z])(?:smtp|pop3|imap|mail)(?:$|[^a-z])", ("邮件", "SMTP", "POP3", "IMAP")),
(r"财务|金融|证券|股票|基金|债券|期货|期权", ("金融",)),
(r"报表|报告期|report", ("报表", "报告期")),
)
SPECIAL_TAG_RULES = (
(r"去重|删除重复|dropduplicate|dedup", ("去重", "删除重复", "deduplicate")),
(r"哈希索引|哈希表", ("哈希索引", "哈希表", "hash")),
(
r"摘要|(?:^|[^a-z0-9_])(?:crc32|md5|sha1|sha224|sha256|sha384|sha512|sm3|digest|hash)(?:$|[^a-z0-9_])",
("摘要", "哈希", "散列", "digest", "hash"),
),
(r"排序|sort", ("排序", "sort")),
(r"过滤|筛选|filter", ("过滤", "筛选", "filter")),
(r"查找|搜索|find|search|lookup", ("查找", "搜索", "lookup")),
)
INTENT_TAGS = {
"返回": ("返回", "获取"),
"获取": ("获取", "查询"),
"读取": ("读取", "获取"),
"写入": ("写入", "保存"),
"设置": ("设置", "修改"),
"创建": ("创建", "生成"),
"删除": ("删除", "移除"),
"清除": ("清除", "重置"),
"添加": ("添加", "追加"),
"查找": ("查找", "搜索"),
"判断": ("判断", "检查"),
"计算": ("计算", "求解"),
"转换": ("转换", "编码"),
"解析": ("解析", "parse"),
"格式化": ("格式化", "format"),
"打开": ("打开", "open"),
"关闭": ("关闭", "close"),
"发送": ("发送", "提交"),
"连接": ("连接", "登录"),
"列出": ("列出", "列表"),
"输出": ("输出", "打印"),
}
def _taxonomy_tags(title: str) -> list[str]:
result = []
for raw in re.split(r"\s*/\s*|\s+-\s+", title):
value = raw.strip(" #")
value = re.sub(r"\([^)]*\)|[^]*", "", value).strip()
for suffix in ("相关函数", "及其实现", "相关"):
if value.endswith(suffix):
value = value[: -len(suffix)].rstrip()
if not value or normalize_key(value) in GENERIC_TAGS:
continue
if len(value) > 18:
continue
chinese = "".join(re.findall(r"[\u3400-\u9fff]+", value))
latin = re.sub(r"[\u3400-\u9fff]+", " ", value).strip()
if latin and normalize_key(latin) not in GENERIC_TAGS:
result.extend(
re.findall(r"[A-Za-z][A-Za-z0-9_+.-]*", latin)
)
if chinese:
result.append(chinese)
return result
def _description_intent(summary: str) -> str:
value = strip_terminal_punctuation(summary)
replacements = {
"得到": "获取",
"获得": "获取",
"取得": "获取",
"新建": "创建",
}
for source, target in replacements.items():
if value.startswith(source):
return target
for intent in INTENT_TAGS:
if value.startswith(intent):
return intent
for intent in ("读取", "写入", "保存", "计算", "判断", "转换", "解析"):
if intent in value[:16]:
return "写入" if intent == "保存" else intent
return ""
def derive_tags(entry: EntryContext, page: PageContext) -> list[str]:
tags: list[str] = []
seen: set[str] = set()
def add(value: str) -> None:
value = clean_text(value).strip()
key = normalize_key(value)
if (
not value
or key in seen
or key in GENERIC_TAGS
or len(tags) >= 12
):
return
seen.add(key)
tags.append(value)
for tag in entry.tags:
add(tag)
for tag in _taxonomy_tags(page.title):
add(tag)
haystack = " ".join(
(entry.name, entry.signature, entry.summary, page.title, page.path)
)
domain_haystack = " ".join((page.title, page.path))
for pattern, aliases in SPECIAL_TAG_RULES:
if re.search(pattern, haystack, re.IGNORECASE):
for alias in aliases:
add(alias)
summary = strip_terminal_punctuation(entry.summary)
intent = _description_intent(summary)
if not intent and not _starts_with_verb(summary):
is_market_price = (
normalize_key(entry.name) in {"open", "close"}
and "盘价" in summary
)
if not is_market_price:
intent = _name_intent(entry.name)
for alias in INTENT_TAGS.get(intent, ()):
add(alias)
for pattern, aliases in DOMAIN_RULES:
if re.search(pattern, domain_haystack, re.IGNORECASE):
for alias in aliases:
add(alias)
for acronym in ACRONYM_RE.findall(entry.summary):
add(acronym)
return tags
def _scope_for_page(page: str) -> str:
parts = Path(page).parts
return parts[0] if len(parts) > 1 else Path(page).stem
def _page_title(lines: list[str], page: str) -> PageContext:
for line in lines:
match = H1_RE.match(line)
if match:
return PageContext(match.group(1), page)
return PageContext(Path(page).stem, page)
def _entry_metadata(lines: list[str], start: int, end: int):
declaration_seen = False
description_index = None
tag_index = None
access = ""
for index in range(start + 1, end):
text = lines[index].strip()
if not text:
continue
if not declaration_seen:
if DECLARATION_LINE_RE.fullmatch(text):
declaration_seen = True
continue
tag_match = TAGS_RE.fullmatch(text)
if tag_match:
tag_index = index
continue
access_match = ACCESS_RE.fullmatch(text)
if access_match:
access = access_match.group(1)
continue
if description_index is None:
if text.startswith(("|", "#", "返回:", "类型:", "可见性:", "值:")):
continue
description_index = index
tags = ()
if tag_index is not None:
tags = tuple(TAGS_RE.fullmatch(lines[tag_index].strip()).group(1).split())
return description_index, tag_index, access, tags
def enrich_markdown(
text: str, page: str, source_catalog: SourceCatalog
) -> tuple[str, Audit]:
had_final_newline = text.endswith("\n")
lines = text.splitlines()
page_context = _page_title(lines, page)
entries = [entry for entry in iter_api_entries(lines) if entry.heading.valid]
audit = Audit(entries=len(entries))
root_owner = ""
owners: dict[int, str] = {}
for entry in entries:
if entry.heading.level == 2:
root_owner = entry.heading.name if entry.heading.kind in {"class", "unit"} else ""
owners[entry.start] = root_owner if entry.heading.level > 2 else ""
for api in reversed(entries):
description_index, tag_index, access, existing_tags = _entry_metadata(
lines, api.start, api.end
)
if description_index is None:
continue
old_description = lines[description_index].strip()
entry = EntryContext(
name=api.heading.name,
signature=api.heading.signature,
kind=api.heading.kind,
scope=_scope_for_page(page),
page=page,
summary=old_description,
access=access,
owner=owners.get(api.start, ""),
tags=existing_tags,
)
sources = source_catalog.match(entry)
if sources:
audit.source_matches += 1
if len(sources) > 1:
audit.ambiguous_sources += 1
evidence = sources
normalized_summary = strip_terminal_punctuation(old_description)
if (
normalized_summary in GENERIC_SUMMARIES
or PROHIBITED_PROCESS_RE.search(normalized_summary)
):
evidence = source_catalog.describe(sources)
new_description = improve_description(entry, evidence)
entry_for_tags = replace(entry, summary=new_description)
new_tags = tuple(derive_tags(entry_for_tags, page_context))
if new_description != old_description:
lines[description_index] = new_description
audit.changed_descriptions += 1
if new_tags != existing_tags:
rendered = f"<!-- tags: {' '.join(new_tags)} -->"
if tag_index is not None:
lines[tag_index] = rendered
else:
insert_at = description_index + 1
if insert_at < len(lines) and lines[insert_at].strip() == "":
insert_at += 1
lines[insert_at:insert_at] = [rendered, ""]
else:
lines[insert_at:insert_at] = ["", rendered, ""]
audit.changed_tags += 1
if new_description != old_description or new_tags != existing_tags:
audit.changes.append(
AuditChange(
page=page,
name=api.heading.name,
kind=api.heading.kind,
old_description=old_description,
new_description=new_description,
old_tags=existing_tags,
new_tags=new_tags,
sources=tuple(
f"{record.corpus}:{record.page_id}" for record in sources
),
)
)
result = "\n".join(lines)
if had_final_newline:
result += "\n"
return result, audit
def _write_report(path: Path, audit: Audit) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle, delimiter="\t", lineterminator="\n")
writer.writerow(
(
"page",
"name",
"kind",
"old_description",
"new_description",
"old_tags",
"new_tags",
"sources",
)
)
for item in audit.changes:
writer.writerow(
(
item.page,
item.name,
item.kind,
item.old_description,
item.new_description,
" ".join(item.old_tags),
" ".join(item.new_tags),
" ".join(item.sources),
)
)
def _selected(page: str, scopes: set[str], prefixes: tuple[str, ...]) -> bool:
if scopes and _scope_for_page(page) not in scopes:
return False
return not prefixes or page.startswith(prefixes)
def main(argv=None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
mode = parser.add_mutually_exclusive_group(required=True)
mode.add_argument("--audit", action="store_true", help="report changes only")
mode.add_argument("--apply", action="store_true", help="write proposed changes")
parser.add_argument("--root", type=Path, required=True, help="codegen Markdown root")
parser.add_argument(
"--docs-root", type=Path, required=True, help="cached tmp/docs root"
)
parser.add_argument("--report", type=Path, help="optional TSV audit report")
parser.add_argument("--scope", action="append", default=[])
parser.add_argument("--prefix", action="append", default=[])
args = parser.parse_args(argv)
catalog = SourceCatalog.from_docs_root(args.docs_root)
aggregate = Audit()
changed_files = 0
for md in sorted(args.root.rglob("*.md")):
page = md.relative_to(args.root).as_posix()
if not _selected(page, set(args.scope), tuple(args.prefix)):
continue
before_stat = md.stat()
original = md.read_text(encoding="utf-8")
enriched, audit = enrich_markdown(original, page, catalog)
aggregate.merge(audit)
if enriched == original:
continue
changed_files += 1
if args.apply:
after_stat = md.stat()
if (
after_stat.st_mtime_ns != before_stat.st_mtime_ns
or after_stat.st_size != before_stat.st_size
):
raise RuntimeError(f"target changed while processing: {md}")
md.write_text(enriched, encoding="utf-8", newline="\n")
if args.report:
_write_report(args.report, aggregate)
print(
"entries={entries} source_matches={source_matches} "
"ambiguous_sources={ambiguous_sources} changed_files={changed_files} "
"changed_descriptions={changed_descriptions} changed_tags={changed_tags}".format(
entries=aggregate.entries,
source_matches=aggregate.source_matches,
ambiguous_sources=aggregate.ambiguous_sources,
changed_files=changed_files,
changed_descriptions=aggregate.changed_descriptions,
changed_tags=aggregate.changed_tags,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+23 -33
View File
@@ -21,7 +21,7 @@ Function declaration fields:
tags optional list of Chinese keywords -> `<!-- tags: ... -->`
params required when the signature takes args; omit for nullary
returns required return type
example optional fenced tsl block, pasted verbatim
examples optional list of structured examples with desc/code/output fields
Each param: name/type/desc required; optional (bool) -> `可选` prefix;
values (list of {value, desc}) -> a `name 取值` enum section.
@@ -29,6 +29,7 @@ Usage (run from repo root):
python tools/tsl-codegen/scripts/generate.py --file entry.yml
python tools/tsl-codegen/scripts/generate.py --file entry.json \
--scope my-project
python scripts/generate.py --file entry.json --root ../..
python tools/tsl-codegen/scripts/generate.py --dir recordings
"""
@@ -279,7 +280,6 @@ def validate_function(fn, where, *, returns_required, extra_fields=()):
"tags",
"params",
"returns",
"example",
"examples",
*extra_fields,
}
@@ -292,12 +292,6 @@ def validate_function(fn, where, *, returns_required, extra_fields=()):
non_empty_string(fn.get("returns"), f"{where}:缺少 returns")
elif "returns" in fn:
optional_draft_string(fn["returns"], f"{where}: returns")
require(
not ("example" in fn and "examples" in fn),
f"{where}example 和 examples 不能同时存在",
)
if "example" in fn:
non_empty_string(fn["example"], f"{where}: example")
if "examples" in fn:
validate_examples(fn["examples"], where)
return name
@@ -333,7 +327,6 @@ def validate_class_member(member, where):
"params",
"returns",
"modifiers",
"example",
"examples",
},
where,
@@ -600,14 +593,6 @@ def render_examples(fn, heading_level):
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 []
@@ -822,7 +807,7 @@ def format_markdown(text):
return result.stdout
def output_path(data, scope):
def output_path(data, scope, root=None):
"""Build the leaf-page destination from the recording file's relative path."""
relative = data.get("path")
require(relative, "录入数据缺少 path")
@@ -854,7 +839,8 @@ def output_path(data, scope):
)
relative_path = Path(*recorded_path.parts)
return (
Path("skills/tsl-api-reference/references/codegen")
(Path(root) if root is not None else Path())
/ "skills/tsl-api-reference/references/codegen"
/ scope
/ relative_path.with_suffix(".md")
)
@@ -902,14 +888,14 @@ def gather_directory_inputs(directory, fmt):
return inputs
def prepare_input(in_path, input_format, scope):
def prepare_input(in_path, input_format, scope, root=None):
data = load_entries(in_path, input_format)
try:
rendered = render_page(data)
except GenerationError as exc:
raise GenerationError(f"录入数据校验失败:{exc}") from exc
try:
out_path = output_path(data, scope)
out_path = output_path(data, scope, root)
except GenerationError as exc:
raise GenerationError(f"输出路径无效:{exc}") from exc
try:
@@ -941,11 +927,14 @@ def find_output_collisions(prepared):
def main(argv=None):
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
if hasattr(sys.stderr, "reconfigure"):
sys.stderr.reconfigure(encoding="utf-8")
parser = ChineseArgumentParser(
description="从 YAML/JSON 录入文件生成 TSL API 文档",
usage=(
"%(prog)s [--help] [--scope SCOPE] [--format {json,yaml}] "
"(--file INPUT_FILE | --dir INPUT_DIR | INPUT_FILE)"
"%(prog)s [--help] [--root ROOT_DIR] [--scope SCOPE] "
"[--format {json,yaml}] "
"(--file INPUT_FILE | --dir INPUT_DIR)"
),
add_help=False,
allow_abbrev=False,
@@ -955,12 +944,6 @@ def main(argv=None):
action="help",
help="显示本帮助并退出(不提供 -h 短选项)",
)
parser.add_argument(
"legacy_input",
nargs="?",
metavar="INPUT_FILE",
help="已废弃,请使用 --file;暂时兼容 YAML/JSON 录入文件路径",
)
parser.add_argument(
"--file",
dest="input_file",
@@ -973,6 +956,11 @@ def main(argv=None):
metavar="INPUT_DIR",
help="批量生成目录中的直属 YAML/JSON 录入文件",
)
parser.add_argument(
"--root",
metavar="ROOT_DIR",
help="包含 skills 目录的项目根目录(默认:当前工作目录)",
)
parser.add_argument(
"--scope",
type=scope_name,
@@ -986,16 +974,16 @@ def main(argv=None):
)
args = parser.parse_args(argv)
input_modes = (args.legacy_input, args.input_file, args.input_dir)
input_modes = (args.input_file, args.input_dir)
if sum(value is not None for value in input_modes) != 1:
parser.error("必须且只能指定一种输入方式:INPUT_FILE、--file 或 --dir")
parser.error("必须且只能指定一种输入方式:--file 或 --dir")
is_batch = args.input_dir is not None
try:
if is_batch:
input_paths = gather_directory_inputs(Path(args.input_dir), args.format)
input_format = None
else:
in_path = Path(args.input_file or args.legacy_input)
in_path = Path(args.input_file)
if not in_path.is_file():
die(f"输入文件不存在或不是普通文件:{in_path}")
input_paths = [in_path]
@@ -1008,7 +996,9 @@ def main(argv=None):
input_errors = []
for in_path in input_paths:
try:
prepared.append(prepare_input(in_path, input_format, args.scope))
prepared.append(
prepare_input(in_path, input_format, args.scope, args.root)
)
except GenerationError as exc:
input_errors.append((in_path, exc))
@@ -1,778 +0,0 @@
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).parents[1] / "scripts" / "enrich_metadata.py"
def load_script():
spec = importlib.util.spec_from_file_location("tsl_enrich_metadata", SCRIPT)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
class SourceCatalogTest(unittest.TestCase):
def setUp(self):
self.module = load_script()
self.temp_dir = tempfile.TemporaryDirectory()
self.docs = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def write_corpus(self, corpus, rows, descriptions):
root = self.docs / corpus
pages = root / "pages"
pages.mkdir(parents=True)
manifest = [
"id\ttitle\turl\tpath\tsource_bytes\tutf8_bytes\tsha256\tstatus"
]
for page_id, title in rows:
manifest.append(
f"{page_id}\t{title}\thttp://example/{page_id}\t"
f"pages/{page_id}.html\t1\t1\tsha\tok"
)
description = descriptions[page_id]
(pages / f"{page_id}.html").write_text(
"<div id='help_content'><h3>"
+ title
+ "</h3><div><div class='DescriteTitle'>简述</div></div>"
+ f"<div>{description}</div></div>",
encoding="utf-8",
)
(root / "manifest.tsv").write_text(
"\n".join(manifest) + "\n", encoding="utf-8"
)
def test_exact_matching_preserves_underscores_and_prefers_scope_corpus(self):
self.write_corpus(
"tsl_base",
[("1", "alpha_test")],
{"1": "基础解释器说明"},
)
self.write_corpus(
"net_function",
[("2", "alpha_test"), ("3", "alphaTest")],
{"2": "NET 说明", "3": "无下划线的另一个函数"},
)
catalog = self.module.SourceCatalog.from_docs_root(self.docs)
builtin = self.module.EntryContext(
name="alpha_test",
signature="alpha_test()",
kind="function",
scope="builtin",
page="builtin/demo.md",
summary="原说明",
)
dotnet = self.module.EntryContext(
name="alpha_test",
signature="alpha_test()",
kind="function",
scope="dotnet",
page="dotnet/demo.md",
summary="原说明",
)
self.assertEqual("tsl_base", catalog.match(builtin)[0].corpus)
self.assertEqual("net_function", catalog.match(dotnet)[0].corpus)
self.assertNotIn(
"alphaTest", {record.title for record in catalog.match(dotnet)}
)
def test_duplicate_titles_remain_visible_for_audit(self):
self.write_corpus(
"net_function",
[("1", "same_name"), ("2", "same_name")],
{"1": "说明一", "2": "说明二"},
)
catalog = self.module.SourceCatalog.from_docs_root(self.docs)
entry = self.module.EntryContext(
name="same_name",
signature="same_name()",
kind="function",
scope="dotnet",
page="dotnet/demo.md",
summary="原说明",
)
self.assertEqual(["1", "2"], [item.page_id for item in catalog.match(entry)])
class DescriptionQualityTest(unittest.TestCase):
def setUp(self):
self.module = load_script()
def entry(self, **overrides):
values = {
"name": "profitRatio",
"signature": "profitRatio(report_date)",
"kind": "function",
"scope": "dotnet",
"page": "dotnet/financial/profitability.md",
"summary": "总资产收益率(%)。",
}
values.update(overrides)
return self.module.EntryContext(**values)
def test_removes_only_terminal_sentence_punctuation(self):
entry = self.entry(summary="总资产收益率(%)。")
self.assertEqual(
"返回总资产收益率(%)",
self.module.improve_description(entry, []),
)
def test_preserves_formula_and_identifiers(self):
entry = self.entry(
summary="总资产报酬率(%)=利润总额/平均资产总额*100。"
)
self.assertEqual(
"计算总资产报酬率(%),公式为利润总额/平均资产总额*100",
self.module.improve_description(entry, []),
)
def test_rejects_source_process_prose(self):
entry = self.entry(summary="返回指定值")
source = self.module.SourceRecord(
corpus="net_function",
page_id="1",
title="profitRatio",
page=Path("1.html"),
description="Windows 已验证通过,Linux 返回 not found",
)
self.assertEqual(
"返回指定值", self.module.improve_description(entry, [source])
)
def test_current_environment_can_be_real_api_semantics(self):
self.assertEqual(
"获取当前环境时间系统参数",
self.module.safe_source_description("获取当前环境时间系统参数。"),
)
def test_property_access_is_stated_explicitly(self):
entry = self.entry(
name="Host",
signature="Host",
kind="property",
scope="builtin",
page="builtin/object/ftp.md",
summary="远程服务器地址。",
access="read/write",
owner="FTP",
)
self.assertEqual(
"获取或设置远程服务器地址",
self.module.improve_description(entry, []),
)
def test_boolean_property_description_is_idempotent(self):
entry = self.entry(
name="UseTLS",
signature="UseTLS",
kind="property",
scope="builtin",
page="builtin/object/imap.md",
summary="是否采用 SSL 连接",
access="read/write",
owner="IMAP",
)
first = self.module.improve_description(entry, [])
repeated = self.module.improve_description(
self.module.EntryContext(
**{**entry.__dict__, "summary": first}
),
[],
)
self.assertEqual("控制是否采用 SSL 连接", first)
self.assertEqual(first, repeated)
def test_low_confidence_sentence_is_kept_except_terminal_punctuation(self):
entry = self.entry(
name="opaqueApi",
page="dotnet.md",
summary="根据调用上下文处理结果,具体规则取决于输入。",
)
self.assertEqual(
"根据调用上下文处理结果,具体规则取决于输入",
self.module.improve_description(entry, []),
)
def test_short_description_is_not_replaced_by_ambiguous_source_text(self):
entry = self.entry(
name="clRed",
page="builtin/color.md",
summary="红色",
)
sources = [
self.module.SourceRecord(
corpus="net_function",
page_id="1",
title="clRed",
page=Path("1.html"),
description="定义",
)
]
self.assertEqual("红色", self.module.improve_description(entry, sources))
def test_existing_action_word_is_normalized_without_duplicate_verb(self):
getter = self.entry(
name="getCValue",
page="builtin/color.md",
summary="得到颜色的 CMYK 模式 C 值。",
)
writer = self.entry(
name="write",
page="builtin/cgi.md",
summary="输出字符串。",
)
self.assertEqual(
"获取颜色的 CMYK 模式 C 值",
self.module.improve_description(getter, []),
)
self.assertEqual("输出字符串", self.module.improve_description(writer, []))
def test_equation_inside_algorithm_sentence_is_not_rewritten_as_formula(self):
entry = self.entry(
name="se_Gauss",
page="builtin/optimization.md",
summary="用高斯消去法求解线性方程组 AX = B",
)
self.assertEqual(
"用高斯消去法求解线性方程组 AX = B",
self.module.improve_description(entry, []),
)
def test_builtin_class_gets_a_purpose_description(self):
entry = self.entry(
name="TStringList",
signature="TStringList",
kind="class",
scope="builtin",
page="builtin/object/tstringlist.md",
summary="TStringList 内置对象",
)
self.assertEqual(
"提供字符串集合存储、查找、排序和名称值管理能力的内置对象",
self.module.improve_description(entry, []),
)
def test_long_property_explanation_is_not_wrapped_in_access_boilerplate(self):
entry = self.entry(
name="CommaTextW",
signature="CommaTextW",
kind="property",
scope="builtin",
page="builtin/object/tstringlist.md",
summary=(
"功能同 CommaText,区别是在读取时返回宽字节字符串,"
"而 CommaText 返回多字节字符串"
),
access="read/write",
owner="TStringList",
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_short_read_and_load_phrases_are_normalized(self):
read_entry = self.entry(
name="read",
page="builtin/object/tstream.md",
summary="读出内容",
)
load_entry = self.entry(
name="loadFromFile",
page="builtin/object/tstringlist.md",
summary="从指定的文件中装载内容",
)
self.assertEqual(
"读取内容", self.module.improve_description(read_entry, [])
)
self.assertEqual(
"从指定的文件中加载内容",
self.module.improve_description(load_entry, []),
)
def test_embedded_read_out_is_normalized_without_duplicate_prefix(self):
entry = self.entry(
name="readExcelSheets",
page="dotnet.md",
summary="从Excel文件中读出Sheets列表。",
)
self.assertEqual(
"从Excel文件中读取Sheets列表",
self.module.improve_description(entry, []),
)
def test_existing_decomposition_verb_is_not_prefixed(self):
entry = self.entry(
name="decodeGraphGroup",
page="builtin/graph.md",
summary="分解图形组合并写入输出参数",
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_comparison_expression_is_not_rewritten_as_formula(self):
entry = self.entry(
name="stockStepAmount",
page="dotnet/financial/stock-capital-flow.md",
summary="分档区间 V1<=Value<V2 的成交金额",
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_financial_action_sentence_is_not_prefixed_with_return(self):
for summary in (
"提取股票的财务比率,与系统证券相关",
"根据报告期获取财务指标",
"将日线数据转换为周线数据",
"统计指定报告期的基金数量",
"生成指定区间的分析结果",
):
with self.subTest(summary=summary):
entry = self.entry(
name="annualRatio",
page="dotnet/financial/financial-analysis.md",
summary=summary,
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_financial_action_clauses_are_not_prefixed_with_return(self):
for summary in (
"用于生成投资组合结果名称",
"如果存在对应记录则返回证券代码",
"对矩阵进行正交处理",
"从净值表中获取基金列表",
"由交易价格倒推出隐含波动率",
"按照周期读取数据",
"与系统参数证券和日期相关",
):
with self.subTest(summary=summary):
entry = self.entry(
name="financialHelper",
page="dotnet/financial/financial-analysis.md",
summary=summary,
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_financial_boolean_description_uses_judgment_wording(self):
entry = self.entry(
name="isBankBond",
page="dotnet/financial/bond-basic-info.md",
summary="是否银行间债券。如果是,返回1,否则返回0",
)
self.assertEqual(
"判断是否银行间债券。如果是,返回1,否则返回0",
self.module.improve_description(entry, []),
)
def test_function_boilerplate_is_removed_from_action_description(self):
entry = self.entry(
name="cb_ytm",
page="dotnet/financial/convertible-bond.md",
summary="该函数采用牛顿迭代法获取可转债的到期收益率",
)
self.assertEqual(
"采用牛顿迭代法获取可转债的到期收益率",
self.module.improve_description(entry, []),
)
def test_financial_numbered_explanation_is_not_prefixed_with_return(self):
entry = self.entry(
name="financialMode",
page="dotnet/financial/financial-analysis.md",
summary="1 表示合并报表,2 表示母公司报表",
)
self.assertEqual(
entry.summary, self.module.improve_description(entry, [])
)
def test_related_data_placeholder_is_made_explicit(self):
entry = self.entry(
name="cb_downPeriodConversionPeriod",
page="dotnet/financial/convertible-bond.md",
summary="CB_DownPeriodConversionPeriod 相关函数",
)
self.assertEqual(
"返回 CB_DownPeriodConversionPeriod 对应的数据",
self.module.improve_description(entry, []),
)
def test_reviewed_generic_descriptions_use_precise_purpose_text(self):
cases = (
(
"sf_Normal",
"统计分布相关函数。",
"计算正态分布函数值",
),
(
"readFile",
"文件访问函数相关函数。",
"读取本地文件中的数据",
),
(
"unicodeEsc2",
"多语言支持函数相关函数。",
"将字符串转换为 \\uxxxx 形式的 Unicode 编码串",
),
)
for name, summary, expected in cases:
with self.subTest(name=name):
entry = self.entry(name=name, page="dotnet.md", summary=summary)
self.assertEqual(
expected, self.module.improve_description(entry, [])
)
def test_reviewed_awkward_descriptions_are_rewritten_directly(self):
cases = (
(
"encodeGraph",
"返回根据指定的类型、名称、数据以及属性数组生成的图形。",
"根据指定的类型、名称、数据和属性数组生成图形并返回",
),
(
"dupeString",
"返回将指定的字符串AText重复ACount次后的字符串",
"将字符串 AText 重复 ACount 次并返回结果",
),
(
"stockpjcj",
"返回:区间平均成交,区间成交金额/区间成交量",
"返回区间平均成交价,计算公式为区间成交金额 / 区间成交量",
),
)
for name, summary, expected in cases:
with self.subTest(name=name):
entry = self.entry(
name=name, page="builtin/string.md", summary=summary
)
self.assertEqual(
expected, self.module.improve_description(entry, [])
)
class TagDerivationTest(unittest.TestCase):
def setUp(self):
self.module = load_script()
def test_array_deduplication_gets_controlled_chinese_and_english_aliases(self):
entry = self.module.EntryContext(
name="arrDropDuplicate",
signature="arrDropDuplicate(data)",
kind="function",
scope="builtin",
page="builtin/array.md",
summary="删除数组中的重复元素",
)
page = self.module.PageContext(
title="Builtin / 数组", path="builtin/array.md"
)
tags = self.module.derive_tags(entry, page)
self.assertTrue(
{"数组", "列表", "去重", "删除重复", "array", "deduplicate"}
<= set(tags)
)
self.assertLessEqual(len(tags), 12)
self.assertNotIn("函数", tags)
self.assertNotIn("builtin", tags)
def test_file_digest_gets_digest_and_hash_aliases(self):
entry = self.module.EntryContext(
name="getMsgDigest",
signature="getMsgDigest(s, mode)",
kind="function",
scope="builtin",
page="builtin/string.md",
summary="计算字符串摘要,支持 CRC32、MD5、SHA 和 SM3",
)
page = self.module.PageContext(
title="Builtin / 字符串", path="builtin/string.md"
)
tags = self.module.derive_tags(entry, page)
self.assertTrue(
{"字符串", "摘要", "哈希", "散列", "digest", "hash"}
<= set(tags)
)
def test_existing_tags_are_preserved_first_and_output_is_idempotent(self):
entry = self.module.EntryContext(
name="readFile",
signature="readFile(alias, file_name)",
kind="function",
scope="builtin",
page="builtin/file.md",
summary="读取文件内容",
tags=("自定义", "读取"),
)
page = self.module.PageContext(
title="Builtin / 文件", path="builtin/file.md"
)
first = self.module.derive_tags(entry, page)
second = self.module.derive_tags(
self.module.EntryContext(**{**entry.__dict__, "tags": tuple(first)}),
page,
)
self.assertEqual(["自定义", "读取"], first[:2])
self.assertEqual(first, second)
def test_substrings_do_not_create_digest_or_com_tags(self):
reshape = self.module.EntryContext(
name="reshape",
signature="reshape(data, shape)",
kind="function",
scope="builtin",
page="builtin/array.md",
summary="重构数组形状",
)
uncompress = self.module.EntryContext(
name="uniuncompress",
signature="uniuncompress(data, type)",
kind="function",
scope="builtin",
page="builtin/string.md",
summary="统一解压缩数据",
)
reshape_tags = self.module.derive_tags(
reshape,
self.module.PageContext("Builtin / 数组", reshape.page),
)
uncompress_tags = self.module.derive_tags(
uncompress,
self.module.PageContext("Builtin / 字符串", uncompress.page),
)
self.assertNotIn("摘要", reshape_tags)
self.assertNotIn("hash", reshape_tags)
self.assertNotIn("COM", uncompress_tags)
self.assertNotIn("OLE", uncompress_tags)
def test_runtime_and_character_names_do_not_create_time_or_graph_tags(self):
random_entry = self.module.EntryContext(
name="randomfrom",
signature="randomfrom(values)",
kind="function",
scope="builtin",
page="builtin/runtime.md",
summary="返回随机数组元素",
)
char_entry = self.module.EntryContext(
name="charToByteLen",
signature="charToByteLen(s, max_len)",
kind="function",
scope="builtin",
page="builtin/string.md",
summary="计算字符串占用的字节数",
)
random_tags = self.module.derive_tags(
random_entry,
self.module.PageContext("Builtin / 运行时", random_entry.page),
)
char_tags = self.module.derive_tags(
char_entry,
self.module.PageContext("Builtin / 字符串", char_entry.page),
)
self.assertNotIn("日期时间", random_tags)
self.assertNotIn("图形", char_tags)
def test_parameter_name_does_not_create_unrelated_time_domain(self):
entry = self.module.EntryContext(
name="futureValue",
signature=(
"futureValue(rate, n_periods, payment, present_value, payment_time)"
),
kind="function",
scope="builtin",
page="builtin/numeric.md",
summary="返回一项投资的未来值",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext("Builtin / 数值计算", entry.page),
)
self.assertNotIn("日期时间", tags)
def test_hash_index_is_not_tagged_as_cryptographic_digest(self):
entry = self.module.EntryContext(
name="THashedStringList",
signature="THashedStringList",
kind="class",
scope="builtin",
page="builtin/object/thashedstringlist.md",
summary="使用哈希索引加速查找的字符串列表内置对象",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext(
"Object / THashedStringList 字符串列表", entry.page
),
)
self.assertIn("哈希索引", tags)
self.assertIn("hash", tags)
self.assertNotIn("摘要", tags)
self.assertNotIn("digest", tags)
def test_business_statistics_verb_does_not_add_probability_domain(self):
entry = self.module.EntryContext(
name="fundCount",
signature="fundCount(report_date)",
kind="function",
scope="dotnet",
page="dotnet/financial/fund-holdings.md",
summary="统计指定报告期的基金数量",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext("金融 / 基金 / 持仓", entry.page),
)
self.assertNotIn("概率", tags)
self.assertNotIn("statistics", tags)
self.assertNotIn("统计指定报告期的基金数量", tags)
self.assertNotIn("证券", tags)
def test_taxonomy_tags_remove_generic_suffixes(self):
entry = self.module.EntryContext(
name="weightedMedian",
signature="weightedMedian(data, weights)",
kind="function",
scope="dotnet",
page="dotnet/financial/weighted-statistics.md",
summary="计算加权中位数",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext(
"基础算法常见加权统计量及其实现 / 时间相关函数",
entry.page,
),
)
self.assertIn("基础算法常见加权统计量", tags)
self.assertIn("时间", tags)
self.assertNotIn("基础算法常见加权统计量及其实现", tags)
self.assertNotIn("时间相关函数", tags)
def test_taxonomy_tags_drop_parenthetical_explanations_and_punctuation(self):
entry = self.module.EntryContext(
name="close",
signature="close()",
kind="function",
scope="dotnet",
page="dotnet/datawarehouse/after_market.md",
summary="收盘价,与系统参数股票、时间、周期和复权相关",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext(
"数据仓库 / 盘后相关(在当日可用于盘中)", entry.page
),
)
self.assertIn("数据仓库", tags)
self.assertIn("盘后", tags)
self.assertNotIn("(", tags)
self.assertNotIn(")", tags)
self.assertNotIn("盘后相关在当日可用于盘中", tags)
self.assertNotIn("关闭", tags)
self.assertNotIn("close", tags)
def test_name_intent_does_not_override_an_explicit_action_clause(self):
entry = self.module.EntryContext(
name="openEndFundFilterByFundIndex",
signature="openEndFundFilterByFundIndex(fund_inds)",
kind="function",
scope="dotnet",
page="dotnet/financial/fund-pool.md",
summary="根据基金指数获取成分基金,并对基金进行筛选",
)
tags = self.module.derive_tags(
entry,
self.module.PageContext("金融 / 基金 / 基金池", entry.page),
)
self.assertNotIn("打开", tags)
self.assertNotIn("open", tags)
class MarkdownEnrichmentTest(unittest.TestCase):
def setUp(self):
self.module = load_script()
def test_inserts_tags_after_description_without_touching_code(self):
text = (
"# Builtin / 数组\n\n"
"## `arrDropDuplicate(data)`\n\n"
"声明:function\n\n"
"删除数组中的重复元素。\n\n"
"| 参数 | 类型 | 说明 |\n"
"| --- | --- | --- |\n"
"| `data` | array | 输入数组 |\n\n"
"返回:array\n\n"
"### 示例\n\n"
"```tsl\nreturn arrDropDuplicate(array(1, 1));\n```\n"
)
result, audit = self.module.enrich_markdown(
text, "builtin/array.md", self.module.SourceCatalog.empty()
)
repeated, repeated_audit = self.module.enrich_markdown(
result, "builtin/array.md", self.module.SourceCatalog.empty()
)
self.assertIn("删除数组中的重复元素\n\n<!-- tags:", result)
self.assertIn("return arrDropDuplicate(array(1, 1));", result)
self.assertEqual(result, repeated)
self.assertEqual(1, audit.changed_descriptions)
self.assertEqual(1, audit.changed_tags)
self.assertEqual(0, repeated_audit.changed_descriptions)
self.assertEqual(0, repeated_audit.changed_tags)
if __name__ == "__main__":
unittest.main()
+104 -13
View File
@@ -164,19 +164,21 @@ class DocGenCliTest(unittest.TestCase):
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(self.generated("base/my_functions").is_file())
def test_legacy_positional_input_remains_supported(self):
def test_positional_input_is_rejected(self):
result = self.run_raw_cli(self.input)
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(self.generated("base/my_functions").is_file())
self.assertEqual(2, result.returncode)
self.assertIn(f"无法识别的参数: {self.input}", result.stderr)
self.assertFalse(self.generated("base/my_functions").exists())
def test_help_marks_legacy_input_as_deprecated(self):
def test_help_only_documents_named_input_options(self):
result = self.run_raw_cli("--help")
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("--file INPUT_FILE", result.stdout)
self.assertIn("--dir INPUT_DIR", result.stdout)
self.assertIn("已废弃,请使用 --file", result.stdout)
self.assertNotIn("positional arguments:", result.stdout)
self.assertNotIn("已废弃", result.stdout)
def test_invalid_format_error_is_fully_chinese(self):
result = self.run_raw_cli("--file", self.input, "--format", "xml")
@@ -192,7 +194,6 @@ class DocGenCliTest(unittest.TestCase):
cases = (
(),
("--file", self.input, "--dir", self.root),
(self.input, "--file", self.input),
)
for args in cases:
with self.subTest(args=args):
@@ -200,7 +201,7 @@ class DocGenCliTest(unittest.TestCase):
self.assertEqual(2, result.returncode)
self.assertIn(
"必须且只能指定一种输入方式:INPUT_FILE、--file 或 --dir",
"必须且只能指定一种输入方式:--file 或 --dir",
result.stderr,
)
@@ -399,6 +400,49 @@ class DocGenCliTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(output.is_file())
def test_root_writes_under_explicit_project_root(self):
output_root = self.root / "explicit-root"
result = self.run_cli("--root", str(output_root))
output = (
output_root
/ "skills"
/ "tsl-api-reference"
/ "references"
/ "codegen"
/ "project"
/ "base"
/ "my_functions.md"
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(output.is_file())
self.assertFalse(self.generated("base/my_functions").exists())
def test_relative_root_is_resolved_from_current_working_directory(self):
result = self.run_cli("--root", "explicit-root")
output = (
self.root
/ "explicit-root"
/ "skills"
/ "tsl-api-reference"
/ "references"
/ "codegen"
/ "project"
/ "base"
/ "my_functions.md"
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(output.is_file())
def test_help_documents_root_option(self):
result = self.run_raw_cli("--help")
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("--root ROOT_DIR", result.stdout)
self.assertIn("包含 skills 目录的项目根目录", result.stdout)
def test_output_option_is_rejected(self):
result = self.run_cli("--output", str(self.root / "out.md"))
self.assertNotEqual(result.returncode, 0)
@@ -498,12 +542,19 @@ class DocGenCliTest(unittest.TestCase):
output.write_text("原内容\n", encoding="utf-8")
bin_dir = self.root / "bin"
bin_dir.mkdir()
fake_npx = bin_dir / "npx"
fake_npx.write_text(
"#!/bin/sh\necho formatter-failed >&2\nexit 9\n",
encoding="utf-8",
)
fake_npx.chmod(0o755)
if os.name == "nt":
fake_npx = bin_dir / "npx.cmd"
fake_npx.write_text(
"@echo off\r\necho formatter-failed 1>&2\r\nexit /b 9\r\n",
encoding="utf-8",
)
else:
fake_npx = bin_dir / "npx"
fake_npx.write_text(
"#!/bin/sh\necho formatter-failed >&2\nexit 9\n",
encoding="utf-8",
)
fake_npx.chmod(0o755)
env = os.environ.copy()
env["PATH"] = str(bin_dir)
@@ -884,6 +935,46 @@ class DocGenCliTest(unittest.TestCase):
self.assertIn("范例02:多行输出", text)
self.assertIn("// 输出:\n// first\n// second", text)
def test_singular_example_is_rejected_without_overwrite(self):
self.write_input(
{
"module": "项目 / 旧示例格式",
"path": "base/singular_example",
"declarations": [
{
"kind": "function",
"name": "demo",
"signature": "demo()",
"desc": "示例函数。",
"returns": "nil",
"example": "return demo();",
}
],
}
)
self.assert_rejected_without_overwrite(
"base/singular_example", "存在未知字段:example"
)
def test_singular_method_example_is_rejected_without_overwrite(self):
self.write_class_member_input(
{
"kind": "method",
"name": "Run",
"visibility": "public",
"binding": "instance",
"signature": "Run()",
"desc": "运行。",
"example": "return self.Run();",
},
"base/singular_method_example",
)
self.assert_rejected_without_overwrite(
"base/singular_method_example", "存在未知字段:example"
)
def test_top_level_enum_sections_are_plain_text_not_emphasis_headings(self):
self.write_input(
{