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
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
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:
- It handles real-world Pine's edge cases without waiting on a third party's release cycle.
- If TradingView ever ships an official grammar, it drops in behind
parser.pyand 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",
}
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()
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
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
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)