跳转到内容

设计原则

parsh 没有正式的宣言。下面这些是实现在反复回归的原则,尽量直白地写出来:

用表达式、值和明确的边界,替代 shell 的字符串约定; 用一点啰嗦,换回可预测性。

提示符是一个省略了左括号的表达式。$ () 等待 echo hi,实际求值的是 (echo hi)。 交互输入和脚本共用同一个 reader、同一个求值器和同一套诊断,因此不存在需要额外学习、 也不存在需要保持同步的第二套命令行语法。

注释遵循同样的规则:(; ...) 是一个可以嵌套、遵守括号配平的形式, 而不是到行尾就结束的行注释。细节见语法与值。

裸词就是文本。123 是文本 123,true、false 和 nil 在值位置没有字面量含义。 通过 #123、#false、#nil 等字面值显式构造类型值;算术和数值比较只接受数值。 动态转换使用 int、float、bool:

(eq 001 1)
(= #001 #1)
(let mode fast)
(if (eq $mode fast) (echo fast))

条件必须产生布尔值或命令状态。文本永远不会被隐式当作真值。

文本永远是一个参数,列表只向外层命令参数展开一层。通配是一次调用,而不是一条语法规则:

(let files (list "a file.rs" "b.rs"))
(printf "<%s>\n" $files)
(for file (glob "*.rs") (echo $file))

重点在于:脚本写的是什么,执行的就是什么——没有隐式拆词,没有隐藏的路径展开, 也不需要猜测空格在调用点意味着什么。

一个表达式可以调用内置形式、宏、函数、结构体构造器或外部程序。外部程序直接启动: 表达式不会再交给另一个 Shell 解析,因此没有第二轮引号处理,也没有注入层。

命令结果是值。命令出现在值位置时,stdout 按 UTF-8 文本捕获并去掉尾部换行; 二进制输出走重定向或管道;退出状态是一个 status 值。普通非零状态会中止当前批次, 但条件与显式捕获可以检查它。完整结果保留退出和信号信息,异常机制用于恢复执行。参见命令与组合。

读取、赋值和传参都会拷贝值,包括嵌套的列表和映射。集合更新返回新值,而不是修改旧值:

(let config {mode fast})
(set config (map/set $config mode slow))

函数值的副本保留相同身份和捕获环境;普通数据的集合更新仍返回新值。

每个括号表达式创建新的词法作用域。let 把绑定写入当前表达式的外围作用域, 同一作用域重复绑定会报错;set 更新最近的已有绑定并返回 nil。函数捕获定义处的作用域, 循环每一轮创建新的作用域。这让“某个值在哪里被改过”这类问题消失, 也让结构体和宏在组合时不必担心别名带来的意外。

列表、映射、结构体、管道和重定向都是表达式。type 在不执行的情况下报告一个名字如何解析, 补全覆盖命令、路径、变量、结构体成员和宏:

(defstruct Point x y)
(type Point.x)
(| (find . -name "*.rs") (sort))

目标是让人和工具去读结构,而不是猜字符串。同一个性质也让生成的脚本更容易审阅: 只有一套语法、没有引号层,脚本读起来就是它执行的样子。参见结构与宏。

这门语言会说明自己还没做什么。管道阶段只接受外部命令;宏没有卫生性; 嵌套的 quasiquote 原样拷贝;原生后台任务使用隔离解释器线程,库导入属于后续工作。退出码和诊断位置稳定到可以依赖: 语言错误用 2,找不到程序用 127,权限不足用 126。参见错误与退出码。

限制是被写下来的,而不是被发现的。

  • 用空格和引号携带结构。
  • 把文本当成真值。
  • 隐式拆词和隐式路径展开。
  • 把表达式交给另一个 Shell 再解析一遍。
  • 把未文档化的行为当作兼容性。

这些选择并非没有成本:

  • 一次性命令更啰嗦:(if (eq $x y) ...) 而不是 [ "$x" = y ]。
  • 标量字面值需要 # 前缀;(echo 1) 仍传递文本。
  • 以 # 开头的字面文本需要加引号。
  • 值语义让大集合的传递有拷贝成本。
  • POSIX 习惯不能直接套用:for f in *.rs 要写成 (for f (glob "*.rs"))。

这个交换是刻意的:多敲几个字符,换来一个行为可以从纸面上读出来的脚本。