2026-04-09 10:12:59 +02:00
|
|
|
//! Minimal local web UI for the query-engine frontend language.
|
|
|
|
|
|
|
|
|
|
use std::io::{self, BufRead, BufReader, Read, Write};
|
|
|
|
|
use std::net::{TcpListener, TcpStream};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
|
|
|
|
|
|
use super::Session;
|
|
|
|
|
|
|
|
|
|
pub fn serve_gui(address: &str) -> io::Result<()> {
|
|
|
|
|
let listener = TcpListener::bind(address)?;
|
|
|
|
|
let session = Arc::new(Mutex::new(Session::new()));
|
|
|
|
|
|
|
|
|
|
println!("GUI available at http://{}", address);
|
|
|
|
|
|
|
|
|
|
for stream in listener.incoming() {
|
|
|
|
|
match stream {
|
|
|
|
|
Ok(stream) => {
|
|
|
|
|
let shared = Arc::clone(&session);
|
|
|
|
|
if let Err(err) = handle_connection(stream, &shared) {
|
|
|
|
|
eprintln!("gui error: {}", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(err) => eprintln!("gui accept error: {}", err),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn handle_connection(mut stream: TcpStream, session: &Arc<Mutex<Session>>) -> io::Result<()> {
|
|
|
|
|
let mut reader = BufReader::new(stream.try_clone()?);
|
|
|
|
|
let mut request_line = String::new();
|
|
|
|
|
reader.read_line(&mut request_line)?;
|
|
|
|
|
if request_line.trim().is_empty() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut parts = request_line.split_whitespace();
|
|
|
|
|
let method = parts.next().unwrap_or_default();
|
|
|
|
|
let path = parts.next().unwrap_or("/");
|
|
|
|
|
|
|
|
|
|
let mut content_length = 0usize;
|
|
|
|
|
loop {
|
|
|
|
|
let mut header = String::new();
|
|
|
|
|
reader.read_line(&mut header)?;
|
|
|
|
|
if header == "\r\n" || header.is_empty() {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if let Some((name, value)) = header.split_once(':')
|
|
|
|
|
&& name.eq_ignore_ascii_case("content-length")
|
|
|
|
|
{
|
|
|
|
|
let parsed = value.trim().parse::<usize>().ok();
|
|
|
|
|
if let Some(length) = parsed {
|
|
|
|
|
content_length = length;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut body = vec![0u8; content_length];
|
|
|
|
|
if content_length > 0 {
|
|
|
|
|
reader.read_exact(&mut body)?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let response = match (method, path) {
|
|
|
|
|
("GET", "/") => http_response("200 OK", "text/html; charset=utf-8", INDEX_HTML),
|
|
|
|
|
("POST", "/execute") => {
|
|
|
|
|
let script = String::from_utf8_lossy(&body);
|
|
|
|
|
let output = {
|
|
|
|
|
let mut locked = session
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|_| io::Error::other("session lock poisoned"))?;
|
|
|
|
|
match locked.execute_script(script.as_ref()) {
|
|
|
|
|
Ok(output) => output,
|
|
|
|
|
Err(err) => format!("error: {}", err),
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
http_response("200 OK", "text/plain; charset=utf-8", &output)
|
|
|
|
|
}
|
|
|
|
|
("POST", "/reset") => {
|
|
|
|
|
let mut locked = session
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|_| io::Error::other("session lock poisoned"))?;
|
|
|
|
|
locked.reset();
|
|
|
|
|
http_response("200 OK", "text/plain; charset=utf-8", "Session reset.")
|
|
|
|
|
}
|
|
|
|
|
_ => http_response("404 Not Found", "text/plain; charset=utf-8", "Not found"),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
stream.write_all(response.as_bytes())?;
|
|
|
|
|
stream.flush()?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn http_response(status: &str, content_type: &str, body: &str) -> String {
|
|
|
|
|
format!(
|
|
|
|
|
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
|
|
|
status,
|
|
|
|
|
content_type,
|
|
|
|
|
body.len(),
|
|
|
|
|
body
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 10:16:41 +02:00
|
|
|
const INDEX_HTML: &str = r##"<!DOCTYPE html>
|
2026-04-09 10:12:59 +02:00
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<title>query-engine GUI</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
--bg: #f4efe6;
|
|
|
|
|
--panel: rgba(255, 251, 245, 0.9);
|
|
|
|
|
--ink: #1f1d1a;
|
|
|
|
|
--accent: #b5542f;
|
|
|
|
|
--accent-soft: #e7c7b8;
|
|
|
|
|
--border: #d7c7b8;
|
|
|
|
|
--shadow: rgba(78, 52, 38, 0.14);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
|
|
|
|
|
body {
|
|
|
|
|
margin: 0;
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
font-family: "Iosevka", "SFMono-Regular", "Menlo", monospace;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
background:
|
|
|
|
|
radial-gradient(circle at top left, rgba(181, 84, 47, 0.18), transparent 28rem),
|
|
|
|
|
linear-gradient(135deg, #efe4d4, var(--bg));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
main {
|
|
|
|
|
width: min(1100px, calc(100% - 2rem));
|
|
|
|
|
margin: 2rem auto;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
section {
|
|
|
|
|
background: var(--panel);
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
box-shadow: 0 18px 50px var(--shadow);
|
|
|
|
|
backdrop-filter: blur(10px);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.editor {
|
|
|
|
|
padding: 1.25rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.header {
|
|
|
|
|
display: flex;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h1, h2, p {
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
h1 {
|
|
|
|
|
font-size: clamp(1.5rem, 2vw, 2rem);
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p {
|
|
|
|
|
color: rgba(31, 29, 26, 0.74);
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
textarea {
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: 22rem;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 14px;
|
|
|
|
|
background: #fffdfa;
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
resize: vertical;
|
|
|
|
|
font: inherit;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
margin-top: 1rem;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
button {
|
|
|
|
|
border: 0;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
padding: 0.8rem 1.2rem;
|
|
|
|
|
font: inherit;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: transform 140ms ease, opacity 140ms ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
button:hover { transform: translateY(-1px); }
|
|
|
|
|
button:active { transform: translateY(0); }
|
|
|
|
|
|
|
|
|
|
.primary {
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
color: #fff8f3;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.secondary {
|
|
|
|
|
background: var(--accent-soft);
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pre {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 1.25rem;
|
2026-04-14 10:16:41 +02:00
|
|
|
min-height: 22rem;
|
|
|
|
|
max-height: 70vh;
|
|
|
|
|
overflow-y: auto;
|
2026-04-09 10:12:59 +02:00
|
|
|
white-space: pre-wrap;
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.output {
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sample {
|
|
|
|
|
font-size: 0.92rem;
|
|
|
|
|
padding: 0 1.25rem 1.25rem;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main>
|
|
|
|
|
<section class="editor">
|
|
|
|
|
<div class="header">
|
|
|
|
|
<div>
|
|
|
|
|
<h1>query-engine</h1>
|
|
|
|
|
<p>Minimal local workbench for rule-driven query experiments.</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<textarea id="script">fact Parent(alice, bob).
|
|
|
|
|
fact Parent(bob, carol).
|
|
|
|
|
rule Parent(?X, ?Y) -> Ancestor(?X, ?Y).
|
|
|
|
|
rule Ancestor(?X, ?Y), Parent(?Y, ?Z) -> Ancestor(?X, ?Z).
|
|
|
|
|
run.
|
|
|
|
|
explain Ancestor(alice, carol)?</textarea>
|
|
|
|
|
<div class="actions">
|
|
|
|
|
<button class="primary" id="execute">Execute</button>
|
|
|
|
|
<button class="secondary" id="reset">Reset Session</button>
|
|
|
|
|
</div>
|
|
|
|
|
<p class="sample">Try <code>query Ancestor(?X, ?Y)?</code>, <code>explain Ancestor(alice, carol)?</code>, or boolean queries like <code>query Parent(alice, bob)?</code>.</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section class="output">
|
|
|
|
|
<pre id="output">Session ready.</pre>
|
|
|
|
|
</section>
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
const output = document.getElementById("output");
|
|
|
|
|
const script = document.getElementById("script");
|
|
|
|
|
|
2026-04-14 10:16:41 +02:00
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 10:12:59 +02:00
|
|
|
async function send(path, body) {
|
|
|
|
|
const response = await fetch(path, { method: "POST", body });
|
2026-04-14 10:16:41 +02:00
|
|
|
return await response.text();
|
2026-04-09 10:12:59 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.getElementById("execute").addEventListener("click", async () => {
|
|
|
|
|
const text = await send("/execute", script.value);
|
2026-04-14 10:16:41 +02:00
|
|
|
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;
|
2026-04-09 10:12:59 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.getElementById("reset").addEventListener("click", async () => {
|
|
|
|
|
const text = await send("/reset", "");
|
2026-04-14 10:16:41 +02:00
|
|
|
output.innerHTML = esc(text);
|
2026-04-09 10:12:59 +02:00
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|
2026-04-14 10:16:41 +02:00
|
|
|
"##;
|