Initial implementation of Improvise TUI

Multi-dimensional data modeling terminal application with:
- Core data model: categories, items, groups, sparse cell store
- Formula system: recursive-descent parser, named formulas, WHERE clauses
- View system: Row/Column/Page axes, tile-based pivot, page slicing
- JSON import wizard (interactive TUI + headless auto-mode)
- Command layer: all mutations via typed Command enum for headless replay
- TUI: Ratatui grid, tile bar, formula/category/view panels, help overlay
- Persistence: .improv (JSON), .improv.gz (gzip), CSV export, autosave
- Static binary via x86_64-unknown-linux-musl + nix flake devShell
- Headless mode: --cmd '{"op":"..."}' and --script file.jsonl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ed L
2026-03-20 21:11:14 -07:00
parent 0ba39672d3
commit eae00522e2
35 changed files with 5413 additions and 0 deletions

58
src/formula/ast.rs Normal file
View File

@ -0,0 +1,58 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AggFunc {
Sum,
Avg,
Min,
Max,
Count,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Filter {
pub category: String,
pub item: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Expr {
Number(f64),
Ref(String),
BinOp(String, Box<Expr>, Box<Expr>),
UnaryMinus(Box<Expr>),
Agg(AggFunc, Box<Expr>, Option<Filter>),
If(Box<Expr>, Box<Expr>, Box<Expr>),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Formula {
/// The raw formula text, e.g. "Profit = Revenue - Cost"
pub raw: String,
/// The item/dimension name this formula computes, e.g. "Profit"
pub target: String,
/// The category containing the target item
pub target_category: String,
/// The expression to evaluate
pub expr: Expr,
/// Optional WHERE filter
pub filter: Option<Filter>,
}
impl Formula {
pub fn new(
raw: impl Into<String>,
target: impl Into<String>,
target_category: impl Into<String>,
expr: Expr,
filter: Option<Filter>,
) -> Self {
Self {
raw: raw.into(),
target: target.into(),
target_category: target_category.into(),
expr,
filter,
}
}
}

5
src/formula/mod.rs Normal file
View File

@ -0,0 +1,5 @@
pub mod parser;
pub mod ast;
pub use ast::{AggFunc, Expr, Filter, Formula};
pub use parser::parse_formula;

306
src/formula/parser.rs Normal file
View File

@ -0,0 +1,306 @@
use anyhow::{anyhow, Result};
use super::ast::{AggFunc, Expr, Filter, Formula};
/// Parse a formula string like "Profit = Revenue - Cost"
/// or "Tax = Revenue * 0.08 WHERE Region = \"East\""
pub fn parse_formula(raw: &str, target_category: &str) -> Result<Formula> {
let raw = raw.trim();
// Split on first `=` to get target = expression
let eq_pos = raw.find('=').ok_or_else(|| anyhow!("Formula must contain '=': {raw}"))?;
let target = raw[..eq_pos].trim().to_string();
let rest = raw[eq_pos + 1..].trim();
// Check for WHERE clause at top level
let (expr_str, filter) = split_where(rest);
let filter = filter.map(|w| parse_where(w)).transpose()?;
let expr = parse_expr(expr_str.trim())?;
Ok(Formula::new(raw, target, target_category, expr, filter))
}
fn split_where(s: &str) -> (&str, Option<&str>) {
// Find WHERE not inside parens or quotes
let bytes = s.as_bytes();
let mut depth = 0i32;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'(' => depth += 1,
b')' => depth -= 1,
b'"' => {
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
i += 1;
}
}
_ if depth == 0 => {
if s[i..].to_ascii_uppercase().starts_with("WHERE") {
let before = &s[..i];
let after = &s[i + 5..];
if before.ends_with(char::is_whitespace) || i == 0 {
return (before.trim(), Some(after.trim()));
}
}
}
_ => {}
}
i += 1;
}
(s, None)
}
fn parse_where(s: &str) -> Result<Filter> {
// Format: Category = "Item" or Category = Item
let eq_pos = s.find('=').ok_or_else(|| anyhow!("WHERE clause must contain '=': {s}"))?;
let category = s[..eq_pos].trim().to_string();
let item_raw = s[eq_pos + 1..].trim();
let item = item_raw.trim_matches('"').to_string();
Ok(Filter { category, item })
}
/// Parse an expression using recursive descent
pub fn parse_expr(s: &str) -> Result<Expr> {
let tokens = tokenize(s)?;
let mut pos = 0;
let expr = parse_add_sub(&tokens, &mut pos)?;
if pos < tokens.len() {
return Err(anyhow!("Unexpected token at position {pos}: {:?}", tokens[pos]));
}
Ok(expr)
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
Number(f64),
Ident(String),
Str(String),
Plus,
Minus,
Star,
Slash,
Caret,
LParen,
RParen,
Comma,
Eq,
Ne,
Lt,
Gt,
Le,
Ge,
}
fn tokenize(s: &str) -> Result<Vec<Token>> {
let mut tokens = Vec::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
match chars[i] {
' ' | '\t' | '\n' => i += 1,
'+' => { tokens.push(Token::Plus); i += 1; }
'-' => { tokens.push(Token::Minus); i += 1; }
'*' => { tokens.push(Token::Star); i += 1; }
'/' => { tokens.push(Token::Slash); i += 1; }
'^' => { tokens.push(Token::Caret); i += 1; }
'(' => { tokens.push(Token::LParen); i += 1; }
')' => { tokens.push(Token::RParen); i += 1; }
',' => { tokens.push(Token::Comma); i += 1; }
'!' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ne); i += 2; }
'<' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Le); i += 2; }
'>' if chars.get(i+1) == Some(&'=') => { tokens.push(Token::Ge); i += 2; }
'<' => { tokens.push(Token::Lt); i += 1; }
'>' => { tokens.push(Token::Gt); i += 1; }
'=' => { tokens.push(Token::Eq); i += 1; }
'"' => {
i += 1;
let mut s = String::new();
while i < chars.len() && chars[i] != '"' {
s.push(chars[i]);
i += 1;
}
if i < chars.len() { i += 1; }
tokens.push(Token::Str(s));
}
c if c.is_ascii_digit() || c == '.' => {
let mut num = String::new();
while i < chars.len() && (chars[i].is_ascii_digit() || chars[i] == '.') {
num.push(chars[i]);
i += 1;
}
tokens.push(Token::Number(num.parse()?));
}
c if c.is_alphabetic() || c == '_' => {
let mut ident = String::new();
while i < chars.len() && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == ' ') {
// Don't consume trailing spaces if next non-space is operator
if chars[i] == ' ' {
// Peek ahead
let j = i + 1;
let next_nonspace = chars[j..].iter().find(|&&c| c != ' ');
if matches!(next_nonspace, Some('+') | Some('-') | Some('*') | Some('/') | Some('^') | Some(')') | Some(',') | None) {
break;
}
}
ident.push(chars[i]);
i += 1;
}
let ident = ident.trim_end().to_string();
tokens.push(Token::Ident(ident));
}
c => return Err(anyhow!("Unexpected character '{c}' in expression")),
}
}
Ok(tokens)
}
fn parse_add_sub(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let mut left = parse_mul_div(tokens, pos)?;
while *pos < tokens.len() {
let op = match &tokens[*pos] {
Token::Plus => "+",
Token::Minus => "-",
_ => break,
};
*pos += 1;
let right = parse_mul_div(tokens, pos)?;
left = Expr::BinOp(op.to_string(), Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_mul_div(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let mut left = parse_pow(tokens, pos)?;
while *pos < tokens.len() {
let op = match &tokens[*pos] {
Token::Star => "*",
Token::Slash => "/",
_ => break,
};
*pos += 1;
let right = parse_pow(tokens, pos)?;
left = Expr::BinOp(op.to_string(), Box::new(left), Box::new(right));
}
Ok(left)
}
fn parse_pow(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let base = parse_unary(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Caret {
*pos += 1;
let exp = parse_unary(tokens, pos)?;
return Ok(Expr::BinOp("^".to_string(), Box::new(base), Box::new(exp)));
}
Ok(base)
}
fn parse_unary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
if *pos < tokens.len() && tokens[*pos] == Token::Minus {
*pos += 1;
let e = parse_primary(tokens, pos)?;
return Ok(Expr::UnaryMinus(Box::new(e)));
}
parse_primary(tokens, pos)
}
fn parse_primary(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
if *pos >= tokens.len() {
return Err(anyhow!("Unexpected end of expression"));
}
match &tokens[*pos].clone() {
Token::Number(n) => {
*pos += 1;
Ok(Expr::Number(*n))
}
Token::Ident(name) => {
let name = name.clone();
*pos += 1;
// Check for function call
let upper = name.to_ascii_uppercase();
match upper.as_str() {
"SUM" | "AVG" | "MIN" | "MAX" | "COUNT" => {
let func = match upper.as_str() {
"SUM" => AggFunc::Sum,
"AVG" => AggFunc::Avg,
"MIN" => AggFunc::Min,
"MAX" => AggFunc::Max,
"COUNT" => AggFunc::Count,
_ => unreachable!(),
};
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
*pos += 1;
let inner = parse_add_sub(tokens, pos)?;
// Optional WHERE filter
let filter = if *pos < tokens.len() {
if let Token::Ident(kw) = &tokens[*pos] {
if kw.to_ascii_uppercase() == "WHERE" {
*pos += 1;
let cat = match &tokens[*pos] {
Token::Ident(s) => { let s = s.clone(); *pos += 1; s }
t => return Err(anyhow!("Expected category name, got {t:?}")),
};
// expect =
if *pos < tokens.len() && tokens[*pos] == Token::Eq { *pos += 1; }
let item = match &tokens[*pos] {
Token::Str(s) | Token::Ident(s) => { let s = s.clone(); *pos += 1; s }
t => return Err(anyhow!("Expected item name, got {t:?}")),
};
Some(Filter { category: cat, item })
} else { None }
} else { None }
} else { None };
// expect )
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
*pos += 1;
}
return Ok(Expr::Agg(func, Box::new(inner), filter));
}
Ok(Expr::Ref(name))
}
"IF" => {
if *pos < tokens.len() && tokens[*pos] == Token::LParen {
*pos += 1;
let cond = parse_comparison(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; }
let then = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::Comma { *pos += 1; }
let else_ = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::RParen { *pos += 1; }
return Ok(Expr::If(Box::new(cond), Box::new(then), Box::new(else_)));
}
Ok(Expr::Ref(name))
}
_ => Ok(Expr::Ref(name)),
}
}
Token::LParen => {
*pos += 1;
let e = parse_add_sub(tokens, pos)?;
if *pos < tokens.len() && tokens[*pos] == Token::RParen {
*pos += 1;
}
Ok(e)
}
t => Err(anyhow!("Unexpected token in expression: {t:?}")),
}
}
fn parse_comparison(tokens: &[Token], pos: &mut usize) -> Result<Expr> {
let left = parse_add_sub(tokens, pos)?;
if *pos >= tokens.len() { return Ok(left); }
let op = match &tokens[*pos] {
Token::Eq => "=",
Token::Ne => "!=",
Token::Lt => "<",
Token::Gt => ">",
Token::Le => "<=",
Token::Ge => ">=",
_ => return Ok(left),
};
*pos += 1;
let right = parse_add_sub(tokens, pos)?;
Ok(Expr::BinOp(op.to_string(), Box::new(left), Box::new(right)))
}