sql_dialect_fmt_highlight/
lib.rs1use sql_dialect_fmt_lexer::{tokenize, LexError};
8use sql_dialect_fmt_syntax::{is_builtin_type, keyword_kind, SyntaxKind};
9
10pub mod semantic;
11pub use semantic::{
12 delta_encode, detect_injections, line_tokens, resolve_tokens, semantic_token, semantic_tokens,
13 semantic_tokens_lsp, semantic_tokens_lsp_utf8, InjectedLanguage, Injection, LineToken,
14 ResolvedToken, SemanticTokenModifiers, SemanticTokenType, SemanticTokens,
15};
16
17#[derive(Clone, Debug, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct Highlighted<'a> {
22 pub tokens: Vec<HighlightToken<'a>>,
24 pub errors: Vec<LexError>,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct HighlightToken<'a> {
33 pub kind: HighlightKind,
35 pub text: &'a str,
37 pub range: std::ops::Range<usize>,
39}
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
42pub enum HighlightKind {
43 Whitespace,
44 Comment,
45 Keyword,
46 Type,
47 Identifier,
48 QuotedIdentifier,
49 String,
50 DollarString,
51 Number,
52 Variable,
53 Operator,
54 Punctuation,
55 Error,
56}
57
58impl HighlightKind {
59 pub const fn scope(self) -> &'static str {
60 match self {
61 HighlightKind::Whitespace => "text.whitespace",
62 HighlightKind::Comment => "comment.line-or-block",
63 HighlightKind::Keyword => "keyword.sql",
64 HighlightKind::Type => "support.type.sql",
65 HighlightKind::Identifier => "identifier.sql",
66 HighlightKind::QuotedIdentifier => "identifier.quoted.sql",
67 HighlightKind::String => "string.quoted.sql",
68 HighlightKind::DollarString => "string.dollar-quoted.sql",
69 HighlightKind::Number => "constant.numeric.sql",
70 HighlightKind::Variable => "variable.parameter.sql",
71 HighlightKind::Operator => "operator.sql",
72 HighlightKind::Punctuation => "punctuation.sql",
73 HighlightKind::Error => "invalid.illegal.sql",
74 }
75 }
76}
77
78pub fn highlight(input: &str) -> Highlighted<'_> {
79 let lexed = tokenize(input);
80 let tokens = lexed
81 .tokens
82 .into_iter()
83 .scan(0usize, |offset, token| {
84 let start = *offset;
85 *offset += token.text.len();
86 Some(HighlightToken {
87 kind: classify(token.kind, token.text),
88 text: token.text,
89 range: start..*offset,
90 })
91 })
92 .collect();
93
94 Highlighted {
95 tokens,
96 errors: lexed.errors,
97 }
98}
99
100pub fn classify(kind: SyntaxKind, text: &str) -> HighlightKind {
101 if kind.is_trivia() {
102 return match kind {
103 SyntaxKind::COMMENT | SyntaxKind::BLOCK_COMMENT => HighlightKind::Comment,
104 _ => HighlightKind::Whitespace,
105 };
106 }
107
108 if kind.is_keyword()
109 || kind == SyntaxKind::CONTEXTUAL_KEYWORD
110 || (kind == SyntaxKind::IDENT && keyword_kind(text).is_some())
111 {
112 return HighlightKind::Keyword;
113 }
114 if kind == SyntaxKind::IDENT && is_builtin_type(text) {
115 return HighlightKind::Type;
116 }
117
118 match kind {
119 SyntaxKind::IDENT => HighlightKind::Identifier,
120 SyntaxKind::QUOTED_IDENT => HighlightKind::QuotedIdentifier,
121 SyntaxKind::STRING => HighlightKind::String,
122 SyntaxKind::DOLLAR_STRING => HighlightKind::DollarString,
123 SyntaxKind::FILE_URI => HighlightKind::String,
124 SyntaxKind::INT_NUMBER | SyntaxKind::FLOAT_NUMBER => HighlightKind::Number,
125 SyntaxKind::VARIABLE | SyntaxKind::DOLLAR | SyntaxKind::QUESTION => HighlightKind::Variable,
126 SyntaxKind::ERROR | SyntaxKind::BANG => HighlightKind::Error,
127 kind if is_operator(kind) => HighlightKind::Operator,
128 _ => HighlightKind::Punctuation,
129 }
130}
131
132fn is_operator(kind: SyntaxKind) -> bool {
133 matches!(
134 kind,
135 SyntaxKind::COLON
136 | SyntaxKind::COLON2
137 | SyntaxKind::ASSIGN
138 | SyntaxKind::EQ
139 | SyntaxKind::NEQ
140 | SyntaxKind::LT
141 | SyntaxKind::LTE
142 | SyntaxKind::GT
143 | SyntaxKind::GTE
144 | SyntaxKind::PLUS
145 | SyntaxKind::MINUS
146 | SyntaxKind::STAR
147 | SyntaxKind::SLASH
148 | SyntaxKind::PERCENT
149 | SyntaxKind::CONCAT
150 | SyntaxKind::PIPE
151 | SyntaxKind::PIPE_GT
152 | SyntaxKind::FLOW_PIPE
153 | SyntaxKind::ARROW
154 | SyntaxKind::FAT_ARROW
155 | SyntaxKind::AMP
156 | SyntaxKind::CARET
157 | SyntaxKind::TILDE
158 | SyntaxKind::AT
159 )
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165
166 #[test]
172 fn classifies_core_snowflake_tokens() {
173 let h = highlight("SELECT $1::NUMBER ->> SELECT \"name\" FROM $1 -- ok\n");
174 assert!(h.errors.is_empty());
175
176 let interesting: Vec<_> = h
177 .tokens
178 .iter()
179 .filter(|token| !matches!(token.kind, HighlightKind::Whitespace))
180 .map(|token| (token.text, token.kind))
181 .collect();
182
183 assert_eq!(
184 interesting,
185 vec![
186 ("SELECT", HighlightKind::Keyword),
187 ("$1", HighlightKind::Variable),
188 ("::", HighlightKind::Operator),
189 ("NUMBER", HighlightKind::Type),
190 ("->>", HighlightKind::Operator),
191 ("SELECT", HighlightKind::Keyword),
192 ("\"name\"", HighlightKind::QuotedIdentifier),
193 ("FROM", HighlightKind::Keyword),
194 ("$1", HighlightKind::Variable),
195 ("-- ok", HighlightKind::Comment),
196 ]
197 );
198 }
199
200 #[test]
201 fn ranges_cover_input_losslessly() {
202 let sql = "SELECT payload:items[0]::VARIANT FROM raw";
203 let highlighted = highlight(sql);
204 assert!(highlighted.errors.is_empty());
205 let joined: String = highlighted.tokens.iter().map(|token| token.text).collect();
206 assert_eq!(joined, sql);
207 for token in &highlighted.tokens {
208 assert_eq!(&sql[token.range.clone()], token.text);
209 }
210 }
211
212 #[test]
213 fn unquoted_file_uri_is_string_like_not_a_comment() {
214 let highlighted = highlight("PUT file:///tmp/data/mydata.csv @stage");
215 assert!(highlighted.errors.is_empty());
216 let uri = highlighted
217 .tokens
218 .iter()
219 .find(|token| token.text.starts_with("file://"))
220 .expect("a file URI token");
221 assert_eq!(uri.kind, HighlightKind::String);
222 assert!(!highlighted
223 .tokens
224 .iter()
225 .any(|token| token.kind == HighlightKind::Comment));
226 }
227}