datafox 0.1.0

A small Datalog parser and streaming query engine for querying facts.
Documentation
use crate::{Atom, Clause, Query, Term, Value};

const CONTINUATION_INDENT: &str = "  ";

pub fn format_query(query: &Query) -> String {
    match query {
        Query::Single(atom) => format_atom(atom),
        Query::Multi(clauses) => clauses
            .iter()
            .enumerate()
            .map(|(index, clause)| {
                let prefix = if index == 0 { "" } else { CONTINUATION_INDENT };
                let suffix = if index + 1 == clauses.len() { "" } else { "," };
                format!("{prefix}{}{suffix}", format_clause(clause))
            })
            .collect::<Vec<_>>()
            .join("\n"),
    }
}

pub fn format_queries(queries: &[Query]) -> String {
    queries
        .iter()
        .map(format_query)
        .collect::<Vec<_>>()
        .join(";\n\n")
}

fn format_clause(clause: &Clause) -> String {
    match clause {
        Clause::Atom(atom) => format_atom(atom),
        Clause::Negated(atom) => format!("!{}", format_atom(atom)),
        Clause::Builtin { name, args } if is_infix_relation(name) && args.len() == 2 => {
            format!("{} {name} {}", format_term(&args[0]), format_term(&args[1]))
        }
        Clause::Builtin { name, args } => format_call(name, args),
    }
}

fn format_atom(atom: &Atom) -> String {
    format_call(&atom.predicate, &atom.args)
}

fn format_call(name: &str, args: &[Term]) -> String {
    let args = args.iter().map(format_term).collect::<Vec<_>>().join(", ");
    format!("{}({args})", format_predicate(name))
}

fn format_term(term: &Term) -> String {
    match term {
        Term::Var(name) => name.clone(),
        Term::Const(value) => format_value(value),
        Term::Call { name, args } if is_infix_operator(name) && args.len() == 2 => {
            format!(
                "({} {name} {})",
                format_term(&args[0]),
                format_term(&args[1])
            )
        }
        Term::Call { name, args } => format_call(name, args),
        Term::Wildcard => "_".to_string(),
    }
}

fn format_value(value: &Value) -> String {
    match value {
        Value::Integer(value) => value.to_string(),
        Value::String(value) => format!("\"{}\"", escape_double_quoted(value)),
    }
}

fn format_predicate(name: &str) -> String {
    if is_identifier(name) {
        name.to_string()
    } else {
        format!("'{}'", name)
    }
}

fn escape_double_quoted(value: &str) -> String {
    value
        .chars()
        .flat_map(|ch| match ch {
            '"' => "\\\"".chars().collect(),
            _ => vec![ch],
        })
        .collect()
}

fn is_identifier(value: &str) -> bool {
    let mut chars = value.chars();
    chars.next().is_some_and(|ch| ch.is_ascii_alphabetic())
        && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '_' | '-' | '?'))
}

fn is_infix_relation(name: &str) -> bool {
    matches!(name, "=" | "<" | "<=" | ">" | ">=")
}

fn is_infix_operator(name: &str) -> bool {
    matches!(name, "+" | "-" | "*" | "/")
}

#[cfg(test)]
mod tests {
    use crate::{format_queries, parse_queries, parse_query};

    #[test]
    fn formats_multi_clause_queries_one_clause_per_line() {
        let query = parse_query(
            r#"node(Node, "macro_invocation", _, _, _, _), text(Node, Text), contains(Text, "dbg!")"#,
        )
        .expect("query");

        assert_eq!(
            crate::format_query(&query),
            "node(Node, \"macro_invocation\", _, _, _, _),\n  text(Node, Text),\n  contains(Text, \"dbg!\")"
        );
    }

    #[test]
    fn formats_query_sets_with_semicolon_separators() {
        let queries = parse_queries(
            r#"node(Node, "call_expression", _, _, _, _), text(Node, Text); !skip(Node), X >= 1"#,
        )
        .expect("queries");

        assert_eq!(
            format_queries(&queries),
            "node(Node, \"call_expression\", _, _, _, _),\n  text(Node, Text);\n\n!skip(Node),\n  X >= 1"
        );
    }

    #[test]
    fn preserves_regex_backslashes() {
        let query = parse_query(r#"text(Text, "_async\s*\(")"#).expect("query");

        assert_eq!(crate::format_query(&query), r#"text(Text, "_async\s*\(")"#);
    }
}