I Built a Module System for a Language That Doesn't Have One
Source: Dev.to – “I built a module system for a language that doesn’t have one”
You know what’s funny about PineScript? It’s 2025 and we’re writing trading indicators like it’s 1995—one file, everything global. No modules. No imports. Just you, your code, and the slow descent into madness.
I’d been putting up with this for a while. I had a fairly complex indicator that used code I’d split across multiple TradingView libraries. It seemed like the right thing to do at the time: separate concerns, keep things clean, very professional.
Then I needed to change one function.
The Workflow From Hell
Updating a library function used to look like this:
- Edit the code locally.
- Push to Git.
- Copy‑paste into TradingView’s editor.
- Republish the library.
- Note the new version number.
- Open the indicator script.
- Update the import statement with the new version.
- Save the script.
- Push the changes to Git.
- Copy‑paste the updated script back into TradingView’s editor.
- Save again.
And that was just for one library—I had five of them.
“You ever see that Seinfeld episode where George tries to do the opposite of every instinct he has?”
That’s how this workflow felt, except there was no Yankees job at the end—just a bug to fix.
There had to be a better way.
Surely Someone’s Solved This
I started researching. Googled things like “PineScript bundler”, “PineScript multiple files”, “PineScript module system”.
I found:
- VS Code extensions for syntax highlighting (cool, but not what I need)
- PyneCore/PyneSys, which transpiles PineScript to Python for running backtests locally (interesting, but a different problem)
- A lot of forum posts from people with the same frustration and no solutions
What I didn’t find was a bundler. Nobody had built the thing I wanted.
But even before searching, I knew why this was hard. I knew what any solution would require. And it wasn’t pretty.
Why This Is Actually Hard
JavaScript has something called scope. When Webpack bundles your code, it can wrap each module in a function and keep them isolated:
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 };
})();
Two functions named double, no problem. They live in different scopes and can’t see each other. Webpack leverages this to keep modules isolated.
PineScript doesn’t have this.
There are no closures, no way to create isolated scopes. Everything is basically global. If you define double in one file and double in another file, then smash those files together, you get a collision—one overwrites the other and your code breaks.
So any PineScript bundler would need to rename things. For example, take the double function from utils/math.pine and rename it to something like __utils_math__double. Do the same for every export from every file, and update all the references. No collisions.
I knew this was the path before I wrote a single line of code. The question was: how do you actually rename things in code reliably?
The Find/Replace Trap
My first instinct was simple: just use find / replace. Find double, replace with __utils_math__double. Done.
Why that falls apart
double(x) =>
x * 2
myLabel = "Call double() for twice the value"
doubleCheck = true
plot(double(close), title="double")
You want to rename the double function to __utils_math__double. With a naïve find / replace you’d get:
__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
- The string literal
"double"was altered. - The unrelated variable
doubleCheckwas renamed.
Find / replace treats the source as a flat string of characters; it cannot distinguish:
- a function definition (
double) - a function call (
double) - a string literal (
"double")
Parsing Pine Script properly would require a full‑blown parser—something that handles its quirky indentation, line continuation, type inference, and other quirks.
That’s why I turned to pynescript.
The Missing Piece
pynescript is a Python library that parses PineScript into an Abstract Syntax Tree (AST) and can unparse it back into PineScript.
If “Abstract Syntax Tree” sounds intimidating, don’t worry—it’s actually a simple concept. An AST is basically the DOM, but for code instead of HTML.
When your browser receives HTML, it doesn’t keep it as a raw string. It parses the markup into a tree where each node knows what it is—a <script> tag, a <div> tag, a text node, etc. You can manipulate these nodes programmatically—add classes, change text, move elements around—without doing string manipulation on raw HTML.
An AST does the same thing for code. When you parse this PineScript snippet:
double(x) =>
x * 2
myLabel = "Call double() for twice the value"
doubleCheck = true
plot(double(close), title="double")
you get a tree where:
- The function definition
doubleis a distinct node. - The string literal
"Call double() for twice the value"is another node. - The variable
doubleCheckis its own identifier node. - The function call
double(close)is yet another node.
Because the tree knows the type of each token, you can safely rename only the identifier nodes that represent the function double, leaving strings and unrelated identifiers untouched.
From there, a bundler can:
- Parse every source file into an AST.
- Walk the tree, collecting all exported identifiers.
- Generate unique, namespaced identifiers (e.g.,
__utils_math__double). - Replace only the identifier nodes that need renaming.
- Emit the transformed code for each file and concatenate the results.
That’s the core idea behind the PineScript bundler I eventually built.
TL;DR
- PineScript’s global‑only nature makes naïve bundling impossible without name collisions.
- Simple find/replace breaks because it can’t distinguish code structure from plain text.
pynescriptprovides a proper AST for PineScript, letting us rename identifiers safely.- With an AST‑based approach we can build a reliable PineScript bundler that namespaces every export and eliminates collisions.
If you’re wrestling with the same workflow nightmare, give pynescript a look and consider building a small bundler around it. It saved me countless hours of copy‑pasting and version‑hunting, and it might just rescue you from the “1995‑style” PineScript hell too.
Example AST
Script
├── FunctionDefinition
│ ├── name: "double" ← I'm a function definition
│ ├── parameters: ["x"]
│ └── body: BinaryOp(x * 2)
│
└── Assignment
├── target: "myLabel"
└── value: StringLiteral ← I'm a string, leave me alone
└── "Call double()"
Now renaming becomes surgical. You say:
“Find all nodes of type
FunctionDefinitionwhere name equalsdouble. Rename those. Find all nodes of typeFunctionCallwhere the function isdouble. Rename those too. LeaveStringLiteralnodes alone.”
The string content is untouched because it’s a StringLiteral node, not a FunctionCall node. doubleCheck is untouched because it’s a completely different identifier node. The structure tells you what’s what.
pynescript had already done the hard work of parsing PineScript correctly (months of work with ANTLR, a serious parser generator). I could just pip install pynescript and get access to that tree.
I had my strategy (rename with prefixes), and now I had my tool (AST manipulation via pynescript). Time to see if it actually worked.
The Spike
Before committing to building a full tool, I wanted to prove the concept. Take two PineScript files, parse them, rename stuff in the AST, merge them, unparse, and see if TradingView accepts the output.
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)
Then I wrote some Python to:
- Parse both files with
pynescript. - Find the function definition for
doublein the AST and rename it to__math_utils__double. - Find references to
doubleinmain.pine’s AST and update them to__math_utils__double. - Merge and unparse.
The result:
//@version=5
indicator("Test", overlay=true)
__math_utils__double(x) =>
x * 2
result = __math_utils__double(close)
plot(result)
I pasted it into TradingView. It worked.
I was genuinely surprised—not because it was magic, but because I’d expected to hit some weird edge case or parser limitation that would kill the whole idea. But no. It just… worked.
Building the Real Thing
With the spike working, I built out the full tool:
- A CLI (
pinecone build,pinecone build --watch). - Config‑file support.
- Proper dependency‑graph construction.
- Topological sorting (so dependencies come before the code that uses them).
- Error messages that point to your original files, not the bundled output.
- A
--copyflag that puts the output straight into your clipboard.
The module syntax uses comments so that unbundled code doesn’t break if you accidentally paste it into TradingView:
// @import { customRsi } from "./indicators/rsi.pine"
// @export myFunction
TradingView’s parser just sees these as comments and ignores them. PineCone sees them as directives.
The prefix strategy uses the file path. src/utils/math.pine becomes __utils_math__. Every exported function and variable from that file gets that prefix, and every reference to those exports gets updated.
It’s not elegant. Your bundled output has ugly names like __indicators_rsi__customRsi. But it works. TradingView accepts it. There are no collisions. And you never have to look at the bundled output anyway—it’s just an intermediate artifact, like compiled code.
What Did I Actually Build?
I’ve been calling it “Webpack for PineScript,” but that’s not quite right. Webpack does a lot of stuff: code splitting, lazy loading, tree shaking, hot module replacement.
PineCone does one thing: it lets you write PineScript across multiple files with a module system, and compiles it down to a single file TradingView can understand.
- It’s a module system and a bundler for a language that has neither.
- It doesn’t add features to PineScript, make your indicators faster, or connect to your broker.
- It simply lets you organize your code like a normal human being in 2025.
And honestly? That’s enough. That’s the thing I needed.
The Takeaway
If you’re working with a language that’s missing something fundamental, you might not be stuck. Look for parsers. Look for AST tools. If someone’s already done the work of understanding the language’s structure, you can build on top of that.
pynescript didn’t solve my problem directly. It’s a parser, not a bundler. But it solved the hard part—correctly parsing PineScript—and let me focus on the part I actually cared about: the module system.
PineCone is open source. If you’re a PineScript developer who’s ever felt the pain of managing multiple libraries, give it a try. And if you’ve got ideas for making it better, I’m all ears.
Serenity now.
PineCone is available here. Built with pynescript, which you should also check out if you’re doing anything programmatic with PineScript.
Claudia is a Front‑end Developer. You can find more of her work here.
Here’s the cleaned‑up markdown segment with the correct link syntax:
[Visit the website](https://www.claudianadalin.com/). 