Skip to main content

rich_ext/
sql.rs

1//! A small SQL highlighter in pure Rust: no syntect, no C, no dependencies.
2//!
3//! [`SqlHighlighter`] is a [`CodeHighlighter`] built on a hand-written lexer
4//! ([`tokenize`]). It knows standard SQL and the common words of PostgreSQL,
5//! MySQL, SQLite, BigQuery, Snowflake and DuckDB, and treats Jinja blocks
6//! (`{{ … }}`, `{% … %}`, `{# … #}`), as dbt models use them, as a token class
7//! of their own. It answers to the language `sql` and the aliases in
8//! [`LANGUAGES`]; any other language is plain text, or goes to a fallback
9//! highlighter ([`SqlHighlighter::with_fallback`]).
10//!
11//! Turn on the `sql` feature. It needs neither `syntax` nor `markdown`:
12//! [`highlight`] gives a styled [`Text`] in a lean build, and with `syntax`
13//! the highlighter serves `Syntax` and Markdown's ```` ```sql ```` fences
14//! through the console's code highlighting.
15//!
16//! ```
17//! use rich::protocol::{CodeHighlighting, ConsoleCodeHighlighting};
18//! use rich::{ColorSystem, Console};
19//! use rich_ext::sql::{self, SqlHighlighter};
20//!
21//! // A styled `Text`, no `Syntax` needed.
22//! let text = sql::highlight("SELECT 1 -- one", "ansi_dark");
23//! assert_eq!(text.plain(), "SELECT 1 -- one");
24//!
25//! // Or every SQL code block a console renders.
26//! let mut console = Console::builder()
27//!     .force_terminal(true)
28//!     .color_system(Some(ColorSystem::Standard))
29//!     .build();
30//! console.set_code_highlighting(Some(CodeHighlighting {
31//!     highlighter: SqlHighlighter::shared(),
32//!     theme: Some("ansi_dark".into()),
33//! }));
34//! # #[cfg(feature = "syntax")] {
35//! let out = console.render_to_string(&rich::Syntax::new("SELECT 1", "sql"));
36//! assert!(out.contains("\x1b[94mSELECT"), "{out:?}");
37//! # }
38//! ```
39//!
40//! # Themes
41//!
42//! `monokai` (the default, upstream's default code theme), and `ansi_dark`
43//! and `ansi_light`, upstream's themes in the terminal's own 16 colours. A
44//! theme this highlighter does not know highlights SQL with `monokai`, so a
45//! console set up for another engine's theme still colours SQL.
46
47use std::collections::HashMap;
48use std::ops::Range;
49use std::path::Path;
50use std::sync::{Arc, OnceLock};
51
52use rich::Text;
53use rich::color::Color;
54use rich::protocol::{
55    CodeHighlighter, HighlightError, HighlightSpan, HighlightedCode, HighlightedLine,
56};
57use rich::style::Style;
58use rich_plugin_api::{Plugin, PluginError, PluginMetadata, PluginRegistrar};
59
60/// The language names this highlighter answers to.
61pub const LANGUAGES: &[&str] = &[
62    "sql",
63    "postgresql",
64    "postgres",
65    "psql",
66    "plpgsql",
67    "mysql",
68    "mariadb",
69    "sqlite",
70    "sqlite3",
71    "bigquery",
72    "snowflake",
73    "duckdb",
74    "tsql",
75    "plsql",
76];
77
78/// The themes this highlighter has.
79pub const THEMES: &[&str] = &["ansi_dark", "ansi_light", "monokai"];
80
81/// The theme used when none is chosen.
82pub const DEFAULT_THEME: &str = "monokai";
83
84/// What a token is.
85#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
86pub enum TokenKind {
87    /// Spaces, tabs and line breaks.
88    Whitespace,
89    /// A reserved or dialect keyword (`SELECT`, `QUALIFY`, `PRAGMA`).
90    Keyword,
91    /// A data type (`VARCHAR`, `INT64`, `TIMESTAMPTZ`).
92    Type,
93    /// `TRUE`, `FALSE`, `NULL`.
94    Constant,
95    /// A name followed by `(`: a function call.
96    Function,
97    /// A name: a table, column, alias or schema.
98    Name,
99    /// A quoted name: `"col"`, `` `col` ``, `[col]`.
100    QuotedName,
101    /// A string literal: `'it''s'`, `E'\n'`, `$$body$$`, `'''raw'''`.
102    String,
103    /// A number: `42`, `1.5e-3`, `.5`, `0xFF`.
104    Number,
105    /// A comment: `-- …` or `/* … */` (nested).
106    Comment,
107    /// A bind parameter: `:name`, `@name`, `@@var`, `$1`, `?`.
108    Parameter,
109    /// An operator: `=`, `<>`, `||`, `::`, `->>`.
110    Operator,
111    /// Punctuation: `( ) , ; . [ ] { }`.
112    Punctuation,
113    /// A Jinja block: `{{ ref('x') }}`, `{% if … %}`, `{# … #}`.
114    Template,
115    /// Anything else (a stray character).
116    Other,
117}
118
119/// A token: its kind and its byte range in the source.
120#[derive(Clone, Debug, PartialEq, Eq)]
121pub struct Token {
122    pub kind: TokenKind,
123    pub range: Range<usize>,
124}
125
126const KEYWORDS: &[&str] = &[
127    "abort",
128    "absolute",
129    "action",
130    "add",
131    "after",
132    "all",
133    "alter",
134    "always",
135    "analyse",
136    "analyze",
137    "and",
138    "any",
139    "array",
140    "as",
141    "asc",
142    "asof",
143    "attach",
144    "authorization",
145    "autoincrement",
146    "auto_increment",
147    "before",
148    "begin",
149    "between",
150    "both",
151    "by",
152    "cascade",
153    "case",
154    "cast",
155    "chain",
156    "check",
157    "cluster",
158    "collate",
159    "column",
160    "columns",
161    "comment",
162    "commit",
163    "concurrently",
164    "conflict",
165    "connect",
166    "constraint",
167    "copy",
168    "create",
169    "cross",
170    "cube",
171    "current",
172    "current_date",
173    "current_time",
174    "current_timestamp",
175    "current_user",
176    "cursor",
177    "database",
178    "deallocate",
179    "declare",
180    "default",
181    "deferrable",
182    "deferred",
183    "delete",
184    "desc",
185    "describe",
186    "detach",
187    "distinct",
188    "do",
189    "drop",
190    "each",
191    "else",
192    "elseif",
193    "elsif",
194    "end",
195    "engine",
196    "escape",
197    "except",
198    "exclude",
199    "exclusive",
200    "execute",
201    "exists",
202    "explain",
203    "extension",
204    "fetch",
205    "filter",
206    "first",
207    "following",
208    "for",
209    "foreign",
210    "format",
211    "from",
212    "full",
213    "function",
214    "glob",
215    "grant",
216    "group",
217    "grouping",
218    "having",
219    "if",
220    "ignore",
221    "ilike",
222    "immediate",
223    "in",
224    "include",
225    "index",
226    "inherits",
227    "initially",
228    "inner",
229    "insert",
230    "instead",
231    "intersect",
232    "interval",
233    "into",
234    "is",
235    "isnull",
236    "join",
237    "key",
238    "language",
239    "last",
240    "lateral",
241    "leading",
242    "left",
243    "like",
244    "limit",
245    "load",
246    "local",
247    "lock",
248    "loop",
249    "match",
250    "matched",
251    "materialized",
252    "merge",
253    "minus",
254    "natural",
255    "next",
256    "no",
257    "not",
258    "nothing",
259    "notnull",
260    "nulls",
261    "of",
262    "offset",
263    "on",
264    "only",
265    "option",
266    "options",
267    "or",
268    "order",
269    "ordinality",
270    "outer",
271    "over",
272    "overwrite",
273    "owner",
274    "partition",
275    "pivot",
276    "pragma",
277    "preceding",
278    "primary",
279    "procedure",
280    "qualify",
281    "range",
282    "recursive",
283    "references",
284    "regexp",
285    "reindex",
286    "release",
287    "rename",
288    "repeat",
289    "replace",
290    "restrict",
291    "return",
292    "returning",
293    "returns",
294    "revoke",
295    "right",
296    "rlike",
297    "rollback",
298    "rollup",
299    "row",
300    "rows",
301    "sample",
302    "savepoint",
303    "schema",
304    "select",
305    "semi",
306    "sequence",
307    "set",
308    "sets",
309    "show",
310    "similar",
311    "some",
312    "stage",
313    "start",
314    "stream",
315    "struct",
316    "summarize",
317    "table",
318    "tablesample",
319    "temp",
320    "temporary",
321    "then",
322    "ties",
323    "to",
324    "top",
325    "trailing",
326    "transaction",
327    "trigger",
328    "truncate",
329    "unbounded",
330    "union",
331    "unique",
332    "unnest",
333    "unpivot",
334    "until",
335    "update",
336    "upsert",
337    "use",
338    "user",
339    "using",
340    "vacuum",
341    "values",
342    "view",
343    "virtual",
344    "volatile",
345    "warehouse",
346    "when",
347    "where",
348    "while",
349    "window",
350    "with",
351    "within",
352    "without",
353    "work",
354];
355
356const TYPES: &[&str] = &[
357    "bigint",
358    "bigserial",
359    "binary",
360    "bit",
361    "blob",
362    "bool",
363    "boolean",
364    "bpchar",
365    "bytea",
366    "bytes",
367    "char",
368    "character",
369    "cidr",
370    "clob",
371    "date",
372    "datetime",
373    "datetime2",
374    "dec",
375    "decimal",
376    "double",
377    "enum",
378    "float",
379    "float4",
380    "float8",
381    "float64",
382    "geography",
383    "geometry",
384    "hugeint",
385    "inet",
386    "int",
387    "int2",
388    "int4",
389    "int8",
390    "int64",
391    "integer",
392    "json",
393    "jsonb",
394    "list",
395    "long",
396    "map",
397    "mediumint",
398    "money",
399    "nchar",
400    "number",
401    "numeric",
402    "nvarchar",
403    "object",
404    "precision",
405    "real",
406    "serial",
407    "smallint",
408    "smallserial",
409    "string",
410    "text",
411    "time",
412    "timestamp",
413    "timestamp_ltz",
414    "timestamp_ntz",
415    "timestamp_tz",
416    "timestamptz",
417    "timetz",
418    "tinyint",
419    "ubigint",
420    "uinteger",
421    "usmallint",
422    "utinyint",
423    "uuid",
424    "varbinary",
425    "varchar",
426    "variant",
427    "xml",
428];
429
430const CONSTANTS: &[&str] = &["false", "null", "true", "unknown"];
431
432/// The kind a word list gives, by lower-case word.
433fn word_kinds() -> &'static HashMap<&'static str, TokenKind> {
434    static KINDS: OnceLock<HashMap<&'static str, TokenKind>> = OnceLock::new();
435    KINDS.get_or_init(|| {
436        let mut kinds = HashMap::new();
437        for (list, kind) in [
438            (KEYWORDS, TokenKind::Keyword),
439            (TYPES, TokenKind::Type),
440            (CONSTANTS, TokenKind::Constant),
441        ] {
442            for word in list {
443                kinds.insert(*word, kind);
444            }
445        }
446        kinds
447    })
448}
449
450fn word_kind(word: &str) -> Option<TokenKind> {
451    if word.len() > 24 {
452        return None;
453    }
454    word_kinds()
455        .get(word.to_ascii_lowercase().as_str())
456        .copied()
457}
458
459fn is_ident_start(ch: char) -> bool {
460    ch.is_alphabetic() || ch == '_'
461}
462
463fn is_ident_char(ch: char) -> bool {
464    ch.is_alphanumeric() || ch == '_' || ch == '$'
465}
466
467const OPERATOR_CHARS: &str = "+-*/%=<>!~^&|#";
468
469/// Split `code` into tokens covering every byte, in order.
470///
471/// Never fails: an unterminated string, comment, quoted name or Jinja block
472/// runs to the end of the input.
473///
474/// ```
475/// use rich_ext::sql::{tokenize, TokenKind};
476///
477/// let code = "select \"a b\" from t -- note";
478/// let kinds: Vec<(TokenKind, &str)> = tokenize(code)
479///     .into_iter()
480///     .filter(|t| t.kind != TokenKind::Whitespace)
481///     .map(|t| (t.kind, &code[t.range]))
482///     .collect();
483/// assert_eq!(kinds, [
484///     (TokenKind::Keyword, "select"),
485///     (TokenKind::QuotedName, "\"a b\""),
486///     (TokenKind::Keyword, "from"),
487///     (TokenKind::Name, "t"),
488///     (TokenKind::Comment, "-- note"),
489/// ]);
490/// ```
491pub fn tokenize(code: &str) -> Vec<Token> {
492    Lexer {
493        code,
494        at: 0,
495        tokens: Vec::new(),
496    }
497    .run()
498}
499
500struct Lexer<'a> {
501    code: &'a str,
502    at: usize,
503    tokens: Vec<Token>,
504}
505
506impl Lexer<'_> {
507    fn rest(&self) -> &str {
508        &self.code[self.at..]
509    }
510
511    fn peek(&self) -> Option<char> {
512        self.rest().chars().next()
513    }
514
515    fn peek_at(&self, n: usize) -> Option<char> {
516        self.rest().chars().nth(n)
517    }
518
519    fn push(&mut self, kind: TokenKind, end: usize) {
520        let end = end.clamp(self.at, self.code.len());
521        if end > self.at {
522            self.tokens.push(Token {
523                kind,
524                range: self.at..end,
525            });
526        }
527        self.at = end;
528    }
529
530    /// The end of the first `close` at or after byte `from`, or the end of input.
531    fn until(&self, from: usize, close: &str) -> usize {
532        self.code
533            .get(from..)
534            .and_then(|rest| rest.find(close))
535            .map_or(self.code.len(), |found| from + found + close.len())
536    }
537
538    /// The last token that is not whitespace or a comment.
539    fn previous(&self) -> Option<&Token> {
540        self.tokens
541            .iter()
542            .rev()
543            .find(|t| !matches!(t.kind, TokenKind::Whitespace | TokenKind::Comment))
544    }
545
546    fn run(mut self) -> Vec<Token> {
547        while let Some(ch) = self.peek() {
548            let start = self.at;
549            match ch {
550                c if c.is_whitespace() => {
551                    let len = self
552                        .rest()
553                        .find(|c: char| !c.is_whitespace())
554                        .unwrap_or(self.rest().len());
555                    self.push(TokenKind::Whitespace, start + len);
556                }
557                '-' if self.peek_at(1) == Some('-') => {
558                    let end = self
559                        .rest()
560                        .find('\n')
561                        .map_or(self.code.len(), |n| start + n);
562                    self.push(TokenKind::Comment, end);
563                }
564                '/' if self.peek_at(1) == Some('*') => {
565                    let end = self.block_comment();
566                    self.push(TokenKind::Comment, end);
567                }
568                '{' if matches!(self.peek_at(1), Some('{' | '%' | '#')) => {
569                    let close = match self.peek_at(1) {
570                        Some('{') => "}}",
571                        Some('%') => "%}",
572                        _ => "#}",
573                    };
574                    let end = self.until(start + 2, close);
575                    self.push(TokenKind::Template, end);
576                }
577                '\'' => {
578                    let end = self.quoted_string(start, '\'', false);
579                    self.push(TokenKind::String, end);
580                }
581                '"' => {
582                    let body = start + 3;
583                    if self.rest().starts_with("\"\"\"")
584                        && !self.code[body..].starts_with('"')
585                        && self.code[body..].contains("\"\"\"")
586                    {
587                        let end = self.until(body, "\"\"\"");
588                        self.push(TokenKind::String, end);
589                    } else {
590                        let end = self.doubled(start, '"');
591                        self.push(TokenKind::QuotedName, end);
592                    }
593                }
594                '`' => {
595                    let end = self.doubled(start, '`');
596                    self.push(TokenKind::QuotedName, end);
597                }
598                '[' if self.bracket_is_name() => {
599                    let end = self.doubled(start, ']');
600                    self.push(TokenKind::QuotedName, end);
601                }
602                '$' => self.dollar(start),
603                '0'..='9' => {
604                    let end = self.number(start);
605                    self.push(TokenKind::Number, end);
606                }
607                '.' if self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
608                    && !self.previous().is_some_and(|t| {
609                        matches!(t.kind, TokenKind::Name | TokenKind::QuotedName)
610                            && t.range.end == start
611                    }) =>
612                {
613                    let end = self.number(start);
614                    self.push(TokenKind::Number, end);
615                }
616                ':' if self.peek_at(1) == Some(':') => self.push(TokenKind::Operator, start + 2),
617                ':' if self.peek_at(1) == Some('=') => self.push(TokenKind::Operator, start + 2),
618                ':' if self.peek_at(1).is_some_and(is_ident_start) => {
619                    let end = self.word_end(start + 1);
620                    self.push(TokenKind::Parameter, end);
621                }
622                '@' if self
623                    .peek_at(1)
624                    .is_some_and(|c| is_ident_start(c) || c == '@') =>
625                {
626                    let skip = if self.peek_at(1) == Some('@') { 2 } else { 1 };
627                    let end = self.word_end(start + skip);
628                    self.push(TokenKind::Parameter, end);
629                }
630                '?' => self.push(TokenKind::Parameter, start + 1),
631                c if is_ident_start(c) => self.word(start),
632                c if OPERATOR_CHARS.contains(c) || c == '@' => {
633                    let end = self.operator(start);
634                    self.push(TokenKind::Operator, end);
635                }
636                '(' | ')' | ',' | ';' | '.' | '[' | ']' | '{' | '}' | ':' => {
637                    self.push(TokenKind::Punctuation, start + 1)
638                }
639                c => self.push(TokenKind::Other, start + c.len_utf8()),
640            }
641        }
642        self.tokens
643    }
644
645    /// `/* … */`, counting nested comments; the end of input if unclosed.
646    fn block_comment(&self) -> usize {
647        let bytes = self.code.as_bytes();
648        let mut depth = 0usize;
649        let mut i = self.at;
650        while i + 1 < bytes.len() {
651            match (bytes[i], bytes[i + 1]) {
652                (b'/', b'*') => {
653                    depth += 1;
654                    i += 2;
655                }
656                (b'*', b'/') => {
657                    depth -= 1;
658                    i += 2;
659                    if depth == 0 {
660                        return i;
661                    }
662                }
663                _ => i += 1,
664            }
665        }
666        self.code.len()
667    }
668
669    /// A string opened by `quote` at `start` (or by a prefix before it), with
670    /// the quote doubled to escape it and, for `backslash`, `\` escapes too.
671    fn quoted_string(&self, start: usize, quote: char, backslash: bool) -> usize {
672        let open = self.code[start..].find(quote).map_or(start, |n| start + n);
673        // BigQuery's triple quotes, when three quotes are not a doubled quote
674        // inside a string (`''''`) and the string is closed.
675        let triple: String = std::iter::repeat_n(quote, 3).collect();
676        let body = open + 3;
677        if self.code[open..].starts_with(&triple)
678            && !self.code[body..].starts_with(quote)
679            && self.code[body..].contains(triple.as_str())
680        {
681            return self.until(body, &triple);
682        }
683        let mut chars = self.code[open + 1..].char_indices().peekable();
684        while let Some((i, ch)) = chars.next() {
685            if backslash && ch == '\\' {
686                chars.next();
687            } else if ch == quote {
688                if chars.peek().is_some_and(|&(_, next)| next == quote) {
689                    chars.next();
690                } else {
691                    return open + 1 + i + ch.len_utf8();
692                }
693            }
694        }
695        self.code.len()
696    }
697
698    /// A name quoted from `start` to `close`, which is escaped by doubling it.
699    fn doubled(&self, start: usize, close: char) -> usize {
700        let mut chars = self.code[start + 1..].char_indices().peekable();
701        while let Some((i, ch)) = chars.next() {
702            if ch == close {
703                if chars.peek().is_some_and(|&(_, next)| next == close) {
704                    chars.next();
705                } else {
706                    return start + 1 + i + ch.len_utf8();
707                }
708            }
709        }
710        self.code.len()
711    }
712
713    /// `[` opens a quoted name (SQL Server) where an expression could start,
714    /// and is a subscript after a name, a closing bracket or `ARRAY`.
715    fn bracket_is_name(&self) -> bool {
716        let Some(previous) = self.previous() else {
717            return true;
718        };
719        let text = &self.code[previous.range.clone()];
720        let attached = previous.range.end == self.at;
721        match previous.kind {
722            TokenKind::Name | TokenKind::QuotedName | TokenKind::Function => !attached,
723            TokenKind::Punctuation => !matches!(text, ")" | "]"),
724            TokenKind::Keyword => !text.eq_ignore_ascii_case("array"),
725            TokenKind::Number | TokenKind::String | TokenKind::Parameter => false,
726            _ => true,
727        }
728    }
729
730    /// `$1`, `$name`, or a dollar-quoted string `$$…$$` / `$tag$…$tag$`.
731    fn dollar(&mut self, start: usize) {
732        let after = &self.code[start + 1..];
733        if after.starts_with(|c: char| c.is_ascii_digit()) {
734            let len = after
735                .find(|c: char| !c.is_ascii_digit())
736                .unwrap_or(after.len());
737            self.push(TokenKind::Parameter, start + 1 + len);
738            return;
739        }
740        let tag_len = after
741            .find(|c: char| !(c.is_alphanumeric() || c == '_'))
742            .unwrap_or(after.len());
743        if after[tag_len..].starts_with('$') {
744            let delimiter = &self.code[start..start + tag_len + 2];
745            let end = self.until(start + delimiter.len(), delimiter);
746            self.push(TokenKind::String, end);
747        } else if tag_len > 0 {
748            self.push(TokenKind::Parameter, start + 1 + tag_len);
749        } else {
750            self.push(TokenKind::Operator, start + 1);
751        }
752    }
753
754    fn number(&self, start: usize) -> usize {
755        let bytes = self.code.as_bytes();
756        let mut i = start;
757        if bytes[i] == b'0' && matches!(bytes.get(i + 1), Some(b'x' | b'X')) {
758            i += 2;
759            while i < bytes.len() && (bytes[i].is_ascii_hexdigit() || bytes[i] == b'_') {
760                i += 1;
761            }
762            return i;
763        }
764        while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'_') {
765            i += 1;
766        }
767        if i < bytes.len() && bytes[i] == b'.' && bytes.get(i + 1) != Some(&b'.') {
768            i += 1;
769            while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'_') {
770                i += 1;
771            }
772        }
773        if i < bytes.len() && matches!(bytes[i], b'e' | b'E') {
774            let mut j = i + 1;
775            if j < bytes.len() && matches!(bytes[j], b'+' | b'-') {
776                j += 1;
777            }
778            if j < bytes.len() && bytes[j].is_ascii_digit() {
779                i = j;
780                while i < bytes.len() && bytes[i].is_ascii_digit() {
781                    i += 1;
782                }
783            }
784        }
785        i
786    }
787
788    fn word_end(&self, from: usize) -> usize {
789        self.code[from..]
790            .find(|c: char| !is_ident_char(c))
791            .map_or(self.code.len(), |n| from + n)
792    }
793
794    fn word(&mut self, start: usize) {
795        let end = self.word_end(start);
796        let word = &self.code[start..end];
797        // A one-letter prefix opens a string: E'…' (backslash escapes),
798        // N'…', B'…', X'…', R'…' (raw), and BigQuery's two-letter ones.
799        if self.code[end..].starts_with(['\'', '"']) && word.len() <= 2 {
800            let lower = word.to_ascii_lowercase();
801            let quote = self.code[end..].chars().next().unwrap_or('\'');
802            let string_prefix = matches!(lower.as_str(), "e" | "n" | "b" | "x" | "r" | "rb" | "br");
803            if string_prefix && (quote == '\'' || matches!(lower.as_str(), "r" | "b" | "rb" | "br"))
804            {
805                let backslash = lower == "e" || (lower.contains('b') && !lower.contains('r'));
806                let close = self.quoted_string(start, quote, backslash);
807                self.push(TokenKind::String, close);
808                return;
809            }
810        }
811        let after_dot = self.previous().is_some_and(|t| {
812            t.kind == TokenKind::Punctuation
813                && &self.code[t.range.clone()] == "."
814                && t.range.end == start
815        });
816        let call = self.code[end..]
817            .trim_start_matches([' ', '\t'])
818            .starts_with('(');
819        let kind = if after_dot {
820            if call {
821                TokenKind::Function
822            } else {
823                TokenKind::Name
824            }
825        } else if let Some(kind) = word_kind(word) {
826            kind
827        } else if call {
828            TokenKind::Function
829        } else {
830            TokenKind::Name
831        };
832        self.push(kind, end);
833    }
834
835    /// A run of operator characters, stopping before a comment.
836    fn operator(&self, start: usize) -> usize {
837        let mut end = start;
838        for (i, ch) in self.code[start..].char_indices() {
839            let here = &self.code[start + i..];
840            if !(OPERATOR_CHARS.contains(ch) || (ch == '@' && i == 0))
841                || (i > 0 && (here.starts_with("--") || here.starts_with("/*")))
842            {
843                break;
844            }
845            end = start + i + ch.len_utf8();
846        }
847        end
848    }
849}
850
851// ---------------------------------------------------------------------------
852// Styles
853// ---------------------------------------------------------------------------
854
855/// The style of `kind` in `theme`; `None` for text left in the default style.
856pub fn token_style(kind: TokenKind, theme: &str) -> Option<Style> {
857    use TokenKind::*;
858    let markup = match theme {
859        "ansi_dark" | "ansi_light" => {
860            let dark = theme == "ansi_dark";
861            match (kind, dark) {
862                (Keyword | Constant, true) | (Number, true) => "bright_blue",
863                (Keyword | Constant, false) | (Number, false) => "blue",
864                (Type, true) | (Template, true) => "bright_cyan",
865                (Type, false) | (Template, false) => "cyan",
866                (Function, true) => "bright_green",
867                (Function, false) => "green",
868                (Parameter, true) => "bright_red",
869                (Parameter, false) => "red",
870                (String, _) => "yellow",
871                (Comment, _) => "dim",
872                (Other, _) => "red underline",
873                _ => return None,
874            }
875        }
876        _ => match kind {
877            Keyword | Type | Constant => "#66d9ef",
878            Function => "#a6e22e",
879            String => "#e6db74",
880            Number => "#ae81ff",
881            Comment => "#959077",
882            Operator => "#ff4689",
883            Parameter => "#fd971f",
884            Template => "italic #fd971f",
885            Other => "#ed007e on #1e0010",
886            _ => return None,
887        },
888    };
889    Style::parse(markup).ok()
890}
891
892/// The text and background colour of `theme`, if it has them.
893fn theme_base(theme: &str) -> (Style, Option<Color>) {
894    match theme {
895        "ansi_dark" | "ansi_light" => (Style::new(), None),
896        _ => {
897            let background = Color::parse("#272822").ok();
898            let mut base = Style::parse("#f8f8f2").unwrap_or_default();
899            if let Some(background) = &background {
900                base = base.with_bgcolor(background.clone());
901            }
902            (base, background)
903        }
904    }
905}
906
907/// Highlight `code` as SQL with `theme` (one of [`THEMES`]; anything else is
908/// `monokai`), as a styled [`Text`]. Needs no `Syntax`, so it works in a
909/// build without syntect.
910///
911/// ```
912/// let text = rich_ext::sql::highlight("select 'x'", "ansi_light");
913/// assert_eq!(text.spans().len(), 2);
914/// ```
915pub fn highlight(code: &str, theme: &str) -> Text {
916    let theme = known_theme(theme);
917    let (base, _) = theme_base(theme);
918    let mut text = Text::new(code);
919    if !base.is_null() {
920        text.set_base_style(base.clone());
921    }
922    for token in tokenize(code) {
923        if let Some(style) = token_style(token.kind, theme) {
924            text.stylize(base.combine(&style), token.range.start, token.range.end);
925        }
926    }
927    text
928}
929
930fn known_theme(theme: &str) -> &str {
931    if THEMES.contains(&theme) {
932        theme
933    } else {
934        DEFAULT_THEME
935    }
936}
937
938/// Whether `language` (a name or a file extension) is SQL to this highlighter.
939pub fn is_sql(language: &str) -> bool {
940    LANGUAGES
941        .iter()
942        .any(|name| name.eq_ignore_ascii_case(language))
943}
944
945// ---------------------------------------------------------------------------
946// The CodeHighlighter
947// ---------------------------------------------------------------------------
948
949/// SQL highlighting as a [`CodeHighlighter`], for `Syntax`, Markdown code
950/// fences and the console's code highlighting.
951#[derive(Clone, Default)]
952pub struct SqlHighlighter {
953    fallback: Option<Arc<dyn CodeHighlighter>>,
954}
955
956impl std::fmt::Debug for SqlHighlighter {
957    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
958        f.debug_struct("SqlHighlighter")
959            .field("fallback", &self.fallback.is_some())
960            .finish()
961    }
962}
963
964impl SqlHighlighter {
965    /// SQL only: other languages are plain text.
966    pub fn new() -> Self {
967        Self::default()
968    }
969
970    /// SQL here, every other language through `fallback` (syntect, lumis or
971    /// another engine).
972    ///
973    /// ```
974    /// use std::sync::Arc;
975    /// use rich::protocol::CodeHighlighter;
976    /// use rich_ext::sql::SqlHighlighter;
977    ///
978    /// let both = SqlHighlighter::with_fallback(Arc::new(SqlHighlighter::new()));
979    /// assert!(both.languages().contains(&"duckdb".to_string()));
980    /// ```
981    pub fn with_fallback(fallback: Arc<dyn CodeHighlighter>) -> Self {
982        SqlHighlighter {
983            fallback: Some(fallback),
984        }
985    }
986
987    /// One shared instance with no fallback.
988    pub fn shared() -> Arc<dyn CodeHighlighter> {
989        static SHARED: OnceLock<Arc<SqlHighlighter>> = OnceLock::new();
990        SHARED
991            .get_or_init(|| Arc::new(SqlHighlighter::new()))
992            .clone()
993    }
994}
995
996impl CodeHighlighter for SqlHighlighter {
997    fn highlight(
998        &self,
999        code: &str,
1000        language: Option<&str>,
1001        theme: &str,
1002    ) -> Result<HighlightedCode, HighlightError> {
1003        if !language.is_some_and(is_sql) {
1004            return match &self.fallback {
1005                Some(fallback) => fallback.highlight(code, language, theme),
1006                None => {
1007                    let (default_style, background) = theme_base(known_theme(theme));
1008                    Ok(HighlightedCode {
1009                        lines: vec![HighlightedLine::default(); code.split('\n').count()],
1010                        background,
1011                        default_style,
1012                    })
1013                }
1014            };
1015        }
1016        let theme = known_theme(theme);
1017        let (default_style, background) = theme_base(theme);
1018        let mut lines = Lines::new(code);
1019        for token in tokenize(code) {
1020            if let Some(style) = token_style(token.kind, theme) {
1021                lines.push(token.range, &default_style.combine(&style));
1022            }
1023        }
1024        Ok(HighlightedCode {
1025            lines: lines.lines,
1026            background,
1027            default_style,
1028        })
1029    }
1030
1031    fn default_theme(&self) -> &str {
1032        DEFAULT_THEME
1033    }
1034
1035    fn themes(&self) -> Vec<String> {
1036        let mut names: Vec<String> = THEMES.iter().map(|name| name.to_string()).collect();
1037        if let Some(fallback) = &self.fallback {
1038            names.extend(fallback.themes());
1039            names.sort();
1040            names.dedup();
1041        }
1042        names
1043    }
1044
1045    fn languages(&self) -> Vec<String> {
1046        let mut names: Vec<String> = LANGUAGES.iter().map(|name| name.to_string()).collect();
1047        if let Some(fallback) = &self.fallback {
1048            names.extend(fallback.languages());
1049            names.sort();
1050            names.dedup();
1051        }
1052        names
1053    }
1054
1055    fn language_for_path(&self, path: &Path) -> Option<String> {
1056        let sql = path
1057            .extension()
1058            .and_then(|ext| ext.to_str())
1059            .is_some_and(|ext| ext.eq_ignore_ascii_case("sql"));
1060        if sql {
1061            return Some("sql".to_string());
1062        }
1063        self.fallback.as_ref()?.language_for_path(path)
1064    }
1065
1066    fn token_style(&self, theme: &str, token: &str) -> Option<Style> {
1067        if !THEMES.contains(&theme)
1068            && let Some(fallback) = &self.fallback
1069        {
1070            return fallback.token_style(theme, token);
1071        }
1072        let theme = known_theme(theme);
1073        match token {
1074            "Comment" => token_style(TokenKind::Comment, theme),
1075            "Text" => Some(theme_base(theme).0).filter(|style| !style.is_null()),
1076            _ => None,
1077        }
1078    }
1079}
1080
1081/// Collects token styles into per-line spans.
1082struct Lines {
1083    /// Byte offset where each line of `code.split('\n')` starts.
1084    starts: Vec<usize>,
1085    len: usize,
1086    lines: Vec<HighlightedLine>,
1087}
1088
1089impl Lines {
1090    fn new(code: &str) -> Self {
1091        let mut starts = vec![0];
1092        starts.extend(code.match_indices('\n').map(|(at, _)| at + 1));
1093        let lines = vec![HighlightedLine::default(); starts.len()];
1094        Lines {
1095            starts,
1096            len: code.len(),
1097            lines,
1098        }
1099    }
1100
1101    fn line_of(&self, at: usize) -> usize {
1102        self.starts.partition_point(|&start| start <= at) - 1
1103    }
1104
1105    /// Style `range`, splitting it at line breaks.
1106    fn push(&mut self, range: Range<usize>, style: &Style) {
1107        if range.is_empty() || range.end > self.len {
1108            return;
1109        }
1110        for line in self.line_of(range.start)..=self.line_of(range.end - 1) {
1111            let begin = self.starts[line];
1112            let end = self.starts.get(line + 1).map_or(self.len, |next| next - 1);
1113            let (from, to) = (range.start.max(begin), range.end.min(end));
1114            if from < to {
1115                self.lines[line].spans.push(HighlightSpan {
1116                    range: from - begin..to - begin,
1117                    style: style.clone(),
1118                });
1119            }
1120            if end < self.len && range.start <= end && end < range.end {
1121                self.lines[line].newline_style = Some(style.clone());
1122            }
1123        }
1124    }
1125}
1126
1127/// Registers [`SqlHighlighter`] as the code highlighter `"sql"`.
1128#[derive(Clone, Copy, Debug, Default)]
1129pub struct SqlPlugin;
1130
1131impl Plugin for SqlPlugin {
1132    fn metadata(&self) -> PluginMetadata {
1133        PluginMetadata::new("sql", "SQL highlighter", env!("CARGO_PKG_VERSION"))
1134            .description("a pure-Rust SQL lexer with Jinja blocks, for SQL code")
1135    }
1136
1137    fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
1138        registrar.code_highlighter("sql", SqlHighlighter::shared());
1139        Ok(())
1140    }
1141}
1142
1143#[cfg(test)]
1144mod tests;