1pub(crate) mod lex;
15mod lint;
16pub mod ops;
17mod parse;
18mod print;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ParseError {
23 pub line: usize,
24 pub col: usize,
25 pub message: String,
26}
27
28impl std::fmt::Display for ParseError {
29 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30 write!(f, "{}:{}: {}", self.line, self.col, self.message)
31 }
32}
33
34impl std::error::Error for ParseError {}
35
36pub use lint::{lint, Lint};
37pub use parse::{parse, parse_analyzed, Analysis};
38pub use print::format;
39
40#[cfg(test)]
41mod tests {
42 use super::*;
43
44 #[test]
45 fn parse_error_displays_with_position() {
46 let e = ParseError {
47 line: 3,
48 col: 7,
49 message: "bad token".into(),
50 };
51 assert_eq!(e.to_string(), "3:7: bad token");
52 }
53}
54
55#[cfg(test)]
56mod roundtrip {
57 use super::{format, parse};
58 use crate::spec::Expr;
59 use serde_json::{json, Value};
60
61 fn corpus() -> Vec<Value> {
62 vec![
63 json!({"op":"Gt","l":{"op":"Data","name":"close"},
64 "r":{"op":"Average","of":{"op":"Data","name":"close"},"n":2}}),
65 json!({"op":"Rank","of":{"op":"Neg","of":{"op":"Data","name":"pe"}}}),
66 json!({"op":"Vwap","high":{"op":"Data","name":"high"},"low":{"op":"Data","name":"low"},
67 "close":{"op":"Data","name":"close"},"volume":{"op":"Data","name":"volume"},"n":20}),
68 json!({"op":"Mask","of":{"op":"IsLargest","of":{"op":"Data","name":"x"},"n":30},
69 "by":{"op":"Gt","l":{"op":"Data","name":"market_cap"},"r":{"op":"Const","value":500000000}}}),
70 json!({"op":"Sustain","of":{"op":"Data","name":"x"},"nwindow":3,"nsatisfy":2}),
71 json!({"op":"Rebalance","of":{"op":"Data","name":"x"},"freq":"ME"}),
72 json!({"op":"Neutralize","of":{"op":"Data","name":"x"},
73 "by":[{"op":"Data","name":"pe"},{"op":"Data","name":"market_cap"}]}),
74 json!({"op":"NeutralizeIndustry","of":{"op":"Data","name":"x"}}),
75 json!({"op":"HoldUntil",
76 "entry":{"op":"Gt","l":{"op":"Data","name":"close"},"r":{"op":"Const","value":1}},
77 "exit":{"op":"Lt","l":{"op":"Data","name":"close"},"r":{"op":"Const","value":1}},
78 "nstocks_limit":1}),
79 json!({"op":"Add",
80 "l":{"op":"Mul","l":{"op":"Const","value":2},"r":{"op":"Data","name":"x"}},
81 "r":{"op":"Data","name":"y"}}),
82 json!({"op":"IndustryRank","of":{"op":"Data","name":"x"},
83 "categories":["tech","fin"]}),
84 ]
85 }
86
87 #[test]
88 fn corpus_is_valid_expr() {
89 for tree in corpus() {
90 let r: Result<Expr, _> = serde_json::from_value(tree.clone());
91 assert!(r.is_ok(), "not a valid Expr: {tree}");
92 }
93 }
94
95 #[test]
96 fn parse_of_print_is_identity() {
97 for tree in corpus() {
98 let text = format(&tree);
99 let back = parse(&text).unwrap_or_else(|e| panic!("reparse failed for `{text}`: {e}"));
100 assert_eq!(back, tree, "round-trip mismatch via `{text}`");
101 }
102 }
103
104 #[test]
108 fn print_is_idempotent() {
109 for tree in corpus() {
110 let once = format(&tree);
111 let reparsed = parse(&once).unwrap();
112 assert_eq!(
113 format(&reparsed),
114 once,
115 "format not idempotent for `{once}`"
116 );
117 }
118 }
119}