Skip to content

Commit cb0a37a

Browse files
committed
Add Pine plot count limit validation
1 parent dde96c6 commit cb0a37a

2 files changed

Lines changed: 167 additions & 0 deletions

File tree

src/pinescript_validator/ast_validator.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
}
2121
)
2222

23+
PLOT_COUNT_LIMIT = 64
24+
2325

2426
@dataclass(slots=True)
2527
class Symbol:
@@ -71,11 +73,15 @@ def __init__(self) -> None:
7173
self.errors: list[Diagnostic] = []
7274
self.function_declarations: dict[str, AST.FunctionDeclaration] = {}
7375
self.consistency_sensitive_functions: set[str] = set(CONSISTENCY_SENSITIVE_BUILTINS)
76+
self.plot_count = 0
77+
self.plot_count_reported = False
7478

7579
def validate(self, program: AST.Program) -> list[Diagnostic]:
7680
self.errors = []
7781
self.function_declarations = {}
7882
self.consistency_sensitive_functions = set(CONSISTENCY_SENSITIVE_BUILTINS)
83+
self.plot_count = 0
84+
self.plot_count_reported = False
7985
self.collect_function_declarations(program.body)
8086
self.mark_consistency_sensitive_functions()
8187
global_scope = Scope()
@@ -418,6 +424,7 @@ def validate_expression(self, expression: AST.Expression, scope: Scope, conditio
418424
if spec is not None:
419425
self.validate_call_signature(expression, resolved_function_name, spec)
420426
self.validate_special_cases(expression, resolved_function_name)
427+
self.track_plot_count(expression, resolved_function_name, scope)
421428
return
422429

423430
if isinstance(expression, AST.BinaryExpression):
@@ -860,6 +867,129 @@ def validate_line_new_positional_types(self, call: AST.CallExpression) -> None:
860867
)
861868
)
862869

870+
def track_plot_count(self, call: AST.CallExpression, function_name: str, scope: Scope) -> None:
871+
plot_count = self.estimate_plot_count(call, function_name, scope)
872+
if plot_count <= 0:
873+
return
874+
875+
self.plot_count += plot_count
876+
if self.plot_count_reported or self.plot_count <= PLOT_COUNT_LIMIT:
877+
return
878+
879+
self.plot_count_reported = True
880+
self.errors.append(
881+
Diagnostic(
882+
line=call.line,
883+
column=call.column,
884+
length=len(function_name),
885+
message=(
886+
f'Estimated plot count is {self.plot_count}, which exceeds the Pine Script limit of {PLOT_COUNT_LIMIT}. '
887+
"Reduce plot-producing calls or simplify dynamic color usage."
888+
),
889+
severity=Severity.ERROR,
890+
source="ast",
891+
)
892+
)
893+
894+
def estimate_plot_count(self, call: AST.CallExpression, function_name: str, scope: Scope) -> int:
895+
if function_name == "plot":
896+
return 1 + self.dynamic_plot_argument_count(call, scope, ("color",))
897+
if function_name == "plotarrow":
898+
return 1 + self.dynamic_plot_argument_count(call, scope, ("colorup", "colordown"))
899+
if function_name == "plotbar":
900+
return 4 + self.dynamic_plot_argument_count(call, scope, ("color",))
901+
if function_name == "plotcandle":
902+
return 4 + self.dynamic_plot_argument_count(call, scope, ("color", "wickcolor", "bordercolor"))
903+
if function_name in {"plotchar", "plotshape"}:
904+
return 1 + self.dynamic_plot_argument_count(call, scope, ("color", "textcolor"))
905+
if function_name in {"alertcondition", "bgcolor", "barcolor"}:
906+
return 1
907+
if function_name == "fill":
908+
color_argument = self.get_argument_value(call, "color", 2)
909+
return 1 if color_argument is not None and not self.is_const_plot_expression(color_argument, scope) else 0
910+
return 0
911+
912+
def dynamic_plot_argument_count(
913+
self,
914+
call: AST.CallExpression,
915+
scope: Scope,
916+
parameter_names: tuple[str, ...],
917+
) -> int:
918+
return sum(
919+
1
920+
for index, parameter_name in enumerate(parameter_names)
921+
if (
922+
argument := self.get_argument_value(call, parameter_name, index + 1)
923+
) is not None
924+
and not self.is_const_plot_expression(argument, scope)
925+
)
926+
927+
def get_argument_value(self, call: AST.CallExpression, name: str, positional_index: int) -> AST.Expression | None:
928+
for argument in call.arguments:
929+
if argument.name == name:
930+
return argument.value
931+
932+
positional_arguments = [argument.value for argument in call.arguments if argument.name is None]
933+
if positional_index < len(positional_arguments):
934+
return positional_arguments[positional_index]
935+
return None
936+
937+
def is_const_plot_expression(self, expression: AST.Expression, scope: Scope) -> bool:
938+
if isinstance(expression, AST.Literal):
939+
return True
940+
941+
if isinstance(expression, AST.Identifier):
942+
symbol = self.lookup_accessible_symbol(scope, expression.name, expression.line, expression.column)
943+
if symbol is not None and symbol.kind == "variable":
944+
return False
945+
return expression.name in self.builtins.standalone_variables
946+
947+
if isinstance(expression, AST.MemberExpression):
948+
return isinstance(expression.object, AST.Identifier) and expression.object.name == "color"
949+
950+
if isinstance(expression, AST.UnaryExpression):
951+
return self.is_const_plot_expression(expression.argument, scope)
952+
953+
if isinstance(expression, AST.BinaryExpression):
954+
return self.is_const_plot_expression(expression.left, scope) and self.is_const_plot_expression(expression.right, scope)
955+
956+
if isinstance(expression, AST.TernaryExpression):
957+
return (
958+
self.is_const_plot_expression(expression.condition, scope)
959+
and self.is_const_plot_expression(expression.consequent, scope)
960+
and self.is_const_plot_expression(expression.alternate, scope)
961+
)
962+
963+
if isinstance(expression, AST.IfExpression):
964+
return (
965+
self.is_const_plot_expression(expression.condition, scope)
966+
and self.is_const_plot_expression(expression.consequent, scope)
967+
and self.is_const_plot_expression(expression.alternate, scope)
968+
)
969+
970+
if isinstance(expression, AST.CallExpression):
971+
function_name = self.extract_function_name(expression.callee)
972+
if function_name in {"color.new", "color.rgb"}:
973+
return all(self.is_const_plot_expression(argument.value, scope) for argument in expression.arguments)
974+
return False
975+
976+
if isinstance(expression, AST.ArrayExpression):
977+
return all(self.is_const_plot_expression(element, scope) for element in expression.elements)
978+
979+
if isinstance(expression, AST.IndexExpression):
980+
return self.is_const_plot_expression(expression.object, scope) and self.is_const_plot_expression(expression.index, scope)
981+
982+
if isinstance(expression, AST.SwitchExpression):
983+
if expression.expression is not None and not self.is_const_plot_expression(expression.expression, scope):
984+
return False
985+
return all(
986+
(case.condition is None or self.is_const_plot_expression(case.condition, scope))
987+
and self.is_const_plot_expression(case.value, scope)
988+
for case in expression.cases
989+
)
990+
991+
return False
992+
863993
def describe_argument_type(self, expression: AST.Expression) -> tuple[str | None, str, str]:
864994
if isinstance(expression, AST.Literal):
865995
if isinstance(expression.value, bool):

tests/test_validator.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,43 @@ def test_indicator_calc_bars_count_must_be_positive(self) -> None:
8686
)
8787
)
8888

89+
def test_plot_count_limit_allows_up_to_64(self) -> None:
90+
code = 'indicator("x")\n' + "\n".join(f"plot(close, title = \"P{i}\")" for i in range(64))
91+
diagnostics = self.validator.validate_text(code)
92+
self.assertFalse(any("Estimated plot count is" in diagnostic.message for diagnostic in diagnostics))
93+
94+
def test_plot_count_limit_reports_when_exceeded(self) -> None:
95+
code = 'indicator("x")\n' + "\n".join(f"plot(close, title = \"P{i}\")" for i in range(65))
96+
diagnostics = self.validator.validate_text(code)
97+
self.assertTrue(
98+
any(
99+
"Estimated plot count is 65, which exceeds the Pine Script limit of 64" in diagnostic.message
100+
for diagnostic in diagnostics
101+
)
102+
)
103+
104+
def test_dynamic_plot_color_counts_as_additional_plot(self) -> None:
105+
code = """
106+
indicator("x")
107+
dynamicColor = close > open ? color.green : color.red
108+
""" + "\n".join(f'plot(close, title = "P{i}", color = dynamicColor)' for i in range(33))
109+
diagnostics = self.validator.validate_text(code)
110+
self.assertTrue(
111+
any(
112+
"Estimated plot count is 66, which exceeds the Pine Script limit of 64" in diagnostic.message
113+
for diagnostic in diagnostics
114+
)
115+
)
116+
117+
def test_fill_with_const_color_does_not_consume_plot_count(self) -> None:
118+
code = """
119+
indicator("x")
120+
p1 = plot(close)
121+
p2 = plot(open)
122+
""" + "\n".join(f'fill(p1, p2, color = color.green)' for _ in range(80))
123+
diagnostics = self.validator.validate_text(code)
124+
self.assertFalse(any("Estimated plot count is" in diagnostic.message for diagnostic in diagnostics))
125+
89126
def test_indicator_negative_max_bars_back_is_rejected(self) -> None:
90127
diagnostics = self.validator.validate_text('indicator("x", max_bars_back = -1)')
91128
self.assertTrue(

0 commit comments

Comments
 (0)