bql 0.1.0

BQL — Bridge Query Language parser and formatter (Rust, generated).
Documentation
//! Generated BQL pretty-printer. Do not edit by hand.
//!
//! Serializes the AST back into a BQL string, walking the concrete-syntax
//! productions.
//!
//! Round-trip: `parse(format(ast)) == ast` holds for every AST that
//! `parse` produces. String / enum / field / participant payloads are
//! carried verbatim — the lexer stores the raw slice between the quotes
//! and the formatter re-wraps it without escaping — so a hand-built AST
//! must use the same raw form the lexer would produce: string-literal
//! bodies must be pre-escaped (use `escape`), enum values must be plain
//! enum words (not number / date / participant shaped), and a
//! participant's `type`/`id` must be bare-word-safe with a recognised
//! `type`. Payloads that violate this are not guaranteed to round-trip.

use crate::ast::*;
use crate::error::{BqlError, BqlResult};

pub fn format(query: Option<&Expr>) -> String {
    match query {
        None => String::new(),
        Some(q) => format_expr(q),
    }
}

fn format_and(v: &And<'_>) -> String {
    v.operands
        .iter()
        .map(|x| format_expr_child(x, 1))
        .collect::<Vec<_>>()
        .join(" AND ")
}

fn format_bin_op(v: &BinOp) -> String {
    match v {
        BinOp::Eq => "=".to_string(),
        BinOp::Neq => "!=".to_string(),
        BinOp::Contains => "~".to_string(),
        BinOp::NotContains => "!~".to_string(),
        BinOp::StartsWith => "^=".to_string(),
        BinOp::NotStartsWith => "!^=".to_string(),
        BinOp::EndsWith => "$=".to_string(),
        BinOp::NotEndsWith => "!$=".to_string(),
        BinOp::Gt => ">".to_string(),
        BinOp::Gte => ">=".to_string(),
        BinOp::Lt => "<".to_string(),
        BinOp::Lte => "<=".to_string(),
    }
}

fn format_boolean(v: &Boolean) -> String {
    match v {
        Boolean::True => "true".to_string(),
        Boolean::False => "false".to_string(),
    }
}

fn expr_precedence(v: &Expr<'_>) -> usize {
    match v {
        Expr::And(_) => 1,
        Expr::Or(_) => 0,
        Expr::Not(_) => 2,
        Expr::Compare(_) => 3,
        Expr::Between(_) => 3,
        Expr::NotBetween(_) => 3,
        Expr::In(_) => 3,
        Expr::NotIn(_) => 3,
        Expr::IsEmpty(_) => 3,
        Expr::IsNull(_) => 3,
        Expr::IsNotEmpty(_) => 3,
        Expr::IsNotNull(_) => 3,
    }
}

fn format_expr(v: &Expr<'_>) -> String {
    match v {
        Expr::And(x) => format_and(x),
        Expr::Or(x) => format_or(x),
        Expr::Not(x) => format!("NOT {}", format_expr_child(x, 2)),
        Expr::Compare(x) => format_compare(x),
        Expr::Between(x) => format_between(x),
        Expr::NotBetween(x) => format_not_between(x),
        Expr::In(x) => format_in(x),
        Expr::NotIn(x) => format_not_in(x),
        Expr::IsEmpty(x) => format_is_empty(x),
        Expr::IsNull(x) => format_is_null(x),
        Expr::IsNotEmpty(x) => format_is_not_empty(x),
        Expr::IsNotNull(x) => format_is_not_null(x),
    }
}

fn format_expr_child(v: &Expr<'_>, parent: usize) -> String {
    let s = format_expr(v);
    if expr_precedence(v) < parent {
        format!("({s})")
    } else {
        s
    }
}

fn format_compare(v: &Compare<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        format_bin_op(&v.op),
        format_value(&v.value),
    ])
}

fn format_between(v: &Between<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        "BETWEEN".to_string(),
        format_value(&v.lo),
        "AND".to_string(),
        format_value(&v.hi),
    ])
}

fn format_not_between(v: &Between<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        "NOT".to_string(),
        "BETWEEN".to_string(),
        format_value(&v.lo),
        "AND".to_string(),
        format_value(&v.hi),
    ])
}

fn format_in(v: &In<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        "IN".to_string(),
        "(".to_string(),
        v.values
            .iter()
            .map(|x| format_value(x))
            .collect::<Vec<_>>()
            .join(", "),
        ")".to_string(),
    ])
}

fn format_not_in(v: &In<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        "NOT".to_string(),
        "IN".to_string(),
        "(".to_string(),
        v.values
            .iter()
            .map(|x| format_value(x))
            .collect::<Vec<_>>()
            .join(", "),
        ")".to_string(),
    ])
}

fn format_is_empty(v: &Empty<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        "IS".to_string(),
        "EMPTY".to_string(),
    ])
}

fn format_is_null(v: &Empty<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        "IS".to_string(),
        "NULL".to_string(),
    ])
}

fn format_is_not_empty(v: &Empty<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        "IS".to_string(),
        "NOT".to_string(),
        "EMPTY".to_string(),
    ])
}

fn format_is_not_null(v: &Empty<'_>) -> String {
    join_pieces(&[
        if is_safe_bare_word(&v.field) {
            v.field.to_string()
        } else {
            quote_string(&v.field)
        },
        "IS".to_string(),
        "NOT".to_string(),
        "NULL".to_string(),
    ])
}

fn format_now(_v: &Now) -> String {
    "now()".to_string()
}

fn format_or(v: &Or<'_>) -> String {
    v.operands
        .iter()
        .map(|x| format_expr_child(x, 0))
        .collect::<Vec<_>>()
        .join(" OR ")
}

fn format_participant(v: &Participant<'_>) -> String {
    format!("{}:{}", v.r#type, v.id)
}

fn format_value(v: &Value<'_>) -> String {
    match v {
        Value::String(s) => quote_string(s),
        Value::Enum(s) => {
            if is_safe_bare_word(s)
                && matches!(crate::parser::classify_word(s, 0), Ok(Value::Enum(_)))
            {
                s.to_string()
            } else {
                quote_string(&escape(s))
            }
        }
        Value::Integer(s) => s.to_string(),
        Value::Float(s) => s.to_string(),
        Value::Boolean(x) => format_boolean(x),
        Value::Date(s) => s.to_string(),
        Value::DateTime(s) => s.to_string(),
        Value::Participant(x) => format_participant(x),
        Value::Now(x) => format_now(x),
    }
}

fn quote_string(s: &str) -> String {
    format!("\"{s}\"")
}

/// Escape logical text so it is safe to carry in a string `Value`: the
/// `\` and `"` characters that would otherwise break out of / re-open the
/// quoted literal are backslash-escaped. Build a string value from
/// untrusted or logical text via this helper so the formatted query
/// round-trips and cannot inject extra conditions. Other characters
/// (including control characters) are valid raw string content and are
/// left as-is.
pub fn escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        if c == '"' || c == '\\' {
            out.push('\\');
        }
        out.push(c);
    }
    out
}

/// Decode a raw string-literal body (the slice the lexer stores between
/// the quotes, still carrying its backslash escapes) into its logical
/// text. This is the inverse of `escape` and decodes the full BQL escape
/// set (`\" \\ \/ \n \t \r \b \f \uXXXX`), matching what the lexer
/// accepts. A malformed escape (unknown letter, truncated `\u`, or a `\u`
/// code point that is not a scalar value) is a `BqlError`.
pub fn unescape(s: &str) -> BqlResult<String> {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.char_indices();
    while let Some((i, c)) = chars.next() {
        if c != '\\' {
            out.push(c);
            continue;
        }
        let Some((_, e)) = chars.next() else {
            return Err(BqlError::new(i, "unterminated escape in string literal"));
        };
        match e {
            '"' => out.push('"'),
            '\\' => out.push('\\'),
            '/' => out.push('/'),
            'n' => out.push('\n'),
            't' => out.push('\t'),
            'r' => out.push('\r'),
            'b' => out.push('\u{8}'),
            'f' => out.push('\u{c}'),
            'u' => {
                let mut hex = String::new();
                for _ in 0..4 {
                    match chars.next() {
                        Some((_, h)) => hex.push(h),
                        None => {
                            return Err(BqlError::new(
                                i,
                                "incomplete `\\u` escape in string literal",
                            ));
                        }
                    }
                }
                let cp = u32::from_str_radix(&hex, 16)
                    .ok()
                    .and_then(char::from_u32)
                    .ok_or_else(|| BqlError::new(i, "invalid `\\u` escape in string literal"))?;
                out.push(cp);
            }
            other => {
                return Err(BqlError::new(
                    i,
                    format!("invalid escape sequence `\\{other}` in string literal"),
                ));
            }
        }
    }
    Ok(out)
}

fn is_safe_bare_word(s: &str) -> bool {
    !s.is_empty()
        && s.chars()
            .all(|c| matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | ':' | '.' | '+'))
        && !is_keyword(s)
}

fn is_keyword(s: &str) -> bool {
    matches!(
        s.to_ascii_lowercase().as_str(),
        "and" | "between" | "empty" | "false" | "in" | "is" | "not" | "null" | "or" | "true"
    )
}

fn join_pieces(parts: &[String]) -> String {
    let mut out = String::new();
    for p in parts.iter().filter(|p| !p.is_empty()) {
        if let (Some(prev), Some(cur)) = (out.chars().last(), p.chars().next()) {
            let no_space = matches!(cur, ')' | ',' | ':') || matches!(prev, '(' | ':');
            if !no_space {
                out.push(' ');
            }
        }
        out.push_str(p);
    }
    out
}