DEV Community

Cover image for There is no official grammar for Pine Script, so I wrote a small one
Khushal Jain
Khushal Jain

Posted on

There is no official grammar for Pine Script, so I wrote a small one

If you have ever tried to lint, format, or diff TradingView Pine Script programmatically, you have hit the same
wall: there is no official grammar. TradingView does not publish or maintain a tree-sitter grammar or an
ANTLR grammar for Pine Script. Every parser you can find is third-party, and third-party grammars for a
language that changes with each Pine version go stale quietly - they just stop matching real-world code.

I could not find anything to put in a CI pipeline for my indicators, so I wrote
PineSprout (pip install pinesprout, Python 3.12+, Linux/macOS/Windows,
MIT). This post is about the parser decision and the linter rules, which are the two parts worth stealing.

The whole toolkit in one CLI

pinesprout generate "<prompt>"     # Claude-powered, from natural language
pinesprout template new ema-cross-indicator --title "My EMA Cross" -o my_indicator.pine
pinesprout lint my_indicator.pine
pinesprout format my_indicator.pine --write
pinesprout analyze my_indicator.pine
pinesprout optimize my_indicator.pine
pinesprout explain my_indicator.pine
pinesprout upgrade legacy_strategy.pine --to 6 --write
pinesprout docs my_indicator.pine --format markdown -o DOCS.md
pinesprout readme my_indicator.pine -o README.md
pinesprout report my_indicator.pine
Enter fullscreen mode Exit fullscreen mode

Every command supports --json. That is not a nicety - it is the reason the tool is usable outside a terminal.
Machine-readable output means the linter can gate a PR, the formatter can run in a pre-commit hook, and the
upgrader can be scripted across a folder of old v4 scripts.

Why a hand-rolled lexer instead of tree-sitter

Two options existed: depend on an unofficial grammar, or write a small one. I wrote the small one.

pinesprout/core/
├── lexer.py        # regex-based Pine Script tokenizer
├── parser.py       # lightweight AST builder
├── ast_nodes.py    # typed AST node definitions
├── formatter.py    # deterministic code formatter
├── linter.py       # static analysis rule engine
├── analyzer.py     # structural / complexity analysis
├── optimizer.py    # refactor suggestions
├── upgrader.py     # v4 -> v5 -> v6 migrations
└── version_rules.py# deprecated symbols & migration rules
Enter fullscreen mode Exit fullscreen mode

The deliberate constraint: the output shape mirrors a tree-sitter concrete syntax tree - typed nodes,
line/column spans, a children list. Nothing downstream (formatter, linter, analyzer, optimizer, upgrader)
knows or cares how the tree got built.

That buys two things:

  1. It handles real-world Pine's edge cases without waiting on a third party's release cycle.
  2. If TradingView ever ships an official grammar, it drops in behind parser.py and no consumer changes.

A regex tokenizer is not "proper" compiler design, and I am fine with that. It is ~two files, dependency-free,
well tested, and it removes the only thing in the project I could not control.

The lint rule that pays for itself: repaint risk

Pine's most expensive class of bug is not a syntax error, it is an indicator that looks brilliant historically
and lies in real time. Repainting. So the linter has a dedicated category (repaint-risk) driven by an actual
list in version_rules.py:

REPAINT_PRONE_FUNCTIONS = {
    "request.security",
    "security",
    "ta.valuewhen",
    "ta.barssince",
    "input.source",
}
Enter fullscreen mode Exit fullscreen mode

request.security() on a higher timeframe without a lookahead guard, ta.valuewhen() reading a value that has
not settled on the real-time bar - when one of these shows up in a plot path, the linter flags the line and
explains that the script can repaint historical values on lower timeframes, with a separate note for the case
where the repainting is intentional. There is a matching PERFORMANCE_SENSITIVE_FUNCTIONS set (ta.sma,
ta.ema, ta.rsi, ta.atr, ta.stoch, ta.macd, ta.highest, ta.lowest) because calling a rolling
series inside a loop is the second most common way to make a script crawl.

Alongside those, the rule set covers unused variables, deprecated syntax, and general performance issues - the
four things that actually make an indicator script unmaintainable. Because the rules are a rule engine, not a
pile of regexes, you can add your house style without touching the core:

# .pinesprout/plugins/my_rules.py
from pinesprout.plugins.base import PineSproutPlugin, LintRule
from pinesprout.core.linter import LintIssue, Severity, LintCategory

class NoMagicColorsRule(LintRule):
    name = "no-magic-colors"

    def check(self, source: str) -> list[LintIssue]:
        issues = []
        for i, line in enumerate(source.splitlines(), start=1):
            if "color=color.red" in line:
                issues.append(LintIssue(
                    line=i, severity=Severity.INFO,
                    category=LintCategory.STYLE,
                    message="Avoid hard-coded colors; expose as an input.",
                ))
        return issues

class MyPlugin(PineSproutPlugin):
    name = "my-org-rules"
    version = "1.0.0"

    def lint_rules(self):
        return [NoMagicColorsRule()]

PLUGIN = MyPlugin()
Enter fullscreen mode Exit fullscreen mode

Plugins load from a local .pinesprout/plugins/ folder or through a pinesprout.plugins entry point, so a team
can version its own rule pack as a package.

Upgrading v4 → v5 → v6

Pine's version migrations are mostly mechanical renames, and they live as data rather than as if-statements in
the upgrader:

# `study()` was renamed to `indicator()` in Pine v5.
# `security()` moved to the `request.*` namespace in v5.   -> request.security(
# `strategy.performance` -> strategy.closedtrades.* accessors introduced in v6
Enter fullscreen mode Exit fullscreen mode

A regex rewrite table plus typed DeprecatedSymbol entries is honestly enough for a language whose breaking
changes are namespace moves. upgrade --to 6 --write applies them, and it is the command I reach for most,
because a v4 script you still believe in is worth more than a rewrite you will never start.

The Studio, if you prefer a browser

streamlit_app.py wraps the CLI: describe a strategy in plain English (AI-generated), pick a template, or paste
your own script, then lint / format / analyze / optimize / explain / upgrade / document it with one-click
downloads.

The bit that is genuinely fun is the Template Builder: 25 indicators (trend, momentum, volatility, volume) ×
4 signal patterns × 3 risk-management styles, with every length, threshold and stop-loss % adjustable -
thousands of distinct scripts, all of them lint-clean because they come out of the same formatter the CLI uses.
There is also a live scrolling ticker (NIFTY 50, SENSEX, BANK NIFTY, commodities via yfinance) and an NSE-style
index watch plus a TradingView global heatmap for the market-browsing side.

pip install -e ".[dev]"
streamlit run streamlit_app.py
Enter fullscreen mode Exit fullscreen mode

Guardrails worth stating

PineSprout only ever operates on Pine Script source you provide. It does not fetch, decompile, or bypass
protection on TradingView's invite-only or protected scripts. Anyone building tooling in this space should say
that out loud in the README rather than let people assume - it is the difference between a useful tool and a
ToS incident.

Dev loop is pytest, ruff check ., mypy pinesprout, and CI runs on every push.

What's the buggiest pattern in your Pine scripts - repainting on historical bars, or security() look-ahead?
And if you maintain a TradingView repo, do you run any lint/format gate on .pine files today? The toolkit is
85599/pinesprout on PyPI and GitHub, MIT - the parser is the part I would
most like someone to disagree with.

Top comments (0)