I Built a Module System for a Language That Doesn't Have One

Published: (December 27, 2025 at 04:26 PM EST)
9 min read
Source: Dev.to

Claudia Nadalin

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:

  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 the indicator script.
  7. Update the import statement with the new version.
  8. Save the script.
  9. Push the changes to Git.
  10. Copy‑paste the updated script back into TradingView’s editor.
  11. 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 doubleCheck was 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 double is a distinct node.
  • The string literal "Call double() for twice the value" is another node.
  • The variable doubleCheck is 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:

  1. Parse every source file into an AST.
  2. Walk the tree, collecting all exported identifiers.
  3. Generate unique, namespaced identifiers (e.g., __utils_math__double).
  4. Replace only the identifier nodes that need renaming.
  5. 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.
  • pynescript provides 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 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, 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:

  1. Parse both files with pynescript.
  2. Find the function definition for double in the AST and rename it to __math_utils__double.
  3. Find references to double in main.pine’s AST and update them to __math_utils__double.
  4. 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 --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

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/).
Back to Blog

Related posts

Read more »

NgRx Toolkit v21

NgRx Toolkit v21 The NgRx Toolkit originates from a time when the SignalStore was not even marked stable. In those early days, community requests for various f...