From d7b2eb414413e4c63a15a265f924be058c0d08da Mon Sep 17 00:00:00 2001 From: Hassan Abedi Date: Tue, 14 Apr 2026 10:16:41 +0200 Subject: [PATCH] Add TUI frontend and syntax highlighting (REPL and web UI) --- AGENTS.md | 2 +- Cargo.toml | 3 + README.md | 3 +- src/frontend/highlight.rs | 310 ++++++++++++++++++++++++++++++++++++++ src/frontend/mod.rs | 2 + src/frontend/repl.rs | 17 ++- src/frontend/tui.rs | 274 +++++++++++++++++++++++++++++++++ src/frontend/web.rs | 84 ++++++++++- src/main.rs | 6 + 9 files changed, 689 insertions(+), 12 deletions(-) create mode 100644 src/frontend/highlight.rs create mode 100644 src/frontend/tui.rs diff --git a/AGENTS.md b/AGENTS.md index 4115dab..0c3705f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/Cargo.toml b/Cargo.toml index 76dd8fb..b2a8244 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 4538fdc..0275da4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/frontend/highlight.rs b/src/frontend/highlight.rs new file mode 100644 index 0000000..a95d00d --- /dev/null +++ b/src/frontend/highlight.rs @@ -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, kind: TokenKind) -> Self { + Self { + text: text.into(), + kind, + } + } +} + +/// Tokenize a single line into highlighted tokens. +pub fn highlight_line(line: &str) -> Vec { + let trimmed = line.trim(); + if trimmed.starts_with('#') { + return vec![HighlightToken::new(line, TokenKind::Comment)]; + } + + let mut tokens = Vec::new(); + let chars: Vec = 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::>().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 = 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[")); + } +} diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 7504787..3d2d4f3 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -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; diff --git a/src/frontend/repl.rs b/src/frontend/repl.rs index 8a67882..8d0cc72 100644 --- a/src/frontend/repl.rs +++ b/src/frontend/repl.rs @@ -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)?, } } } diff --git a/src/frontend/tui.rs b/src/frontend/tui.rs new file mode 100644 index 0000000..c934bd2 --- /dev/null +++ b/src/frontend/tui.rs @@ -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, + scroll: u16, + history: Vec, + history_index: Option, + } + + 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> = tokens + .iter() + .map(|t| Span::styled(t.text.clone(), token_style(t.kind))) + .collect(); + Line::from(spans) + } + + fn draw(terminal: &mut Terminal>, 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> = 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>, + 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(()) + } +} diff --git a/src/frontend/web.rs b/src/frontend/web.rs index 28e85b9..c74630b 100644 --- a/src/frontend/web.rs +++ b/src/frontend/web.rs @@ -101,7 +101,7 @@ fn http_response(status: &str, content_type: &str, body: &str) -> String { ) } -const INDEX_HTML: &str = r#" +const INDEX_HTML: &str = r##" @@ -218,7 +218,9 @@ const INDEX_HTML: &str = r#" 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)? 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 `${esc(line)}`; + 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 += `${esc(m[0])}`; + rest = rest.slice(m[0].length); continue; + } + if ((m = rest.match(OP_RE))) { + out += `${esc(m[0])}`; + rest = rest.slice(m[0].length); continue; + } + if ((m = rest.match(VAR_RE))) { + out += `${esc(m[0])}`; + rest = rest.slice(m[0].length); first = false; continue; + } + if ((m = rest.match(NUM_RE))) { + out += `${esc(m[0])}`; + 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 += `${esc(word)}`; + 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,">"); + } + + 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> ${esc(ts)}\n${highlighted}`; + output.scrollTop = output.scrollHeight; }); document.getElementById("reset").addEventListener("click", async () => { const text = await send("/reset", ""); - output.textContent = text; + output.innerHTML = esc(text); }); -"#; +"##; diff --git a/src/main.rs b/src/main.rs index 5775c4e..f449c0d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 "); + #[cfg(feature = "tui")] + println!(" query-engine tui"); + #[cfg(not(feature = "tui"))] + println!(" query-engine tui (requires --features tui)"); }