const RESET: &str = "\x1b[0m";
const KEYWORD: &str = "\x1b[1;36m"; const STRING: &str = "\x1b[32m"; const NUMBER: &str = "\x1b[35m"; const OPERATOR: &str = "\x1b[33m"; const PATH: &str = "\x1b[34m"; const REGISTER: &str = "\x1b[36m";
const OPERATORS: &[&str] = &[
";;;", ":::", "::;", "::", "<=>?", "<=>", "~>", "<~", "->", "<-", "@|", "&&", "||", "=~", "?=",
">=", "<=", "!=", "*=", "|", "!", "=", "<", ">", "+", "{", "}", "?", "(", ")", "[", "]", ",",
];
#[derive(Clone, Copy)]
enum Class {
Keyword,
Str,
Number,
Operator,
Path,
Register,
}
impl Class {
fn ansi(self) -> &'static str {
match self {
Class::Keyword => KEYWORD,
Class::Str => STRING,
Class::Number => NUMBER,
Class::Operator => OPERATOR,
Class::Path => PATH,
Class::Register => REGISTER,
}
}
fn css(self) -> &'static str {
match self {
Class::Keyword => "qh-keyword",
Class::Str => "qh-string",
Class::Number => "qh-number",
Class::Operator => "qh-operator",
Class::Path => "qh-path",
Class::Register => "qh-register",
}
}
}
fn is_name_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_'
}
fn is_name_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
fn scan(src: &str) -> Vec<(Option<Class>, &str)> {
let b = src.as_bytes();
let mut spans: Vec<(Option<Class>, &str)> = Vec::new();
let mut i = 0;
while i < b.len() {
let c = b[i] as char;
if c.is_ascii_whitespace() {
let start = i;
i += 1;
while i < b.len() && (b[i] as char).is_ascii_whitespace() {
i += 1;
}
spans.push((None, &src[start..i]));
continue;
}
if c == '"' || c == '\'' {
let quote = c;
let start = i;
i += 1;
while i < b.len() {
let ch = b[i] as char;
i += 1;
if ch == '\\' {
i += 1;
} else if ch == quote {
break;
}
}
spans.push((Some(Class::Str), &src[start..i.min(b.len())]));
continue;
}
if (c == '$' || c == '@' || c == '%') && !(c == '@' && b.get(i + 1) == Some(&b'|')) {
let start = i;
i += 1;
while i < b.len() && matches!(b[i] as char, '.' | '*' | '$' | '-') {
i += 1;
}
while i < b.len() && is_name_char(b[i] as char) {
i += 1;
}
spans.push((Some(Class::Register), &src[start..i]));
continue;
}
if c.is_ascii_digit() {
let start = i;
while i < b.len() && (b[i] as char).is_ascii_digit() {
i += 1;
}
if i < b.len()
&& b[i] as char == '.'
&& i + 1 < b.len()
&& (b[i + 1] as char).is_ascii_digit()
{
i += 1;
while i < b.len() && (b[i] as char).is_ascii_digit() {
i += 1;
}
}
if i < b.len() && (b[i] as char).is_ascii_alphabetic() {
while i < b.len()
&& matches!(b[i] as char, 'a'..='z' | 'A'..='Z' | '0'..='9' | '^' | '*' | '/' | '%' | '-')
{
i += 1;
}
}
spans.push((Some(Class::Number), &src[start..i]));
continue;
}
if c == '.' && i + 1 < b.len() && is_name_start(b[i + 1] as char) {
let start = i;
i += 1;
while i < b.len() && is_name_char(b[i] as char) {
i += 1;
}
spans.push((Some(Class::Register), &src[start..i]));
continue;
}
if is_name_start(c) {
let start = i;
while i < b.len() && is_name_char(b[i] as char) {
i += 1;
}
let word = &src[start..i];
spans.push((is_keyword(word).then_some(Class::Keyword), word));
continue;
}
if c == '/' {
if i + 1 < b.len() && b[i + 1] as char == '/' {
spans.push((Some(Class::Path), &src[i..i + 2]));
i += 2;
} else {
spans.push((Some(Class::Path), &src[i..i + 1]));
i += 1;
}
continue;
}
if let Some(op) = OPERATORS.iter().find(|op| src[i..].starts_with(**op)) {
spans.push((Some(Class::Operator), &src[i..i + op.len()]));
i += op.len();
continue;
}
let start = i;
i += 1;
while i < b.len() && (b[i] & 0xC0) == 0x80 {
i += 1;
}
spans.push((None, &src[start..i]));
}
spans
}
pub fn highlight_ansi(src: &str) -> String {
let mut out = String::with_capacity(src.len() + 32);
for (class, s) in scan(src) {
match class {
Some(c) => {
out.push_str(c.ansi());
out.push_str(s);
out.push_str(RESET);
}
None => out.push_str(s),
}
}
out
}
pub fn highlight_html(src: &str) -> String {
let mut out = String::with_capacity(src.len() + 64);
for (class, s) in scan(src) {
match class {
Some(c) => {
out.push_str("<span class=\"");
out.push_str(c.css());
out.push_str("\">");
escape_into(&mut out, s);
out.push_str("</span>");
}
None => escape_into(&mut out, s),
}
}
out
}
fn escape_into(out: &mut String, s: &str) {
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
_ => out.push(ch),
}
}
}
fn is_keyword(word: &str) -> bool {
crate::stdlib::known_scalar(word)
|| crate::stdlib::known_agg(word)
|| crate::stdlib::known_keyed(word)
|| matches!(word, "def" | "macro" | "not" | "and" | "or")
}
#[cfg(test)]
mod tests {
use super::*;
fn strip(s: &str) -> String {
let mut out = String::new();
let b = s.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == 0x1b {
while i < b.len() && b[i] != b'm' {
i += 1;
}
i += 1;
} else {
out.push(b[i] as char);
i += 1;
}
}
out
}
#[test]
fn preserves_source_exactly() {
for q in [
"/books/*[/price:: > 20]/title::",
"/@hosts/*[::draw > 0.2kW]::name",
"/a/* <=> /b/*[::k = $*1::k] | rec(\"x\", ::v)",
"//function_item @| count | .n | %.",
] {
assert_eq!(strip(&highlight_ansi(q)), q, "round-trip: {q}");
}
}
#[test]
fn colors_the_right_tokens() {
let h = highlight_ansi("/x/* | rec(\"a\", ::v) @| count");
assert!(h.contains(&format!("{KEYWORD}rec{RESET}")));
assert!(h.contains(&format!("{KEYWORD}count{RESET}")));
assert!(h.contains(&format!("{STRING}\"a\"{RESET}")));
assert!(h.contains(&format!("{PATH}/{RESET}")));
let u = highlight_ansi("[::draw > 0.2kW]");
assert!(u.contains(&format!("{NUMBER}0.2kW{RESET}")));
assert!(!highlight_ansi("::draw").contains(&format!("{KEYWORD}draw")));
}
#[test]
fn html_escapes_and_wraps() {
let h = highlight_html("/a/*[::v > 1] | rec(\"x\", ::v)");
assert!(h.contains("<span class=\"qh-path\">/</span>"));
assert!(h.contains("<span class=\"qh-keyword\">rec</span>"));
assert!(h.contains("<span class=\"qh-operator\">></span>"));
assert!(h.contains("<span class=\"qh-string\">"x"</span>"));
}
}