use std::path::PathBuf;
use lemon::meta;
use serde_json::{json, Map, Value};
fn field_schema(kind: &str) -> Value {
match kind {
"expr" => json!({ "$ref": "#/$defs/Expr" }),
"number" => json!({ "type": "number" }),
"bool" => json!({ "type": "boolean" }),
"string" => json!({ "type": "string" }),
"list" => json!({ "type": "array", "items": { "$ref": "#/$defs/Expr" } }),
"list-string" => json!({ "type": "array", "items": { "type": "string" } }),
other => panic!("unknown field kind `{other}`"),
}
}
fn build_catalog() -> Value {
let mut ops: Vec<Value> = Vec::new();
ops.push(json!({
"name": "Data",
"tag": "Data",
"form": "leaf",
"description": "A raw input series by name (e.g. close, pe, revenue_growth). In DSL, a bare identifier lowers to this.",
"args": [
{ "name": "name", "kind": "string", "required": true }
]
}));
ops.push(json!({
"name": "Const",
"tag": "Const",
"form": "leaf",
"description": "A constant scalar value, broadcast across the panel. In DSL, a bare number operand lowers to this.",
"args": [
{ "name": "value", "kind": "number", "required": true }
]
}));
for op in meta::function_ops() {
let args: Vec<Value> = op
.fields
.iter()
.map(|f| {
let mut m = Map::new();
m.insert("name".into(), json!(f.name));
m.insert("kind".into(), json!(f.kind));
m.insert("required".into(), json!(f.required));
if let Some(d) = &f.default {
m.insert("default".into(), d.clone());
}
Value::Object(m)
})
.collect();
ops.push(json!({
"name": op.name,
"aliases": op.aliases,
"tag": op.tag,
"form": "function",
"description": op.description,
"args": args,
}));
}
for (symbol, tag) in meta::binary_operators() {
ops.push(json!({
"name": symbol,
"tag": tag,
"form": "operator",
"description": binop_description(tag),
"args": [
{ "name": "l", "kind": "expr", "required": true },
{ "name": "r", "kind": "expr", "required": true }
]
}));
}
ops.push(json!({
"name": "-",
"tag": "Neg",
"form": "operator",
"unary": true,
"description": "Negation (-`of`).",
"args": [
{ "name": "of", "kind": "expr", "required": true }
]
}));
ops.push(json!({
"name": "not",
"tag": "Not",
"form": "operator",
"unary": true,
"description": "Logical NOT of a boolean panel (NaN is falsy, so `not` of NaN is 1).",
"args": [
{ "name": "of", "kind": "expr", "required": true }
]
}));
Value::Array(ops)
}
fn binop_description(tag: &str) -> &'static str {
match tag {
"Gt" => "1 where `l` is greater than `r`, else 0.",
"Lt" => "1 where `l` is less than `r`, else 0.",
"Ge" => "1 where `l` is greater than or equal to `r`, else 0.",
"Le" => "1 where `l` is less than or equal to `r`, else 0.",
"And" => "Logical AND of two boolean panels.",
"Or" => "Logical OR of two boolean panels.",
"Add" => "Element-wise `l` + `r`.",
"Sub" => "Element-wise `l` - `r`.",
"Mul" => "Element-wise `l` * `r`.",
"Div" => "Element-wise `l` / `r`.",
other => panic!("no description for operator tag `{other}`"),
}
}
fn op_object_schema(
tag: &str,
description: &str,
fields: &[(&str, &str, bool)], ) -> Value {
let mut properties = Map::new();
properties.insert("op".into(), json!({ "const": tag }));
let mut required: Vec<Value> = vec![json!("op")];
for (name, kind, req) in fields {
properties.insert((*name).into(), field_schema(kind));
if *req {
required.push(json!(*name));
}
}
json!({
"type": "object",
"title": tag,
"description": description,
"properties": Value::Object(properties),
"required": required,
"additionalProperties": false,
})
}
fn build_spec_schema() -> Value {
let mut branches: Vec<Value> = Vec::new();
branches.push(op_object_schema(
"Data",
"A raw input series by name.",
&[("name", "string", true)],
));
branches.push(op_object_schema(
"Const",
"A constant scalar value, broadcast across the panel.",
&[("value", "number", true)],
));
for op in meta::function_ops() {
let fields: Vec<(&str, &str, bool)> = op
.fields
.iter()
.map(|f| (f.name, f.kind, f.required))
.collect();
branches.push(op_object_schema(op.tag, op.description, &fields));
}
for (_, tag) in meta::binary_operators() {
branches.push(op_object_schema(
tag,
binop_description(tag),
&[("l", "expr", true), ("r", "expr", true)],
));
}
branches.push(op_object_schema(
"Neg",
"Negation (-`of`).",
&[("of", "expr", true)],
));
branches.push(op_object_schema(
"Not",
"Logical NOT of a boolean panel (NaN is falsy, so `not` of NaN is 1).",
&[("of", "expr", true)],
));
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/citrusquant/citrusquant/schema/lemon-spec.schema.json",
"title": "Lemon strategy spec (Expr tree)",
"description": "The serializable lemon strategy AST: a JSON tree tagged by \"op\". Generated from crates/lemon metadata; do not edit by hand.",
"$ref": "#/$defs/Expr",
"$defs": {
"Expr": {
"oneOf": branches
}
}
})
}
fn schema_dir() -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest
.parent() .and_then(|p| p.parent()) .expect("crate is two levels below workspace root")
.join("schema")
}
fn write_pretty(path: &std::path::Path, value: &Value) {
let mut s = serde_json::to_string_pretty(value).expect("serialize");
s.push('\n');
std::fs::write(path, s).unwrap_or_else(|e| panic!("write {}: {e}", path.display()));
println!("wrote {}", path.display());
}
fn main() {
let dir = schema_dir();
std::fs::create_dir_all(&dir).expect("create schema dir");
write_pretty(&dir.join("op-catalog.json"), &build_catalog());
write_pretty(&dir.join("lemon-spec.schema.json"), &build_spec_schema());
let schema = build_spec_schema();
let branches = schema["$defs"]["Expr"]["oneOf"].as_array().unwrap();
let tags: std::collections::HashSet<&str> = branches
.iter()
.map(|b| b["properties"]["op"]["const"].as_str().unwrap())
.collect();
for tag in meta::ALL_OP_TAGS {
assert!(tags.contains(tag), "schema missing op tag `{tag}`");
}
assert_eq!(
tags.len(),
meta::ALL_OP_TAGS.len(),
"schema branch count != ALL_OP_TAGS count"
);
println!("ok: {} op tags covered", tags.len());
}