我为一种没有模块系统的语言构建了模块系统
Source: Dev.to – “I built a module system for a language that doesn’t have one”
你知道 PineScript 有多可笑吗?现在是 2025 年,我们写交易指标的方式却像是 1995 年——只有一个文件,所有东西都是全局的。没有模块。没有 import。只有你、你的代码,以及慢慢陷入疯狂的过程。
我已经忍受这种情况有一段时间了。我有一个相当复杂的指标,使用了我在多个 TradingView 库中拆分的代码。当时看起来是正确的做法:分离关注点,保持整洁,非常专业。
然后我需要修改一个函数。
来自地狱的工作流程
更新一个库函数过去是这样的:
- 在本地编辑代码。
- 推送到 Git。
- 复制‑粘贴到 TradingView 的编辑器。
- 重新发布库。
- 记录新的版本号。
- 打开指示器脚本。
- 用新版本更新 import 语句。
- 保存脚本。
- 将更改推送到 Git。
- 将更新后的脚本复制‑粘贴回 TradingView 的编辑器。
- 再次保存。
而这仅仅是针对 一个 库——我有五个。
“你看过那集 Seinfeld(《宋飞正传》)里乔治试图做所有本能相反的情节吗?”
这就是这种工作流程的感觉,只是最后没有扬基的工作——只有一个要修复的 bug。
必须有更好的办法。
肯定有人已经解决了这个
我开始进行研究。搜索了类似 “PineScript bundler”、“PineScript multiple files”、“PineScript module system” 的关键词。
我发现了:
- VS Code 语法高亮扩展(很酷,但不是我需要的)
- PyneCore/PyneSys,它将 PineScript 转译为 Python,以便在本地运行回测(有趣,但是另一个问题)
- 大量论坛帖子,作者们都有相同的困惑,却没有解决方案
我没有找到打包工具。没有人构建我想要的东西。
但即使在搜索之前,我已经知道这有多难。我清楚任何解决方案都需要什么。而且并不美好。
为什么这实际上很难
JavaScript 有一种叫做 作用域 的概念。当 Webpack 打包你的代码时,它可以把每个模块包装在一个函数里,从而保持它们相互隔离:
var __module_1__ = (function () {
function double(x) {
return x * 2;
}
return { double };
})();
var __module_2__ = (function () {
function double(x) {
return x + 100;
} // No collision – different scope
return { double };
})();
两个名为 double 的函数,没问题。它们位于不同的作用域中,互相不可见。Webpack 正是利用这一点来保持模块的隔离。
PineScript 没有这种机制。
没有闭包,也没有办法创建隔离的作用域。所有东西基本上都是全局的。如果你在一个文件里定义了 double,在另一个文件里也定义了 double,然后把这两个文件合并在一起,就会产生冲突——一个会覆盖另一个,代码就会出错。
因此,任何 PineScript 打包工具都必须 重命名 这些内容。例如,把 utils/math.pine 中的 double 函数重命名为类似 __utils_math__double 的名字。对每个文件的每个导出都做同样的处理,并更新所有引用。这样就不会产生冲突。
我在写下第一行代码之前就已经知道这条路要走。问题是:如何在代码中可靠地进行重命名?
查找/替换陷阱
我的第一直觉很简单:直接使用 查找 / 替换。查找 double,替换为 __utils_math__double。完成。
为什么会出问题
double(x) =>
x * 2
myLabel = "Call double() for twice the value"
doubleCheck = true
plot(double(close), title="double")
你想把 double 函数重命名为 __utils_math__double。使用天真的查找 / 替换会得到:
__utils_math__double(x) =>
x * 2
myLabel = "Call __utils_math__double() for twice the value" // Broken – changed string content
__utils_math__doubleCheck = true // Broken – renamed wrong variable
plot(__utils_math__double(close), title="__utils_math__double") // Broken – changed string content
- 字符串字面量
"double"被修改了。 - 与之无关的变量
doubleCheck被重命名了。
查找 / 替换把源代码视为一串平面的字符;它无法区分:
- 函数定义 (
double) - 函数调用 (
double) - 字符串字面量 (
"double")
要正确解析 Pine Script,需要一个完整的解析器——能够处理其古怪的缩进、换行续写、类型推断以及其他怪癖。
这就是我转而使用 pynescript 的原因。
Source: …
缺失的拼图
pynescript 是一个 Python 库,用于将 PineScript 解析为 抽象语法树 (AST),并且可以将其重新反解析回 PineScript。
如果“抽象语法树”听起来让人望而生畏,别担心——它其实是一个很简单的概念。AST 基本上相当于代码的 DOM,而不是 HTML 的 DOM。
当浏览器收到 HTML 时,它不会把它当作原始字符串保存。它会把标记解析成一棵树,每个节点都知道自己是什么——<script> 标签、<div> 标签、文本节点等。你可以以编程方式操作这些节点——添加类、修改文本、移动元素——而不必对原始 HTML 进行字符串操作。
AST 对代码做的事情与此相同。当你解析下面这段 PineScript:
double(x) =>
x * 2
myLabel = "Call double() for twice the value"
doubleCheck = true
plot(double(close), title="double")
你会得到一棵树,其中:
- 函数定义
double是一个独立的节点。 - 字符串字面量
"Call double() for twice the value"是另一个节点。 - 变量
doubleCheck是它自己的标识符节点。 - 函数调用
double(close)又是一个节点。
因为树知道每个 token 的 类型,你可以安全地只重命名代表函数 double 的标识符节点,而不会影响字符串和其他不相关的标识符。
在此基础上,打包工具可以:
- 解析 每个源文件为 AST。
- 遍历 树,收集所有导出的标识符。
- 生成 唯一的、带命名空间的标识符(例如
__utils_math__double)。 - 仅替换 需要重命名的标识符节点。
- 输出 每个文件的转换后代码并将结果拼接。
这就是我最终构建的 PineScript 打包器背后的核心思路。
TL;DR
- PineScript 只支持全局作用域,这使得在不产生名称冲突的情况下进行简单的打包几乎不可能。
- 直接的查找/替换会出错,因为它无法区分代码结构和普通文本。
pynescript提供了 PineScript 的完整抽象语法树(AST),让我们能够安全地重命名标识符。- 采用基于 AST 的方法,我们可以构建一个可靠的 PineScript 打包工具,为每个导出添加命名空间,从而消除冲突。
如果你也在为同样的工作流噩梦而苦恼,建议看看 pynescript 并考虑围绕它构建一个小型打包器。它为我省去了无数次的复制‑粘贴和版本搜索,也许能把你从“1995 年风格”的 PineScript 地狱中拯救出来。
示例 AST
Script
├── FunctionDefinition
│ ├── name: "double" ← 我是一个函数定义
│ ├── parameters: ["x"]
│ └── body: BinaryOp(x * 2)
│
└── Assignment
├── target: "myLabel"
└── value: StringLiteral ← 我是一个字符串,别动我
└── "Call double()"
现在重命名就像外科手术一样精确。你可以这样说:
“查找所有类型为
FunctionDefinition且名称等于double的节点。对这些节点重命名。再查找所有类型为FunctionCall且函数名为double的节点。也对它们重命名。保持StringLiteral节点不变。”
字符串内容保持不变,因为它是 StringLiteral 节点,而不是 FunctionCall 节点。doubleCheck 也保持不变,因为它是完全不同的标识符节点。结构告诉我们每个节点的意义。
pynescript 已经完成了正确解析 PineScript 的艰巨工作(数月使用 ANTLR——一个强大的解析器生成器——的开发)。我只需 pip install pynescript 就能获取到这棵树。
我已经有了策略(使用前缀重命名),现在也有了工具(通过 pynescript 进行 AST 操作)。接下来就看它是否真的有效了。
尖刺
在投入构建完整工具之前,我想先验证概念。取两个 PineScript 文件,解析它们,在抽象语法树(AST)中重命名元素,合并后再反解析,看看 TradingView 是否接受生成的代码。
math_utils.pine
//@version=5
// @export double
double(x) =>
x * 2
main.pine
//@version=5
// @import { double } from "./math_utils.pine"
indicator("Test", overlay=true)
result = double(close)
plot(result)
随后我写了一段 Python 来:
- 使用
pynescript解析两个文件。 - 在 AST 中找到
double的函数定义并将其重命名为__math_utils__double。 - 在
main.pine的 AST 中找到对double的引用并更新为__math_utils__double。 - 合并并反解析。
结果:
//@version=5
indicator("Test", overlay=true)
__math_utils__double(x) =>
x * 2
result = __math_utils__double(close)
plot(result)
我把它粘贴到 TradingView 中,居然成功了。
我真的感到惊讶——不是因为它有什么魔法,而是因为我本以为会遇到某些奇怪的边缘情况或解析器限制,从而让整个想法破灭。但事实并非如此。它就……成功了。
构建真实工具
在实现了 spike 之后,我完成了完整工具的构建:
- 一个 CLI(
pinecone build、pinecone build --watch)。 - 支持配置文件。
- 正确的依赖图构建。
- 拓扑排序(确保依赖在使用它们的代码之前出现)。
- 错误信息指向你的原始文件,而不是打包后的输出。
--copy参数可以直接将输出复制到剪贴板。
模块语法使用注释,这样如果你不小心把未打包的代码粘贴到 TradingView 中也不会出错:
// @import { customRsi } from "./indicators/rsi.pine"
// @export myFunction
TradingView 的解析器只会把这些当作注释并忽略它们。PineCone 则将其视为指令。
前缀策略使用文件路径。src/utils/math.pine 会变成 __utils_math__。该文件中每个导出的函数和变量都会加上该前缀,所有对这些导出的引用也会相应更新。
这并不优雅。你的打包输出会出现像 __indicators_rsi__customRsi 这样的丑陋名称。但它能工作,TradingView 能接受。不会产生冲突。而且你根本不需要查看打包输出——它只是一个中间产物,就像编译后的代码一样。
我到底构建了什么?
我一直把它称作 “Webpack for PineScript”,但这并不完全准确。Webpack 能做很多事:代码拆分、懒加载、树摇、热模块替换。
PineCone 只做 一件事:它让你能够在多个文件中编写 PineScript,并使用模块系统,将其编译成 TradingView 能理解的单个文件。
- 它是一个 模块系统,也是一种 打包工具,而 PineScript 本身既没有模块系统也没有打包工具。
- 它不向 PineScript 添加新功能,不会让你的指标运行得更快,也不与经纪商连接。
- 它仅仅是让你能够像 2025 年的普通人一样组织代码。
说实话?这已经足够了。这正是我所需要的。
要点
如果你正在使用的语言缺少某些基础功能,你未必就束手无策。可以寻找解析器(parsers)或抽象语法树(AST)工具。如果已经有人完成了对该语言结构的理解工作,你就可以在此基础上进行构建。
pynescript 并没有直接解决我的问题。它是一个解析器,而不是打包工具。但它解决了最困难的部分——正确解析 PineScript——从而让我可以专注于我真正关心的部分:模块系统。
PineCone 是开源的。如果你是曾经为管理多个库而感到痛苦的 PineScript 开发者,欢迎尝试。如果你有改进的想法,我非常乐意倾听。
现在宁静。
PineCone 可在此处获取。它是使用 pynescript 构建的,如果你在进行任何 PineScript 的编程工作,也应该去看看它。
Claudia 是一名前端开发者。你可以在这里看到她的更多作品。
下面是已清理的 Markdown 片段,使用了正确的链接语法:
[Visit the website](https://www.claudianadalin.com/). 