Add TUI frontend and syntax highlighting (REPL and web UI)
This commit is contained in:
parent
c3a1c7d9dd
commit
d7b2eb4144
@ -55,7 +55,7 @@ Quick examples:
|
|||||||
- `inference.rs`: shared matching, negation filtering, and provenance-aware materialization helpers.
|
- `inference.rs`: shared matching, negation filtering, and provenance-aware materialization helpers.
|
||||||
- `stratification.rs`: stratification analysis for rules with negation.
|
- `stratification.rs`: stratification analysis for rules with negation.
|
||||||
- `union_find.rs`: equality merging support.
|
- `union_find.rs`: equality merging support.
|
||||||
- `src/frontend/`: lightweight interactive surface for scripts, REPL, and local web UI.
|
- `src/frontend/`: lightweight interactive surface for scripts, REPL, local web UI, TUI (behind `tui` feature), and syntax highlighting.
|
||||||
- `src/relational/`: schemas, values, rows, and result sets for relational execution.
|
- `src/relational/`: schemas, values, rows, and result sets for relational execution.
|
||||||
- `src/catalog/`: predicate-to-table schema inference and catalog access.
|
- `src/catalog/`: predicate-to-table schema inference and catalog access.
|
||||||
- `src/io/`: CSV-based fact import and export.
|
- `src/io/`: CSV-based fact import and export.
|
||||||
|
|||||||
@ -29,8 +29,11 @@ path = "src/main.rs"
|
|||||||
[features]
|
[features]
|
||||||
default = [] # No features enabled by default
|
default = [] # No features enabled by default
|
||||||
binaries = []
|
binaries = []
|
||||||
|
tui = ["dep:ratatui", "dep:crossterm"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
|
||||||
|
crossterm = { version = "0.28", optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1.6"
|
proptest = "1.6"
|
||||||
|
|||||||
@ -13,7 +13,7 @@ execution boundaries.
|
|||||||
- Restricted, standard, oblivious, and Skolem chase variants
|
- Restricted, standard, oblivious, and Skolem chase variants
|
||||||
- Optional semi-naive evaluation across all chase variants
|
- Optional semi-naive evaluation across all chase variants
|
||||||
- Provenance-oriented explanations for derived answers
|
- Provenance-oriented explanations for derived answers
|
||||||
- Script, REPL, and local web UI for experimentation
|
- Script, REPL, local web UI, and optional TUI for experimentation (all with syntax highlighting)
|
||||||
- Relational schema, catalog, logical-plan, and execution scaffolding
|
- Relational schema, catalog, logical-plan, and execution scaffolding
|
||||||
- Physical operator scaffolding with a small rule-based rewrite layer
|
- Physical operator scaffolding with a small rule-based rewrite layer
|
||||||
- A minimal SQL slice for `SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT` queries over predicate-backed tables, including `COUNT`, `SUM`, `MIN`, `MAX`, and `AVG` aggregates
|
- A minimal SQL slice for `SELECT-FROM-WHERE-GROUP BY-ORDER BY-LIMIT` queries over predicate-backed tables, including `COUNT`, `SUM`, `MIN`, `MAX`, and `AVG` aggregates
|
||||||
@ -99,6 +99,7 @@ cargo run -- repl
|
|||||||
cargo run -- gui
|
cargo run -- gui
|
||||||
cargo run -- script examples/scripts/ancestor.ech
|
cargo run -- script examples/scripts/ancestor.ech
|
||||||
cargo run -- script examples/scripts/sql_join.ech
|
cargo run -- script examples/scripts/sql_join.ech
|
||||||
|
cargo run --features tui -- tui
|
||||||
```
|
```
|
||||||
|
|
||||||
#### REPL language
|
#### REPL language
|
||||||
|
|||||||
310
src/frontend/highlight.rs
Normal file
310
src/frontend/highlight.rs
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
//! Token-based syntax highlighting for the chase language and SQL.
|
||||||
|
//!
|
||||||
|
//! This module tokenizes input lines and classifies each span so that
|
||||||
|
//! frontends (REPL, TUI, web) can apply their own styling. Two convenience
|
||||||
|
//! renderers are provided out of the box:
|
||||||
|
//!
|
||||||
|
//! - [`to_ansi`]: renders highlighted text with ANSI escape codes for
|
||||||
|
//! terminal output.
|
||||||
|
//! - [`HighlightToken`]: structured tokens that TUI and web frontends can
|
||||||
|
//! map to their native style types.
|
||||||
|
|
||||||
|
/// The semantic role of a highlighted token.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TokenKind {
|
||||||
|
/// A chase command keyword: `fact`, `rule`, `run`, `query`, `explain`,
|
||||||
|
/// `show`, `reset`, `help`, `schema`, `sql`.
|
||||||
|
Command,
|
||||||
|
/// A SQL keyword: `SELECT`, `FROM`, `WHERE`, `GROUP`, `BY`, `ORDER`,
|
||||||
|
/// `LIMIT`, `AS`, `AND`, `OR`, `ASC`, `DESC`, `NULL`.
|
||||||
|
SqlKeyword,
|
||||||
|
/// An aggregate function name: `COUNT`, `SUM`, `MIN`, `MAX`, `AVG`.
|
||||||
|
Function,
|
||||||
|
/// A variable reference such as `?X`.
|
||||||
|
Variable,
|
||||||
|
/// A string literal enclosed in single quotes.
|
||||||
|
StringLiteral,
|
||||||
|
/// A numeric literal.
|
||||||
|
Number,
|
||||||
|
/// An operator: `=`, `!=`, `<>`, `->`, `,`, `.`, `(`, `)`, `*`, `;`, `?`.
|
||||||
|
Operator,
|
||||||
|
/// A predicate or identifier name.
|
||||||
|
Identifier,
|
||||||
|
/// Whitespace or anything that does not fit another category.
|
||||||
|
Plain,
|
||||||
|
/// A comment line starting with `#`.
|
||||||
|
Comment,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single highlighted token with its text content and kind.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct HighlightToken {
|
||||||
|
pub text: String,
|
||||||
|
pub kind: TokenKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HighlightToken {
|
||||||
|
fn new(text: impl Into<String>, kind: TokenKind) -> Self {
|
||||||
|
Self {
|
||||||
|
text: text.into(),
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokenize a single line into highlighted tokens.
|
||||||
|
pub fn highlight_line(line: &str) -> Vec<HighlightToken> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed.starts_with('#') {
|
||||||
|
return vec![HighlightToken::new(line, TokenKind::Comment)];
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tokens = Vec::new();
|
||||||
|
let chars: Vec<char> = line.chars().collect();
|
||||||
|
let len = chars.len();
|
||||||
|
let mut i = 0;
|
||||||
|
let mut is_first_word = true;
|
||||||
|
|
||||||
|
while i < len {
|
||||||
|
let ch = chars[i];
|
||||||
|
|
||||||
|
// Whitespace
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
let start = i;
|
||||||
|
while i < len && chars[i].is_whitespace() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
tokens.push(HighlightToken::new(&line[start..i], TokenKind::Plain));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// String literal
|
||||||
|
if ch == '\'' {
|
||||||
|
let start = i;
|
||||||
|
i += 1;
|
||||||
|
while i < len {
|
||||||
|
if chars[i] == '\'' {
|
||||||
|
i += 1;
|
||||||
|
if i < len && chars[i] == '\'' {
|
||||||
|
i += 1; // escaped quote
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokens.push(HighlightToken::new(
|
||||||
|
&line[start..i],
|
||||||
|
TokenKind::StringLiteral,
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-char operators
|
||||||
|
if ch == '-' && i + 1 < len && chars[i + 1] == '>' {
|
||||||
|
tokens.push(HighlightToken::new("->", TokenKind::Operator));
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '!' && i + 1 < len && chars[i + 1] == '=' {
|
||||||
|
tokens.push(HighlightToken::new("!=", TokenKind::Operator));
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ch == '<' && i + 1 < len && chars[i + 1] == '>' {
|
||||||
|
tokens.push(HighlightToken::new("<>", TokenKind::Operator));
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variable (?X) — must be checked before the single-char operator set
|
||||||
|
// since `?` also appears there for standalone query terminators.
|
||||||
|
if ch == '?' && i + 1 < len && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_') {
|
||||||
|
let start = i;
|
||||||
|
i += 1;
|
||||||
|
while i < len && (chars[i].is_alphanumeric() || chars[i] == '_') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
tokens.push(HighlightToken::new(&line[start..i], TokenKind::Variable));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number
|
||||||
|
if ch.is_ascii_digit() {
|
||||||
|
let start = i;
|
||||||
|
while i < len && chars[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
tokens.push(HighlightToken::new(&line[start..i], TokenKind::Number));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Word (identifier or keyword)
|
||||||
|
if ch.is_alphabetic() || ch == '_' {
|
||||||
|
let start = i;
|
||||||
|
while i < len && (chars[i].is_alphanumeric() || "_-:.".contains(chars[i])) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let word = &line[start..i];
|
||||||
|
let kind = classify_word(word, is_first_word);
|
||||||
|
tokens.push(HighlightToken::new(word, kind));
|
||||||
|
is_first_word = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
tokens.push(HighlightToken::new(
|
||||||
|
&line[i..i + ch.len_utf8()],
|
||||||
|
TokenKind::Plain,
|
||||||
|
));
|
||||||
|
i += ch.len_utf8();
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify_word(word: &str, is_first_word: bool) -> TokenKind {
|
||||||
|
let upper = word.to_ascii_uppercase();
|
||||||
|
|
||||||
|
// Chase commands (only when they are the first word on a line).
|
||||||
|
if is_first_word {
|
||||||
|
match upper.as_str() {
|
||||||
|
"FACT" | "RULE" | "RUN" | "QUERY" | "EXPLAIN" | "SHOW" | "RESET" | "HELP"
|
||||||
|
| "SCHEMA" | "SQL" => return TokenKind::Command,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL keywords
|
||||||
|
match upper.as_str() {
|
||||||
|
"SELECT" | "FROM" | "WHERE" | "GROUP" | "BY" | "ORDER" | "LIMIT" | "AS" | "AND" | "OR"
|
||||||
|
| "ASC" | "DESC" | "NULL" | "NOT" | "ON" | "JOIN" | "INNER" | "LEFT" | "RIGHT"
|
||||||
|
| "OUTER" | "HAVING" | "DISTINCT" | "INSERT" | "UPDATE" | "DELETE" | "CREATE" | "DROP"
|
||||||
|
| "TABLE" | "INTO" | "VALUES" | "SET" => {
|
||||||
|
return TokenKind::SqlKeyword;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate / function names
|
||||||
|
match upper.as_str() {
|
||||||
|
"COUNT" | "SUM" | "MIN" | "MAX" | "AVG" => return TokenKind::Function,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chase-language keywords appearing mid-line
|
||||||
|
match upper.as_str() {
|
||||||
|
"FACTS" | "RULES" => return TokenKind::Command,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenKind::Identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ANSI rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ANSI_RESET: &str = "\x1b[0m";
|
||||||
|
const ANSI_BOLD: &str = "\x1b[1m";
|
||||||
|
const ANSI_CYAN: &str = "\x1b[36m";
|
||||||
|
const ANSI_BLUE: &str = "\x1b[34m";
|
||||||
|
const ANSI_GREEN: &str = "\x1b[32m";
|
||||||
|
const ANSI_YELLOW: &str = "\x1b[33m";
|
||||||
|
const ANSI_MAGENTA: &str = "\x1b[35m";
|
||||||
|
const ANSI_RED: &str = "\x1b[31m";
|
||||||
|
const ANSI_GRAY: &str = "\x1b[90m";
|
||||||
|
|
||||||
|
fn ansi_for(kind: TokenKind) -> &'static str {
|
||||||
|
match kind {
|
||||||
|
TokenKind::Command => ANSI_BOLD,
|
||||||
|
TokenKind::SqlKeyword => ANSI_BLUE,
|
||||||
|
TokenKind::Function => ANSI_CYAN,
|
||||||
|
TokenKind::Variable => ANSI_MAGENTA,
|
||||||
|
TokenKind::StringLiteral => ANSI_GREEN,
|
||||||
|
TokenKind::Number => ANSI_YELLOW,
|
||||||
|
TokenKind::Operator => ANSI_RED,
|
||||||
|
TokenKind::Identifier => "",
|
||||||
|
TokenKind::Plain => "",
|
||||||
|
TokenKind::Comment => ANSI_GRAY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render a line with ANSI escape codes for terminal display.
|
||||||
|
pub fn to_ansi(line: &str) -> String {
|
||||||
|
let tokens = highlight_line(line);
|
||||||
|
let mut out = String::with_capacity(line.len() + 64);
|
||||||
|
for token in &tokens {
|
||||||
|
let code = ansi_for(token.kind);
|
||||||
|
if code.is_empty() {
|
||||||
|
out.push_str(&token.text);
|
||||||
|
} else {
|
||||||
|
out.push_str(code);
|
||||||
|
out.push_str(&token.text);
|
||||||
|
out.push_str(ANSI_RESET);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render multiple lines with ANSI highlighting.
|
||||||
|
pub fn to_ansi_block(text: &str) -> String {
|
||||||
|
text.lines().map(to_ansi).collect::<Vec<_>>().join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlights_chase_command() {
|
||||||
|
let tokens = highlight_line("fact Parent(alice, bob).");
|
||||||
|
assert_eq!(tokens[0].kind, TokenKind::Command);
|
||||||
|
assert_eq!(tokens[0].text, "fact");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlights_sql_keywords_and_function() {
|
||||||
|
let tokens = highlight_line("sql SELECT COUNT(*) FROM Parent WHERE c0 = 'alice';");
|
||||||
|
let kinds: Vec<TokenKind> = tokens.iter().map(|t| t.kind).collect();
|
||||||
|
assert!(kinds.contains(&TokenKind::Command)); // sql
|
||||||
|
assert!(kinds.contains(&TokenKind::SqlKeyword)); // SELECT, FROM, WHERE
|
||||||
|
assert!(kinds.contains(&TokenKind::Function)); // COUNT
|
||||||
|
assert!(kinds.contains(&TokenKind::StringLiteral)); // 'alice'
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlights_variables() {
|
||||||
|
let tokens = highlight_line("rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).");
|
||||||
|
let var_tokens: Vec<&HighlightToken> = tokens
|
||||||
|
.iter()
|
||||||
|
.filter(|t| t.kind == TokenKind::Variable)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(var_tokens.len(), 4);
|
||||||
|
assert_eq!(var_tokens[0].text, "?X");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlights_comment() {
|
||||||
|
let tokens = highlight_line("# this is a comment");
|
||||||
|
assert_eq!(tokens.len(), 1);
|
||||||
|
assert_eq!(tokens[0].kind, TokenKind::Comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlights_operators() {
|
||||||
|
let tokens = highlight_line("a != b");
|
||||||
|
let op = tokens
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.kind == TokenKind::Operator)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(op.text, "!=");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ansi_output_contains_escape_codes() {
|
||||||
|
let ansi = to_ansi("fact Parent(alice, bob).");
|
||||||
|
assert!(ansi.contains("\x1b["));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +1,11 @@
|
|||||||
//! Frontend utilities for interacting with the query-engine playground.
|
//! Frontend utilities for interacting with the query-engine playground.
|
||||||
|
|
||||||
|
pub mod highlight;
|
||||||
pub mod language;
|
pub mod language;
|
||||||
pub mod provenance;
|
pub mod provenance;
|
||||||
pub mod repl;
|
pub mod repl;
|
||||||
pub mod session;
|
pub mod session;
|
||||||
|
pub mod tui;
|
||||||
pub mod web;
|
pub mod web;
|
||||||
|
|
||||||
pub use repl::run_repl;
|
pub use repl::run_repl;
|
||||||
|
|||||||
@ -1,13 +1,18 @@
|
|||||||
//! Interactive REPL for the minimal query-engine frontend language.
|
//! Interactive REPL for the minimal query-engine frontend language.
|
||||||
|
//!
|
||||||
|
//! Output is syntax-highlighted with ANSI escape codes when the terminal
|
||||||
|
//! supports them.
|
||||||
|
|
||||||
use std::io::{self, BufRead, Write};
|
use std::io::{self, BufRead, IsTerminal, Write};
|
||||||
|
|
||||||
use super::Session;
|
use super::Session;
|
||||||
|
use super::highlight::to_ansi_block;
|
||||||
|
|
||||||
pub fn run_repl() -> io::Result<()> {
|
pub fn run_repl() -> io::Result<()> {
|
||||||
let stdin = io::stdin();
|
let stdin = io::stdin();
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
let mut session = Session::new();
|
let mut session = Session::new();
|
||||||
|
let color = stdout.is_terminal();
|
||||||
|
|
||||||
writeln!(stdout, "query-engine REPL")?;
|
writeln!(stdout, "query-engine REPL")?;
|
||||||
writeln!(stdout, "Type `help` for commands and `quit` to exit.")?;
|
writeln!(stdout, "Type `help` for commands and `quit` to exit.")?;
|
||||||
@ -32,8 +37,14 @@ pub fn run_repl() -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
match session.execute_script(trimmed) {
|
match session.execute_script(trimmed) {
|
||||||
Ok(output) => writeln!(stdout, "{}", output)?,
|
Ok(output) => {
|
||||||
Err(err) => writeln!(stdout, "error: {}", err)?,
|
if color {
|
||||||
|
writeln!(stdout, "{}", to_ansi_block(&output))?;
|
||||||
|
} else {
|
||||||
|
writeln!(stdout, "{}", output)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(err) => writeln!(stdout, "\x1b[31merror: {}\x1b[0m", err)?,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
274
src/frontend/tui.rs
Normal file
274
src/frontend/tui.rs
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
//! Terminal user interface built on `ratatui` and `crossterm`.
|
||||||
|
//!
|
||||||
|
//! The TUI provides a two-pane layout: a scrollable output area on top and
|
||||||
|
//! an input line at the bottom. Input is syntax-highlighted as the user
|
||||||
|
//! types, and command history is navigable with the up/down arrow keys.
|
||||||
|
//!
|
||||||
|
//! Enable with `cargo run --features tui -- tui`.
|
||||||
|
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
pub mod app {
|
||||||
|
use std::io::{self, Stdout};
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
|
use crossterm::execute;
|
||||||
|
use crossterm::terminal::{
|
||||||
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
||||||
|
};
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::CrosstermBackend;
|
||||||
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||||
|
|
||||||
|
use crate::frontend::highlight::{HighlightToken, TokenKind, highlight_line};
|
||||||
|
use crate::frontend::session::Session;
|
||||||
|
|
||||||
|
struct TuiState {
|
||||||
|
session: Session,
|
||||||
|
input: String,
|
||||||
|
cursor: usize,
|
||||||
|
output_lines: Vec<String>,
|
||||||
|
scroll: u16,
|
||||||
|
history: Vec<String>,
|
||||||
|
history_index: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TuiState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
session: Session::new(),
|
||||||
|
input: String::new(),
|
||||||
|
cursor: 0,
|
||||||
|
output_lines: vec!["Welcome to query-engine TUI. Type `help` for commands.".into()],
|
||||||
|
scroll: 0,
|
||||||
|
history: Vec::new(),
|
||||||
|
history_index: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit(&mut self) {
|
||||||
|
let line = self.input.clone();
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.output_lines.push(format!("chase> {}", line));
|
||||||
|
self.history.push(line.clone());
|
||||||
|
self.history_index = None;
|
||||||
|
|
||||||
|
if line.trim() == "quit" || line.trim() == "exit" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.session
|
||||||
|
.execute_script(&line)
|
||||||
|
.unwrap_or_else(|e| format!("error: {}", e));
|
||||||
|
|
||||||
|
for out_line in response.lines() {
|
||||||
|
self.output_lines.push(out_line.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.input.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
// Auto-scroll to bottom.
|
||||||
|
let total = self.output_lines.len() as u16;
|
||||||
|
self.scroll = total.saturating_sub(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_up(&mut self) {
|
||||||
|
if self.history.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let new_index = match self.history_index {
|
||||||
|
None => self.history.len() - 1,
|
||||||
|
Some(0) => 0,
|
||||||
|
Some(i) => i - 1,
|
||||||
|
};
|
||||||
|
self.history_index = Some(new_index);
|
||||||
|
self.input = self.history[new_index].clone();
|
||||||
|
self.cursor = self.input.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn history_down(&mut self) {
|
||||||
|
match self.history_index {
|
||||||
|
None => {}
|
||||||
|
Some(i) if i + 1 >= self.history.len() => {
|
||||||
|
self.history_index = None;
|
||||||
|
self.input.clear();
|
||||||
|
self.cursor = 0;
|
||||||
|
}
|
||||||
|
Some(i) => {
|
||||||
|
self.history_index = Some(i + 1);
|
||||||
|
self.input = self.history[i + 1].clone();
|
||||||
|
self.cursor = self.input.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_style(kind: TokenKind) -> Style {
|
||||||
|
match kind {
|
||||||
|
TokenKind::Command => Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
TokenKind::SqlKeyword => Style::default().fg(Color::Blue),
|
||||||
|
TokenKind::Function => Style::default().fg(Color::Cyan),
|
||||||
|
TokenKind::Variable => Style::default().fg(Color::Magenta),
|
||||||
|
TokenKind::StringLiteral => Style::default().fg(Color::Green),
|
||||||
|
TokenKind::Number => Style::default().fg(Color::Yellow),
|
||||||
|
TokenKind::Operator => Style::default().fg(Color::Red),
|
||||||
|
TokenKind::Identifier => Style::default(),
|
||||||
|
TokenKind::Plain => Style::default(),
|
||||||
|
TokenKind::Comment => Style::default().fg(Color::DarkGray),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokens_to_line(tokens: &[HighlightToken]) -> Line<'static> {
|
||||||
|
let spans: Vec<Span<'static>> = tokens
|
||||||
|
.iter()
|
||||||
|
.map(|t| Span::styled(t.text.clone(), token_style(t.kind)))
|
||||||
|
.collect();
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(terminal: &mut Terminal<CrosstermBackend<Stdout>>, state: &TuiState) -> io::Result<()> {
|
||||||
|
terminal.draw(|frame| {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([Constraint::Min(3), Constraint::Length(3)])
|
||||||
|
.split(frame.area());
|
||||||
|
|
||||||
|
// Output pane
|
||||||
|
let output_lines: Vec<Line<'static>> = state
|
||||||
|
.output_lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| tokens_to_line(&highlight_line(l)))
|
||||||
|
.collect();
|
||||||
|
let output_height = chunks[0].height.saturating_sub(2);
|
||||||
|
let max_scroll = (output_lines.len() as u16).saturating_sub(output_height);
|
||||||
|
let scroll = state.scroll.min(max_scroll);
|
||||||
|
let output = Paragraph::new(output_lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title(" Output ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::DarkGray)),
|
||||||
|
)
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.scroll((scroll, 0));
|
||||||
|
frame.render_widget(output, chunks[0]);
|
||||||
|
|
||||||
|
// Input pane
|
||||||
|
let input_line = tokens_to_line(&highlight_line(&state.input));
|
||||||
|
let input = Paragraph::new(input_line).block(
|
||||||
|
Block::default()
|
||||||
|
.title(" chase> ")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan)),
|
||||||
|
);
|
||||||
|
frame.render_widget(input, chunks[1]);
|
||||||
|
|
||||||
|
// Place cursor
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
let cursor_x = chunks[1].x + 1 + state.cursor as u16;
|
||||||
|
let cursor_y = chunks[1].y + 1;
|
||||||
|
frame.set_cursor_position((cursor_x, cursor_y));
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the TUI event loop.
|
||||||
|
pub fn run_tui() -> io::Result<()> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let mut state = TuiState::new();
|
||||||
|
let result = run_loop(&mut terminal, &mut state);
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_loop(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
|
state: &mut TuiState,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
draw(terminal, state)?;
|
||||||
|
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
if key.kind != KeyEventKind::Press {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
let should_quit =
|
||||||
|
state.input.trim() == "quit" || state.input.trim() == "exit";
|
||||||
|
state.submit();
|
||||||
|
if should_quit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
state.input.insert(state.cursor, c);
|
||||||
|
state.cursor += c.len_utf8();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if state.cursor > 0 {
|
||||||
|
state.cursor -= 1;
|
||||||
|
state.input.remove(state.cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
if state.cursor < state.input.len() {
|
||||||
|
state.input.remove(state.cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
if state.cursor > 0 {
|
||||||
|
state.cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
if state.cursor < state.input.len() {
|
||||||
|
state.cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
state.cursor = 0;
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
state.cursor = state.input.len();
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
state.history_up();
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
state.history_down();
|
||||||
|
}
|
||||||
|
KeyCode::PageUp => {
|
||||||
|
state.scroll = state.scroll.saturating_sub(10);
|
||||||
|
}
|
||||||
|
KeyCode::PageDown => {
|
||||||
|
state.scroll = state.scroll.saturating_add(10);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -101,7 +101,7 @@ fn http_response(status: &str, content_type: &str, body: &str) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDEX_HTML: &str = r#"<!DOCTYPE html>
|
const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
@ -218,7 +218,9 @@ const INDEX_HTML: &str = r#"<!DOCTYPE html>
|
|||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
min-height: 100%;
|
min-height: 22rem;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@ -265,22 +267,90 @@ explain Ancestor(alice, carol)?</textarea>
|
|||||||
const output = document.getElementById("output");
|
const output = document.getElementById("output");
|
||||||
const script = document.getElementById("script");
|
const script = document.getElementById("script");
|
||||||
|
|
||||||
|
const CMD_RE = /^(fact|rule|run|query|explain|show|reset|help|schema|sql)\b/i;
|
||||||
|
const SQL_KW = /^(SELECT|FROM|WHERE|GROUP|BY|ORDER|LIMIT|AS|AND|OR|ASC|DESC|NULL|NOT|ON|JOIN|HAVING|DISTINCT)\b/i;
|
||||||
|
const FUNC_RE = /^(COUNT|SUM|MIN|MAX|AVG)\b/i;
|
||||||
|
const VAR_RE = /^\?[A-Za-z_]\w*/;
|
||||||
|
const NUM_RE = /^\d+/;
|
||||||
|
const STR_RE = /^'(?:[^']|'')*'/;
|
||||||
|
const OP_RE = /^(->|!=|<>|[=,.*();?])/;
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
command: "#fff; font-weight:bold",
|
||||||
|
sqlkw: "#5599ff",
|
||||||
|
func: "#00bbbb",
|
||||||
|
variable: "#cc66ff",
|
||||||
|
string: "#44bb44",
|
||||||
|
number: "#ddaa00",
|
||||||
|
operator: "#cc4444",
|
||||||
|
comment: "#888",
|
||||||
|
};
|
||||||
|
|
||||||
|
function highlightLine(line) {
|
||||||
|
if (line.trimStart().startsWith("#"))
|
||||||
|
return `<span style="color:${COLORS.comment}">${esc(line)}</span>`;
|
||||||
|
let out = "", rest = line, first = true;
|
||||||
|
while (rest.length > 0) {
|
||||||
|
let m;
|
||||||
|
if ((m = rest.match(/^\s+/))) {
|
||||||
|
out += m[0]; rest = rest.slice(m[0].length); continue;
|
||||||
|
}
|
||||||
|
if ((m = rest.match(STR_RE))) {
|
||||||
|
out += `<span style="color:${COLORS.string}">${esc(m[0])}</span>`;
|
||||||
|
rest = rest.slice(m[0].length); continue;
|
||||||
|
}
|
||||||
|
if ((m = rest.match(OP_RE))) {
|
||||||
|
out += `<span style="color:${COLORS.operator}">${esc(m[0])}</span>`;
|
||||||
|
rest = rest.slice(m[0].length); continue;
|
||||||
|
}
|
||||||
|
if ((m = rest.match(VAR_RE))) {
|
||||||
|
out += `<span style="color:${COLORS.variable}">${esc(m[0])}</span>`;
|
||||||
|
rest = rest.slice(m[0].length); first = false; continue;
|
||||||
|
}
|
||||||
|
if ((m = rest.match(NUM_RE))) {
|
||||||
|
out += `<span style="color:${COLORS.number}">${esc(m[0])}</span>`;
|
||||||
|
rest = rest.slice(m[0].length); first = false; continue;
|
||||||
|
}
|
||||||
|
if ((m = rest.match(/^[A-Za-z_][\w\-:.]*/))) {
|
||||||
|
let word = m[0], color = null;
|
||||||
|
if (first && CMD_RE.test(word)) color = COLORS.command;
|
||||||
|
else if (SQL_KW.test(word)) color = COLORS.sqlkw;
|
||||||
|
else if (FUNC_RE.test(word)) color = COLORS.func;
|
||||||
|
if (color) out += `<span style="color:${color}">${esc(word)}</span>`;
|
||||||
|
else out += esc(word);
|
||||||
|
rest = rest.slice(word.length); first = false; continue;
|
||||||
|
}
|
||||||
|
out += esc(rest[0]); rest = rest.slice(1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return s.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightBlock(text) {
|
||||||
|
return text.split("\n").map(highlightLine).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
async function send(path, body) {
|
async function send(path, body) {
|
||||||
const response = await fetch(path, { method: "POST", body });
|
const response = await fetch(path, { method: "POST", body });
|
||||||
const text = await response.text();
|
return await response.text();
|
||||||
return text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("execute").addEventListener("click", async () => {
|
document.getElementById("execute").addEventListener("click", async () => {
|
||||||
const text = await send("/execute", script.value);
|
const text = await send("/execute", script.value);
|
||||||
output.textContent += `\n\n> ${new Date().toLocaleTimeString()}\n${text}`;
|
const ts = new Date().toLocaleTimeString();
|
||||||
|
const highlighted = highlightBlock(text);
|
||||||
|
output.innerHTML += `\n\n<span style="color:${COLORS.comment}">> ${esc(ts)}</span>\n${highlighted}`;
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("reset").addEventListener("click", async () => {
|
document.getElementById("reset").addEventListener("click", async () => {
|
||||||
const text = await send("/reset", "");
|
const text = await send("/reset", "");
|
||||||
output.textContent = text;
|
output.innerHTML = esc(text);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"#;
|
"##;
|
||||||
|
|||||||
@ -15,6 +15,8 @@ fn main() -> io::Result<()> {
|
|||||||
[cmd] if cmd.eq_ignore_ascii_case("gui") => serve_gui("127.0.0.1:7878"),
|
[cmd] if cmd.eq_ignore_ascii_case("gui") => serve_gui("127.0.0.1:7878"),
|
||||||
[cmd, address] if cmd.eq_ignore_ascii_case("gui") => serve_gui(address),
|
[cmd, address] if cmd.eq_ignore_ascii_case("gui") => serve_gui(address),
|
||||||
[cmd, path] if cmd.eq_ignore_ascii_case("script") => run_script(path),
|
[cmd, path] if cmd.eq_ignore_ascii_case("script") => run_script(path),
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
[cmd] if cmd.eq_ignore_ascii_case("tui") => query_engine::frontend::tui::app::run_tui(),
|
||||||
_ => {
|
_ => {
|
||||||
print_usage();
|
print_usage();
|
||||||
Ok(())
|
Ok(())
|
||||||
@ -39,4 +41,8 @@ fn print_usage() {
|
|||||||
println!(" query-engine repl");
|
println!(" query-engine repl");
|
||||||
println!(" query-engine gui [host:port]");
|
println!(" query-engine gui [host:port]");
|
||||||
println!(" query-engine script <path>");
|
println!(" query-engine script <path>");
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
println!(" query-engine tui");
|
||||||
|
#[cfg(not(feature = "tui"))]
|
||||||
|
println!(" query-engine tui (requires --features tui)");
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user