|
| 1 | +/* |
| 2 | + * CatSQL - the low cost SQL Terminal! |
| 3 | + * Copyright (C) 2026, Float314. |
| 4 | + * |
| 5 | + * This program is free software; you can redistribute it and/or |
| 6 | + * modify it under the terms of the GNU Lesser General Public |
| 7 | + * License as published by the Free Software Foundation; either |
| 8 | + * version 3 of the License, or (at your option) any later version. |
| 9 | +
|
| 10 | + * This program is distributed in the hope that it will be useful, |
| 11 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU |
| 13 | + * Lesser General Public License for more details. |
| 14 | + * |
| 15 | + * You should have received a copy of the GNU Lesser General Public License |
| 16 | + * along with this program; if not, write to the Free Software Foundation, |
| 17 | + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
| 18 | +*/ |
| 19 | + |
| 20 | +/* |
| 21 | + * Pretty-print errors coming from sqlparser-rs. |
| 22 | + * |
| 23 | + * sqlparser only exposes the location of an error as text inside its message |
| 24 | + * (for example "Expected: an expression, found: FROM at Line: 1, Column: 15"). |
| 25 | + * We split that suffix back out and turn it into a compiler-style diagnostic: |
| 26 | + * |
| 27 | + * Error: Expected: an expression, found: FROM |
| 28 | + * ╭─[<query>:1:15] |
| 29 | + * │ |
| 30 | + * 1 │ SELECT * FROM FROM users; |
| 31 | + * │ ──┬─ |
| 32 | + * │ ╰── error starts here |
| 33 | + * ────╯ |
| 34 | + * |
| 35 | + * The goal is to show where the problem is, not to suggest a fix. |
| 36 | + */ |
| 37 | + |
| 38 | +use std::ops::Range; |
| 39 | + |
| 40 | +use ariadne::{Color, Label, Report, ReportKind, Source}; |
| 41 | +use colorize::AnsiColor; |
| 42 | +use sqlparser::parser::ParserError; |
| 43 | + |
| 44 | +/// Prefix added by `ParserError`'s `Display` implementation. |
| 45 | +const PARSER_PREFIX: &str = "sql parser error: "; |
| 46 | +/// Suffix sqlparser appends to messages that carry a source location. |
| 47 | +const LOCATION_MARKER: &str = " at Line: "; |
| 48 | + |
| 49 | +/// Print a sqlparser error as a source-annotated diagnostic. |
| 50 | +/// |
| 51 | +/// `query` must be the exact SQL string that was handed to sqlparser, so the |
| 52 | +/// line/column in the error message points to the right place. |
| 53 | +pub fn catch_err_print(query: &str, error: &ParserError) { |
| 54 | + let rendered = error.to_string(); |
| 55 | + let raw_message = rendered.strip_prefix(PARSER_PREFIX).unwrap_or(&rendered); |
| 56 | + let (message, location) = split_location(raw_message); |
| 57 | + |
| 58 | + let offset = match location |
| 59 | + .and_then(|(line, column)| line_col_to_offset(query, line, column)) |
| 60 | + // EOF errors often have no location attached by sqlparser. In that |
| 61 | + // case the problem is at the end of the input. |
| 62 | + .or_else(|| message.contains("found: EOF").then(|| query.chars().count())) |
| 63 | + { |
| 64 | + Some(offset) => offset, |
| 65 | + None => { |
| 66 | + // No usable source location (e.g. recursion limit exceeded). |
| 67 | + eprintln!("{} {}", "Error!".bold().red(), message); |
| 68 | + return; |
| 69 | + } |
| 70 | + }; |
| 71 | + |
| 72 | + let span = error_span(query, offset, message); |
| 73 | + let source_id = "<query>"; |
| 74 | + let diagnostic_span = (source_id, span); |
| 75 | + |
| 76 | + let report = Report::build(ReportKind::Error, diagnostic_span.clone()) |
| 77 | + .with_message(message) |
| 78 | + .with_label( |
| 79 | + Label::new(diagnostic_span) |
| 80 | + .with_message("error starts here") |
| 81 | + .with_color(Color::Red), |
| 82 | + ) |
| 83 | + .with_note("while parsing this SQL statement") |
| 84 | + .finish(); |
| 85 | + |
| 86 | + if let Err(render_error) = report.eprint((source_id, Source::from(query))) { |
| 87 | + // Rendering should never be the reason the user loses the error. |
| 88 | + eprintln!("{} {}", "Error!".bold().red(), message); |
| 89 | + eprintln!("(could not render diagnostic: {render_error})"); |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +/// Split sqlparser's `"... at Line: L, Column: C"` suffix from the message. |
| 94 | +fn split_location(message: &str) -> (&str, Option<(u64, u64)>) { |
| 95 | + let Some(marker_index) = message.rfind(LOCATION_MARKER) else { |
| 96 | + return (message, None); |
| 97 | + }; |
| 98 | + |
| 99 | + let location = &message[marker_index + LOCATION_MARKER.len()..]; |
| 100 | + let Some((line, column)) = location.split_once(", Column: ") else { |
| 101 | + return (message, None); |
| 102 | + }; |
| 103 | + |
| 104 | + match (line.trim().parse::<u64>(), column.trim().parse::<u64>()) { |
| 105 | + (Ok(line), Ok(column)) => (&message[..marker_index], Some((line, column))), |
| 106 | + _ => (message, None), |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +/// Convert a 1-based line/column pair into a 0-based character offset. |
| 111 | +/// |
| 112 | +/// Ariadne uses character offsets by default, which also matches how |
| 113 | +/// sqlparser counts columns. |
| 114 | +fn line_col_to_offset(source: &str, line: u64, column: u64) -> Option<usize> { |
| 115 | + if line == 0 || column == 0 { |
| 116 | + return None; |
| 117 | + } |
| 118 | + |
| 119 | + let mut current_line = 1u64; |
| 120 | + let mut line_start = 0usize; |
| 121 | + |
| 122 | + for (index, character) in source.chars().enumerate() { |
| 123 | + if current_line == line { |
| 124 | + break; |
| 125 | + } |
| 126 | + if character == '\n' { |
| 127 | + current_line += 1; |
| 128 | + line_start = index + 1; |
| 129 | + } |
| 130 | + } |
| 131 | + |
| 132 | + if current_line != line { |
| 133 | + return None; |
| 134 | + } |
| 135 | + |
| 136 | + let line_length = source |
| 137 | + .chars() |
| 138 | + .skip(line_start) |
| 139 | + .take_while(|character| *character != '\n') |
| 140 | + .count(); |
| 141 | + |
| 142 | + // Clamp to the end of the line so EOF locations still point somewhere. |
| 143 | + Some(line_start + (column as usize - 1).min(line_length)) |
| 144 | +} |
| 145 | + |
| 146 | +/// Find the span of the token named in the error, falling back to one character. |
| 147 | +fn error_span(source: &str, offset: usize, message: &str) -> Range<usize> { |
| 148 | + let characters: Vec<char> = source.chars().collect(); |
| 149 | + |
| 150 | + if offset >= characters.len() { |
| 151 | + return characters.len()..characters.len(); |
| 152 | + } |
| 153 | + |
| 154 | + if let Some(found) = found_token(message) { |
| 155 | + let found: Vec<char> = found.chars().collect(); |
| 156 | + if !found.is_empty() && characters[offset..].starts_with(&found) { |
| 157 | + return offset..offset + found.len(); |
| 158 | + } |
| 159 | + } |
| 160 | + |
| 161 | + offset..(offset + 1).min(characters.len()) |
| 162 | +} |
| 163 | + |
| 164 | +/// Extract the token from sqlparser's `"Expected: ..., found: <token>"` text. |
| 165 | +fn found_token(message: &str) -> Option<&str> { |
| 166 | + let found = message.split(", found: ").nth(1)?.trim(); |
| 167 | + (!found.is_empty()).then_some(found) |
| 168 | +} |
| 169 | + |
| 170 | +#[cfg(test)] |
| 171 | +mod tests { |
| 172 | + use super::*; |
| 173 | + |
| 174 | + #[test] |
| 175 | + fn splits_location_from_message() { |
| 176 | + let (message, location) = split_location("Expected: X, found: Y at Line: 2, Column: 7"); |
| 177 | + |
| 178 | + assert_eq!(message, "Expected: X, found: Y"); |
| 179 | + assert_eq!(location, Some((2, 7))); |
| 180 | + } |
| 181 | + |
| 182 | + #[test] |
| 183 | + fn keeps_messages_without_locations() { |
| 184 | + let (message, location) = split_location("recursion limit exceeded"); |
| 185 | + |
| 186 | + assert_eq!(message, "recursion limit exceeded"); |
| 187 | + assert_eq!(location, None); |
| 188 | + } |
| 189 | + |
| 190 | + #[test] |
| 191 | + fn maps_line_and_column_to_character_offset() { |
| 192 | + let source = "SELECT 1;\nSELECT * FROM;"; |
| 193 | + let second_line = source.chars().position(|c| c == '\n').unwrap() + 1; |
| 194 | + |
| 195 | + assert_eq!(line_col_to_offset(source, 2, 1), Some(second_line)); |
| 196 | + assert_eq!(line_col_to_offset(source, 2, 8), Some(second_line + 7)); |
| 197 | + assert_eq!(line_col_to_offset(source, 1, 1), Some(0)); |
| 198 | + } |
| 199 | + |
| 200 | + #[test] |
| 201 | + fn clamps_columns_past_the_end_of_a_line() { |
| 202 | + let source = "SELECT * FROM"; |
| 203 | + assert_eq!(line_col_to_offset(source, 1, 100), Some(source.chars().count())); |
| 204 | + } |
| 205 | + |
| 206 | + #[test] |
| 207 | + fn highlights_the_found_token() { |
| 208 | + let source = "SELECT * FROM FROM users"; |
| 209 | + let offset = source.chars().position(|c| c == 'F').unwrap(); |
| 210 | + // The first "FROM" is the found token in this example. |
| 211 | + let span = error_span(source, offset, "Expected: an expression, found: FROM"); |
| 212 | + |
| 213 | + assert_eq!(span, offset..offset + 4); |
| 214 | + } |
| 215 | + |
| 216 | + #[test] |
| 217 | + fn falls_back_to_a_single_character() { |
| 218 | + let source = "SELECT 'unterminated"; |
| 219 | + let offset = source.chars().position(|c| c == '\'').unwrap(); |
| 220 | + |
| 221 | + assert_eq!(error_span(source, offset, "Unterminated string literal"), offset..offset + 1); |
| 222 | + } |
| 223 | +} |
0 commit comments