Skip to content

Commit d6431b8

Browse files
committed
Improve Pine v6 validation and add doc-driven tests
1 parent 747b6c5 commit d6431b8

2 files changed

Lines changed: 227 additions & 9 deletions

File tree

src/pinescript_validator/ast_validator.py

Lines changed: 179 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections import Counter
34
from dataclasses import dataclass, field
45

56
from . import ast as AST
@@ -26,6 +27,7 @@ class Symbol:
2627
line: int
2728
column: int
2829
kind: str
30+
type_name: str | None = None
2931
used: bool = False
3032

3133

@@ -121,17 +123,32 @@ def collect_direct_declarations(self, statements: list[AST.Statement], scope: Sc
121123
if statement.alias:
122124
self.define_symbol(scope, statement.alias, statement.line, statement.column, "namespace")
123125
elif isinstance(statement, AST.VariableDeclaration):
124-
self.define_symbol(scope, statement.name, statement.name_line, statement.name_column, "variable")
126+
self.define_symbol(
127+
scope,
128+
statement.name,
129+
statement.name_line,
130+
statement.name_column,
131+
"variable",
132+
self.extract_declared_type_name(statement.type_annotation),
133+
)
125134
elif isinstance(statement, AST.DestructuringAssignment):
126135
for variable in statement.variables:
127136
self.define_symbol(scope, variable.name, variable.line, variable.column, "variable")
128137

129-
def define_symbol(self, scope: Scope, name: str, line: int, column: int, kind: str) -> None:
138+
def define_symbol(
139+
self,
140+
scope: Scope,
141+
name: str,
142+
line: int,
143+
column: int,
144+
kind: str,
145+
type_name: str | None = None,
146+
) -> None:
130147
if name == "_":
131148
return
132149
existing = scope.symbols.get(name)
133150
if existing is None:
134-
scope.symbols[name] = Symbol(name=name, line=line, column=column, kind=kind)
151+
scope.symbols[name] = Symbol(name=name, line=line, column=column, kind=kind, type_name=type_name)
135152
return
136153

137154
if existing.kind == kind:
@@ -173,6 +190,8 @@ def validate_statement(self, statement: AST.Statement, scope: Scope, conditional
173190
self.validate_type_annotation(statement.type_annotation, scope, statement.line, statement.column)
174191
if statement.init is not None:
175192
self.validate_expression(statement.init, scope, conditional_context)
193+
if self.is_bool_type_annotation(statement.type_annotation) and self.is_na_literal(statement.init):
194+
self.bool_na_error(statement.init.line, statement.init.column)
176195
return
177196

178197
if isinstance(statement, AST.DestructuringAssignment):
@@ -233,6 +252,8 @@ def validate_statement(self, statement: AST.Statement, scope: Scope, conditional
233252
if field.type_annotation is not None:
234253
self.validate_type_annotation(field.type_annotation, scope, field.line, field.column)
235254
if field.default_value is not None:
255+
if self.is_bool_type_annotation(field.type_annotation) and self.is_na_literal(field.default_value):
256+
self.bool_na_error(field.default_value.line, field.default_value.column)
236257
if not self.is_valid_type_field_default(field.default_value):
237258
self.errors.append(
238259
Diagnostic(
@@ -258,15 +279,24 @@ def validate_statement(self, statement: AST.Statement, scope: Scope, conditional
258279
for param in statement.params:
259280
if param.type_annotation is not None:
260281
self.validate_type_annotation(param.type_annotation, scope, param.line, param.column)
261-
self.define_symbol(function_scope, param.name, param.line, param.column, "parameter")
282+
self.define_symbol(
283+
function_scope,
284+
param.name,
285+
param.line,
286+
param.column,
287+
"parameter",
288+
self.extract_declared_type_name(param.type_annotation),
289+
)
262290
if param.default_value is not None:
263291
self.validate_expression(param.default_value, function_scope)
292+
if self.is_bool_type_annotation(param.type_annotation) and self.is_na_literal(param.default_value):
293+
self.bool_na_error(param.default_value.line, param.default_value.column)
264294
self.collect_direct_declarations(statement.body, function_scope)
265295
self.validate_block(statement.body, function_scope)
266296
return
267297

268298
if isinstance(statement, AST.IfStatement):
269-
self.validate_expression(statement.condition, scope, conditional_context)
299+
self.validate_boolean_condition_expression(statement.condition, scope, conditional_context)
270300
consequent_scope = Scope(parent=scope)
271301
self.collect_direct_declarations(statement.consequent, consequent_scope)
272302
self.validate_block(statement.consequent, consequent_scope, conditional_context or "scope")
@@ -296,7 +326,7 @@ def validate_statement(self, statement: AST.Statement, scope: Scope, conditional
296326
return
297327

298328
if isinstance(statement, AST.WhileStatement):
299-
self.validate_expression(statement.condition, scope, conditional_context)
329+
self.validate_boolean_condition_expression(statement.condition, scope, conditional_context)
300330
loop_scope = Scope(parent=scope)
301331
self.collect_direct_declarations(statement.body, loop_scope)
302332
self.validate_block(statement.body, loop_scope, conditional_context)
@@ -400,14 +430,14 @@ def validate_expression(self, expression: AST.Expression, scope: Scope, conditio
400430
return
401431

402432
if isinstance(expression, AST.TernaryExpression):
403-
self.validate_expression(expression.condition, scope, conditional_context)
433+
self.validate_boolean_condition_expression(expression.condition, scope, conditional_context)
404434
ternary_context = conditional_context or "ternary"
405435
self.validate_expression(expression.consequent, scope, ternary_context)
406436
self.validate_expression(expression.alternate, scope, ternary_context)
407437
return
408438

409439
if isinstance(expression, AST.IfExpression):
410-
self.validate_expression(expression.condition, scope, conditional_context)
440+
self.validate_boolean_condition_expression(expression.condition, scope, conditional_context)
411441
ternary_context = conditional_context or "ternary"
412442
self.validate_expression(expression.consequent, scope, ternary_context)
413443
self.validate_expression(expression.alternate, scope, ternary_context)
@@ -617,7 +647,23 @@ def resolve_generic_function_name(self, function_name: str) -> str:
617647

618648
def validate_call_signature(self, call: AST.CallExpression, function_name: str, spec: FunctionSpec) -> None:
619649
positional_args = [arg for arg in call.arguments if arg.name is None]
620-
provided_named = {arg.name for arg in call.arguments if arg.name is not None}
650+
named_arguments = [arg for arg in call.arguments if arg.name is not None]
651+
duplicate_named = [name for name, count in Counter(arg.name for arg in named_arguments).items() if count > 1]
652+
if duplicate_named:
653+
for name in sorted(duplicate_named):
654+
self.errors.append(
655+
Diagnostic(
656+
line=call.line,
657+
column=call.column,
658+
length=len(name),
659+
message=f'Function call cannot include repeated argument for parameter "{name}"',
660+
severity=Severity.ERROR,
661+
source="ast",
662+
)
663+
)
664+
return
665+
666+
provided_named = {arg.name for arg in named_arguments}
621667
if function_name.startswith("input."):
622668
provided_named = {
623669
name
@@ -967,6 +1013,118 @@ def extract_integer_literal(self, expression: AST.Expression) -> tuple[int, str,
9671013
return signed_value, f"{expression.operator}{raw_value}", expression.line, expression.column
9681014
return None
9691015

1016+
def validate_boolean_condition_expression(
1017+
self,
1018+
expression: AST.Expression,
1019+
scope: Scope,
1020+
conditional_context: str | None = None,
1021+
) -> None:
1022+
self.validate_expression(expression, scope, conditional_context)
1023+
inferred = self.infer_boolean_expression(expression, scope)
1024+
if inferred is False:
1025+
self.errors.append(
1026+
Diagnostic(
1027+
line=expression.line,
1028+
column=expression.column,
1029+
length=max(1, len(self.describe_expression(expression))),
1030+
message='Condition expression must be of type "bool" in Pine Script v6',
1031+
severity=Severity.ERROR,
1032+
source="ast",
1033+
)
1034+
)
1035+
1036+
def infer_boolean_expression(self, expression: AST.Expression, scope: Scope) -> bool | None:
1037+
if isinstance(expression, AST.Literal):
1038+
if isinstance(expression.value, bool):
1039+
return True
1040+
return False
1041+
1042+
if isinstance(expression, AST.Identifier):
1043+
symbol = self.lookup_accessible_symbol(scope, expression.name, expression.line, expression.column)
1044+
if symbol is not None and symbol.type_name == "bool":
1045+
return True
1046+
if expression.name in self.builtins.standalone_variables:
1047+
return False
1048+
return None
1049+
1050+
if isinstance(expression, AST.MemberExpression):
1051+
if isinstance(expression.object, AST.Identifier):
1052+
if expression.object.name == "barstate":
1053+
return True
1054+
symbol = self.lookup_accessible_symbol(scope, expression.object.name, expression.line, expression.column)
1055+
if symbol is not None and symbol.type_name == "bool":
1056+
return True
1057+
return None
1058+
1059+
if isinstance(expression, AST.CallExpression):
1060+
function_name = self.extract_function_name(expression.callee)
1061+
if function_name in {"ta.cross", "ta.crossover", "ta.crossunder"}:
1062+
return True
1063+
return None
1064+
1065+
if isinstance(expression, AST.UnaryExpression):
1066+
if expression.operator == "not":
1067+
return True
1068+
return False
1069+
1070+
if isinstance(expression, AST.BinaryExpression):
1071+
if expression.operator in {"==", "!=", ">", "<", ">=", "<=", "and", "or"}:
1072+
return True
1073+
return False
1074+
1075+
if isinstance(expression, AST.TernaryExpression):
1076+
consequent = self.infer_boolean_expression(expression.consequent, scope)
1077+
alternate = self.infer_boolean_expression(expression.alternate, scope)
1078+
if consequent is True and alternate is True:
1079+
return True
1080+
if consequent is False or alternate is False:
1081+
return False
1082+
return None
1083+
1084+
if isinstance(expression, AST.IfExpression):
1085+
consequent = self.infer_boolean_expression(expression.consequent, scope)
1086+
alternate = self.infer_boolean_expression(expression.alternate, scope)
1087+
if consequent is True and alternate is True:
1088+
return True
1089+
if consequent is False or alternate is False:
1090+
return False
1091+
return None
1092+
1093+
return None
1094+
1095+
@staticmethod
1096+
def extract_declared_type_name(type_annotation: AST.TypeAnnotation | None) -> str | None:
1097+
if type_annotation is None:
1098+
return None
1099+
return type_annotation.name
1100+
1101+
def is_bool_type_annotation(self, type_annotation: AST.TypeAnnotation | None) -> bool:
1102+
type_name = self.extract_declared_type_name(type_annotation)
1103+
if type_name is None:
1104+
return False
1105+
return self.strip_array_suffix(type_name) == "bool"
1106+
1107+
@staticmethod
1108+
def is_na_literal(expression: AST.Expression) -> bool:
1109+
return isinstance(expression, AST.Literal) and expression.value == "na"
1110+
1111+
def describe_expression(self, expression: AST.Expression) -> str:
1112+
if isinstance(expression, AST.Identifier):
1113+
return expression.name
1114+
if isinstance(expression, AST.Literal):
1115+
return expression.raw
1116+
if isinstance(expression, AST.MemberExpression):
1117+
parent = self.describe_expression(expression.object)
1118+
return f"{parent}.{expression.property.name}"
1119+
if isinstance(expression, AST.CallExpression):
1120+
function_name = self.extract_function_name(expression.callee)
1121+
return function_name or "call"
1122+
if isinstance(expression, AST.BinaryExpression):
1123+
return expression.operator
1124+
if isinstance(expression, AST.UnaryExpression):
1125+
return expression.operator
1126+
return "expression"
1127+
9701128
def mutable_argument_error(self, name: str, line: int, column: int) -> None:
9711129
self.errors.append(
9721130
Diagnostic(
@@ -979,6 +1137,18 @@ def mutable_argument_error(self, name: str, line: int, column: int) -> None:
9791137
)
9801138
)
9811139

1140+
def bool_na_error(self, line: int, column: int) -> None:
1141+
self.errors.append(
1142+
Diagnostic(
1143+
line=line,
1144+
column=column,
1145+
length=2,
1146+
message='Cannot assign "na" to a "bool" value in Pine Script v6',
1147+
severity=Severity.ERROR,
1148+
source="ast",
1149+
)
1150+
)
1151+
9821152
def lookup_accessible_symbol(
9831153
self,
9841154
scope: Scope,

tests/test_pine_v6_docs_cases.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
5+
from pinescript_validator.diagnostics import Severity
6+
from pinescript_validator.validator import PineScriptValidator
7+
8+
9+
class PineV6DocsCasesTests(unittest.TestCase):
10+
def setUp(self) -> None:
11+
self.validator = PineScriptValidator()
12+
13+
def test_strategy_when_parameter_is_rejected(self) -> None:
14+
code = """
15+
strategy("Conditional strategy", overlay = true)
16+
longCondition = ta.crossover(ta.sma(close, 14), ta.sma(close, 28))
17+
strategy.entry("My Long Entry Id", strategy.long, when = longCondition)
18+
"""
19+
diagnostics = self.validator.validate_text(code)
20+
self.assertTrue(any("Invalid parameter 'when'" in d.message for d in diagnostics))
21+
22+
def test_request_security_inside_local_scope_is_accepted(self) -> None:
23+
code = """
24+
indicator("Dynamic Request In Local Scope")
25+
if bar_index > 10
26+
x = request.security("NASDAQ:AAPL", "D", close)
27+
"""
28+
diagnostics = self.validator.validate_text(code)
29+
self.assertFalse(any(d.severity == Severity.ERROR for d in diagnostics))
30+
31+
def test_numeric_values_must_not_be_implicitly_cast_to_bool(self) -> None:
32+
code = "color expr = bar_index ? color.green : color.red"
33+
diagnostics = self.validator.validate_text(code)
34+
self.assertTrue(any('Condition expression must be of type "bool"' in d.message for d in diagnostics))
35+
36+
def test_function_calls_must_reject_duplicate_named_arguments(self) -> None:
37+
code = "plot(close, linewidth = 1, linewidth = 2)"
38+
diagnostics = self.validator.validate_text(code)
39+
self.assertTrue(any('repeated argument for parameter "linewidth"' in d.message for d in diagnostics))
40+
41+
def test_bool_values_cannot_be_na(self) -> None:
42+
code = "bool flag = na"
43+
diagnostics = self.validator.validate_text(code)
44+
self.assertTrue(any('Cannot assign "na" to a "bool" value in Pine Script v6' in d.message for d in diagnostics))
45+
46+
47+
if __name__ == "__main__":
48+
unittest.main()

0 commit comments

Comments
 (0)