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.
|
||||
- `stratification.rs`: stratification analysis for rules with negation.
|
||||
- `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/catalog/`: predicate-to-table schema inference and catalog access.
|
||||
- `src/io/`: CSV-based fact import and export.
|
||||
|
||||
@ -29,8 +29,11 @@ path = "src/main.rs"
|
||||
[features]
|
||||
default = [] # No features enabled by default
|
||||
binaries = []
|
||||
tui = ["dep:ratatui", "dep:crossterm"]
|
||||
|
||||
[dependencies]
|
||||
ratatui = { version = "0.29", optional = true, default-features = false, features = ["crossterm"] }
|
||||
crossterm = { version = "0.28", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1.6"
|
||||
|
||||
@ -13,7 +13,7 @@ execution boundaries.
|
||||
- Restricted, standard, oblivious, and Skolem chase variants
|
||||
- Optional semi-naive evaluation across all chase variants
|
||||
- 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
|
||||
- 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
|
||||
@ -99,6 +99,7 @@ cargo run -- repl
|
||||
cargo run -- gui
|
||||
cargo run -- script examples/scripts/ancestor.ech
|
||||
cargo run -- script examples/scripts/sql_join.ech
|
||||
cargo run --features tui -- tui
|
||||
```
|
||||
|
||||
#### 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.
|
||||
|
||||
pub mod highlight;
|
||||
pub mod language;
|
||||
pub mod provenance;
|
||||
pub mod repl;
|
||||
pub mod session;
|
||||
pub mod tui;
|
||||
pub mod web;
|
||||
|
||||
pub use repl::run_repl;
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
//! 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::highlight::to_ansi_block;
|
||||
|
||||
pub fn run_repl() -> io::Result<()> {
|
||||
let stdin = io::stdin();
|
||||
let mut stdout = io::stdout();
|
||||
let mut session = Session::new();
|
||||
let color = stdout.is_terminal();
|
||||
|
||||
writeln!(stdout, "query-engine REPL")?;
|
||||
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) {
|
||||
Ok(output) => writeln!(stdout, "{}", output)?,
|
||||
Err(err) => writeln!(stdout, "error: {}", err)?,
|
||||
Ok(output) => {
|
||||
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">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
@ -218,7 +218,9 @@ const INDEX_HTML: &str = r#"<!DOCTYPE html>
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 1.25rem;
|
||||
min-height: 100%;
|
||||
min-height: 22rem;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
@ -265,22 +267,90 @@ explain Ancestor(alice, carol)?</textarea>
|
||||
const output = document.getElementById("output");
|
||||
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) {
|
||||
const response = await fetch(path, { method: "POST", body });
|
||||
const text = await response.text();
|
||||
return text;
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
document.getElementById("execute").addEventListener("click", async () => {
|
||||
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 () => {
|
||||
const text = await send("/reset", "");
|
||||
output.textContent = text;
|
||||
output.innerHTML = esc(text);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</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, address] if cmd.eq_ignore_ascii_case("gui") => serve_gui(address),
|
||||
[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();
|
||||
Ok(())
|
||||
@ -39,4 +41,8 @@ fn print_usage() {
|
||||
println!(" query-engine repl");
|
||||
println!(" query-engine gui [host:port]");
|
||||
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