Skip to content

Commit 96c8fac

Browse files
Float314Opencode
andcommitted
Preetified error messages.
The Kids are going to love this! Co-authored-by: Opencode <noreply@opencode.ai>
1 parent 21be906 commit 96c8fac

7 files changed

Lines changed: 261 additions & 25 deletions

File tree

‎Cargo.lock‎

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ version = "0.4.0"
44
edition = "2024"
55

66
[dependencies]
7+
ariadne = "0.6.0"
78
colorize = "0.1.0"
89
dirs = "7.0.0"
910
libsql = "0.10.0"

‎helperscript.sh‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
# Always a helperscript
22

3+
echo "==> CatsToy build helperscript"
34
CPP=g++
45
CPPARGS="-Wall -Wextra -std=c++23 -g"
5-
6+
echo "==> Arguments: C++: $CPPARGS "
7+
echo "==> Using Compiler: $CPP"
68
cd other/catstoy
79
$CPP $CPPARGS -o catstoy main.cpp
810
mv catstoy ../../catstoy
911

1012
cd ../..
1113

12-
echo "Done building catstoy helperprogram"
14+
echo "==>Done building catstoy helperprogram"

‎src/ast.rs‎

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use colorize::AnsiColor;
2222
use sqlparser::{
2323
ast::Statement,
2424
dialect::{Dialect, GenericDialect, MySqlDialect, PostgreSqlDialect},
25-
parser::Parser,
25+
parser::{Parser, ParserError},
2626
};
2727
use std::process;
2828
use tabled::{Table, Tabled};
@@ -47,27 +47,16 @@ fn parser_dialect(dialect: SQLDialect) -> Box<dyn Dialect> {
4747

4848
/*
4949
* Generate AST Based on Dialect and Query.
50-
* query: String -> The query to feed to sqlparser.
50+
* query: &str -> The query to feed to sqlparser.
5151
* dialect_sql: SQLDialect (enum) -> Dialect. (more details on configfile.rs )
5252
*
5353
*/
5454
pub fn generate_ast(
55-
query: String,
55+
query: &str,
5656
dialect_sql: SQLDialect,
57-
) -> Result<Vec<Statement>, Box<dyn std::error::Error>> {
57+
) -> Result<Vec<Statement>, ParserError> {
5858
let dialect = parser_dialect(dialect_sql);
59-
60-
match Parser::parse_sql(dialect.as_ref(), &query) {
61-
Ok(ast) => Ok(ast),
62-
Err(err) => {
63-
println!("{} {}", "Error!".bold().red(), err);
64-
Err(format!(
65-
"Could not parse query using the {}",
66-
dialect_sql.display_name()
67-
)
68-
.into())
69-
}
70-
}
59+
Parser::parse_sql(dialect.as_ref(), query)
7160
}
7261

7362
/*
@@ -209,12 +198,11 @@ pub fn matchdotcmds(
209198
let config = configfile::get_config_or_create().unwrap();
210199
let dialect = config.dialect;
211200

212-
let dialect_a = parser_dialect(dialect);
213-
214-
let ast_produced = match Parser::parse_sql(dialect_a.as_ref(), &requested.join(" ")) {
201+
let query = requested.join(" ");
202+
let ast_produced = match generate_ast(&query, dialect) {
215203
Ok(ast) => ast,
216204
Err(err) => {
217-
println!("{} {}", "Error!".bold().red(), err);
205+
crate::errordisplay::catch_err_print(&query, &err);
218206
return;
219207
}
220208
};

‎src/errordisplay.rs‎

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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+
}

‎src/main.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ mod configfile;
2525
mod create_db;
2626
mod parser;
2727
mod queryfetch;
28+
mod errordisplay;
2829

2930
#[tokio::main]
3031
async fn main() -> Result<(), Box<dyn std::error::Error>> {
@@ -53,7 +54,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
5354
Ok(line) => {
5455
match parser::match_args(line, &mut db, &mut dialect).await {
5556
Ok(_) => {}
56-
Err(_e) => println!("Error occured!"),
57+
Err(e) => println!("{} {}", "Error!".bold().red(), e),
5758
};
5859
}
5960
/* ctrl-c */

‎src/parser.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use colorize::AnsiColor;
2121
use crate::ast;
2222
use crate::configfile::SQLDialect;
2323
use crate::create_db;
24+
use crate::errordisplay;
2425
use crate::queryfetch;
2526
use libsql::Connection;
2627
use sqlparser;
@@ -44,9 +45,12 @@ pub async fn match_args(
4445
ast::matchdotcmds(command.clone(), dialect);
4546
} else {
4647
// generate ast using the dialect selected in the config file
47-
let ast_produced = match ast::generate_ast(command, *dialect) {
48+
let ast_produced = match ast::generate_ast(&command, *dialect) {
4849
Ok(ast) => ast,
49-
Err(err) => return Err(err),
50+
Err(err) => {
51+
errordisplay::catch_err_print(&command, &err);
52+
return Ok(());
53+
}
5054
};
5155

5256
for statement in ast_produced {

0 commit comments

Comments
 (0)