我为一种没有模块系统的语言构建了模块系统

发布: (2025年12月28日 GMT+8 05:26)
16 min read
原文: Dev.to

Claudia Nadalin

Source: Dev.to – “I built a module system for a language that doesn’t have one”

你知道 PineScript 有多可笑吗?现在是 2025 年,我们写交易指标的方式却像是 1995 年——只有一个文件,所有东西都是全局的。没有模块。没有 import。只有你、你的代码,以及慢慢陷入疯狂的过程。

我已经忍受这种情况有一段时间了。我有一个相当复杂的指标,使用了我在多个 TradingView 库中拆分的代码。当时看起来是正确的做法:分离关注点,保持整洁,非常专业。

然后我需要修改一个函数。

来自地狱的工作流程

更新一个库函数过去是这样的:

  1. 在本地编辑代码。
  2. 推送到 Git。
  3. 复制‑粘贴到 TradingView 的编辑器。
  4. 重新发布库。
  5. 记录新的版本号。
  6. 打开指示器脚本。
  7. 用新版本更新 import 语句。
  8. 保存脚本。
  9. 将更改推送到 Git。
  10. 将更新后的脚本复制‑粘贴回 TradingView 的编辑器。
  11. 再次保存。

而这仅仅是针对 一个 库——我有五个。

“你看过那集 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 的标识符节点,而不会影响字符串和其他不相关的标识符。

在此基础上,打包工具可以:

  1. 解析 每个源文件为 AST。
  2. 遍历 树,收集所有导出的标识符。
  3. 生成 唯一的、带命名空间的标识符(例如 __utils_math__double)。
  4. 仅替换 需要重命名的标识符节点。
  5. 输出 每个文件的转换后代码并将结果拼接。

这就是我最终构建的 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 来:

  1. 使用 pynescript 解析两个文件。
  2. 在 AST 中找到 double 的函数定义并将其重命名为 __math_utils__double
  3. main.pine 的 AST 中找到对 double 的引用并更新为 __math_utils__double
  4. 合并并反解析。

结果:

//@version=5
indicator("Test", overlay=true)

__math_utils__double(x) =>
    x * 2

result = __math_utils__double(close)
plot(result)

我把它粘贴到 TradingView 中,居然成功了。

我真的感到惊讶——不是因为它有什么魔法,而是因为我本以为会遇到某些奇怪的边缘情况或解析器限制,从而让整个想法破灭。但事实并非如此。它就……成功了。

构建真实工具

在实现了 spike 之后,我完成了完整工具的构建:

  • 一个 CLI(pinecone buildpinecone 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/).
Back to Blog

相关文章

阅读更多 »

NgRx Toolkit v21

NgRx Toolkit v21 NgRx Toolkit 起源于 SignalStore 甚至还未标记为稳定的时期。 在那些早期,社区对各种 f...

使用 Claw 在手机上控制 Claude Code

问题:你正深入进行一次 Claude Code 会话,正在处理一个复杂任务。但你需要离开一下——去喝咖啡、接电话、接孩子。你该怎么办?