DEV Community

Claudia Nadalin
Claudia Nadalin

Posted on • Originally published at claudianadalin.com

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. 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

Here's what updating a library function looked like:

  1. Edit the code locally
  2. Push to git
  3. Copy-paste into TradingView's editor
  4. Republish the library
  5. Note the new version number
  6. Open my indicator script
  7. Update the import statement with the new version
  8. Save that
  9. Push that to git
  10. Copy-paste into TradingView's editor
  11. Save again

And that's for ONE library. I had five.

You ever see that Seinfeld episode where George tries to do the opposite of every instinct he has? That's what this workflow felt like, except I wasn't getting a job with the Yankees at the end of it. I was just trying to fix a bug.

There had to be another 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 transpile PineScript to Python for running backtests locally (interesting, but 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 they're isolated from each other:

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 };
})();
Enter fullscreen mode Exit fullscreen mode

Two functions named double, no problem. They live in different scopes. They 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've got a collision. One overwrites the other. Your code breaks.

So any PineScript bundler would need to rename things. 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. 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.

Here's why that falls apart immediately. Imagine this code:

double(x) =>
    x * 2

myLabel = "Call double() for twice the value"
doubleCheck = true
plot(double(close), title="double")
Enter fullscreen mode Exit fullscreen mode

You want to rename the double function to __utils_math__double.

With 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
Enter fullscreen mode Exit fullscreen mode

You've corrupted string literals and renamed a completely unrelated variable that happened to start with "double".

Find/replace sees code as a flat string of characters. It has no idea that "double" inside quotes is a string, not a function reference. It can't tell that doubleCheck is a different identifier. It just sees the letters d-o-u-b-l-e and does its thing.

I needed a way to understand the structure of the code. To know that this double is a function definition, that double is a function call, and that "double" is just text inside a string that should be left alone.

This is where I got stuck. Building something that truly understands PineScript's structure would take months. Parsing is hard. PineScript has weird indentation rules, line continuation, type inference, all sorts of quirks.

Then I found pynescript.

The Missing Piece

pynescript is a Python library that parses PineScript into something called 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 gets HTML, it doesn't keep it as a string. It parses it into a tree structure where each node knows what it is. A <div>. A <p>. A text node. You can manipulate these nodes programmatically - add classes, change text, move things around - without doing string manipulation on raw HTML.

An AST does the same thing for code. When you parse this:

double(x) =>
    x * 2

myLabel = "Call double()"
Enter fullscreen mode Exit fullscreen mode

You get a tree like:

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()"
Enter fullscreen mode Exit fullscreen mode

Now renaming becomes surgical. You say: "Find all nodes of type FunctionDefinition where name equals double. Rename those. Find all nodes of type FunctionCall where the function is double. Rename those too. Leave StringLiteral nodes 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, see if TradingView accepts the output.

I created two tiny files:

math_utils.pine:

//@version=5
// @export double

double(x) =>
    x * 2
Enter fullscreen mode Exit fullscreen mode

main.pine:

//@version=5
// @import { double } from "./math_utils.pine"

indicator("Test", overlay=true)

result = double(close)
plot(result)
Enter fullscreen mode Exit fullscreen mode

Then wrote some Python to:

  1. Parse both files with pynescript
  2. Find the function definition for double in the AST
  3. Rename it to __math_utils__double
  4. Find references to double in main.pine's AST
  5. Update them to __math_utils__double
  6. Merge and unparse

I ran it. Out came:

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

__math_utils__double(x) =>
    x * 2

result = __math_utils__double(close)
plot(result)
Enter fullscreen mode Exit fullscreen mode

I pasted it into TradingView.

It worked.

I sat there for a second, 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 --copy flag 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
Enter fullscreen mode Exit fullscreen mode

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. 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 bundler for a language that has neither.

PineCone doesn't add features to PineScript. It doesn't make your indicators faster. It doesn't connect to your broker. It just 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.

Anyway, 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 Frontend Developer. You can find more of her work here.

Top comments (0)