diff --git a/crates/sqlquack-duckdb/src/layout/_helpers/breaks.rs b/crates/sqlquack-duckdb/src/layout/_helpers/breaks.rs index 179072238..4c73c7b2a 100644 --- a/crates/sqlquack-duckdb/src/layout/_helpers/breaks.rs +++ b/crates/sqlquack-duckdb/src/layout/_helpers/breaks.rs @@ -1,15 +1,28 @@ //! Where lines break: before a token, and after one that asks for a break. use crate::layout::_helpers::plan::Plan; -use crate::layout::_helpers::tokens::{Token, TokenKind}; +use crate::layout::_helpers::tokens::{Token, TokenKind, tokens}; use crate::layout::constants::{ BY_CLAUSE_WORDS, CLAUSE_WORDS, INDENT, INLINE_WIDTH, JOIN_MODIFIERS, NON_CALL_WORDS, SELECT_MODIFIERS, SET_OPERATION_MODIFIERS, SET_OPERATIONS, }; use crate::layout::models::{Case, Clause, Frame, Walk}; +/// `sql` one clause and select item per line, nested queries and long `CASE`s indented by depth. +pub(crate) fn lay_out(sql: &str) -> String { + let tokens: Vec> = tokens(sql); + let plan = Plan::new(&tokens); + let mut walk = start(plan.width(0, tokens.len().saturating_sub(1)) > INLINE_WIDTH); + for index in 0..tokens.len() { + let level = walk.line_break(&tokens, index); + walk.push_token(&tokens, index, level); + walk.forced = walk.after_token(&tokens, index, &plan); + } + walk.laid_out +} + /// The walk over a statement, which breaks its lines when `broken`. -pub(crate) fn start(broken: bool) -> Walk { +fn start(broken: bool) -> Walk { Walk { frames: vec![Frame { query: true, diff --git a/crates/sqlquack-duckdb/src/layout/_helpers/tokens.rs b/crates/sqlquack-duckdb/src/layout/_helpers/tokens.rs index 7e5235891..59f75fc9c 100644 --- a/crates/sqlquack-duckdb/src/layout/_helpers/tokens.rs +++ b/crates/sqlquack-duckdb/src/layout/_helpers/tokens.rs @@ -66,6 +66,30 @@ pub(crate) fn tokens(sql: &str) -> Vec> { tokens } +/// Text whose literals or comments the tokens may misread: `$`, `E''`, unclosed or nested ones. +pub(crate) fn unmodelled(tokens: &[Token<'_>]) -> Option<&'static str> { + tokens + .iter() + .enumerate() + .find_map(|(index, token)| match token.kind { + TokenKind::Symbol if token.is_symbol("$") => Some("a `$`"), + TokenKind::Quoted + if index > 0 && !token.spaced && tokens[index - 1].kind == TokenKind::Word => + { + Some("a prefixed string") + } + TokenKind::Quoted if !token.text[1..].ends_with(&token.text[..1]) => { + Some("an unclosed quote") + } + TokenKind::BlockComment + if !token.text.ends_with("*/") || token.text[2..].contains("/*") => + { + Some("an unclosed or nested comment") + } + _ => None, + }) +} + /// Whether `byte` continues a bare word; bytes of non-ASCII characters count as word bytes. fn is_word_byte(byte: u8) -> bool { byte.is_ascii_alphanumeric() || byte == b'_' || !byte.is_ascii() diff --git a/crates/sqlquack-duckdb/src/layout/main/checked_layout.rs b/crates/sqlquack-duckdb/src/layout/main/checked_layout.rs index 74892ef0c..5640eb3ac 100644 --- a/crates/sqlquack-duckdb/src/layout/main/checked_layout.rs +++ b/crates/sqlquack-duckdb/src/layout/main/checked_layout.rs @@ -1,11 +1,16 @@ //! `checked_layout`: the layout of DuckDB SQL, checked to change only whitespace. -use crate::layout::_helpers::tokens::tokens; -use crate::layout::main::layout_sql::layout_sql; +use crate::layout::_helpers::breaks::lay_out; +use crate::layout::_helpers::tokens::{tokens, unmodelled}; -/// `sql` laid out by [`layout_sql`], or why that changed more than whitespace or is not idempotent. +/// `sql` laid out, or why its text is unmodelled or its layout changes more than whitespace. pub fn checked_layout(sql: &str) -> Result { - let laid_out = layout_sql(sql); + if let Some(reason) = unmodelled(&tokens(sql)) { + return Err(format!( + "it holds {reason}, which the layout does not model" + )); + } + let laid_out = lay_out(sql); let texts = |sql: &str| -> Vec { tokens(sql) .iter() @@ -19,7 +24,7 @@ pub fn checked_layout(sql: &str) -> Result { if stripped(sql) != stripped(&laid_out) { return Err("its characters other than whitespace differ".to_owned()); } - if layout_sql(&laid_out) != laid_out { + if lay_out(&laid_out) != laid_out { return Err("laying it out again changes it".to_owned()); } Ok(laid_out) diff --git a/crates/sqlquack-duckdb/src/layout/main/layout_sql.rs b/crates/sqlquack-duckdb/src/layout/main/layout_sql.rs index 81a8b5a7d..172852186 100644 --- a/crates/sqlquack-duckdb/src/layout/main/layout_sql.rs +++ b/crates/sqlquack-duckdb/src/layout/main/layout_sql.rs @@ -1,19 +1,8 @@ -//! `layout_sql`: DuckDB SQL laid out over lines, with only the whitespace between tokens changed. +//! `layout_sql`: DuckDB SQL laid out over lines, or unchanged where the layout cannot be checked. -use crate::layout::_helpers::breaks::start; -use crate::layout::_helpers::plan::Plan; -use crate::layout::_helpers::tokens::{Token, tokens}; -use crate::layout::constants::INLINE_WIDTH; +use crate::layout::main::checked_layout::checked_layout; -/// `sql` one clause and select item per line, nested queries and long `CASE`s indented by depth. +/// `sql` laid out by [`checked_layout`], or `sql` itself on one line when the check fails. pub fn layout_sql(sql: &str) -> String { - let tokens: Vec> = tokens(sql); - let plan = Plan::new(&tokens); - let mut walk = start(plan.width(0, tokens.len().saturating_sub(1)) > INLINE_WIDTH); - for index in 0..tokens.len() { - let level = walk.line_break(&tokens, index); - walk.push_token(&tokens, index, level); - walk.forced = walk.after_token(&tokens, index, &plan); - } - walk.laid_out + checked_layout(sql).unwrap_or_else(|_| sql.to_owned()) } diff --git a/crates/sqlquack-duckdb/src/layout/tests/test_layout_sql.rs b/crates/sqlquack-duckdb/src/layout/tests/test_layout_sql.rs index 41499469b..05ccce63b 100644 --- a/crates/sqlquack-duckdb/src/layout/tests/test_layout_sql.rs +++ b/crates/sqlquack-duckdb/src/layout/tests/test_layout_sql.rs @@ -1,5 +1,6 @@ use crate::layout::main::checked_layout::checked_layout; -use crate::layout::tests::test_types::LayoutTestCase; +use crate::layout::main::layout_sql::layout_sql; +use crate::layout::tests::test_types::{FallbackTestCase, LayoutTestCase}; #[test] fn given_converted_sql_when_laid_out_then_clauses_items_and_long_cases_break_by_depth() { @@ -72,3 +73,38 @@ WHERE ("T"."A" IS DISTINCT FROM "T"."B")"#, ); } } + +#[test] +fn given_text_the_layout_does_not_model_when_laid_out_then_the_single_line_is_kept() { + let test_cases = [ + FallbackTestCase { + description: "a dollar-quoted string holding clause words and doubled spaces", + sql: r#"SELECT $$a FROM b, WHERE c$$ AS "TEXT", "T"."A" AS "A" FROM "T" AS "T" WHERE ("T"."A" > 1)"#, + expected_reason: "a `$`", + }, + FallbackTestCase { + description: "an escape string with an escaped quote and doubled spaces", + sql: r#"SELECT E'it\'s FROM here' AS "TEXT", "T"."A" AS "A" FROM "T" AS "T" WHERE ("T"."A" > 1)"#, + expected_reason: "a prefixed string", + }, + FallbackTestCase { + description: "a nested comment holding a clause word", + sql: r#"SELECT /* a /* b */ FROM c */ "T"."A" AS "A", "T"."B" AS "B" FROM "T" AS "T" WHERE ("T"."A" > 1)"#, + expected_reason: "an unclosed or nested comment", + }, + ]; + for test_case in test_cases { + let refusal = checked_layout(test_case.sql).expect_err(test_case.description); + assert!( + refusal.contains(test_case.expected_reason), + "{}: {refusal}", + test_case.description + ); + assert_eq!( + layout_sql(test_case.sql), + test_case.sql, + "{}", + test_case.description + ); + } +} diff --git a/crates/sqlquack-duckdb/src/layout/tests/test_types.rs b/crates/sqlquack-duckdb/src/layout/tests/test_types.rs index 820149f5d..74f19ee99 100644 --- a/crates/sqlquack-duckdb/src/layout/tests/test_types.rs +++ b/crates/sqlquack-duckdb/src/layout/tests/test_types.rs @@ -4,3 +4,10 @@ pub(crate) struct LayoutTestCase { pub(crate) sql: &'static str, pub(crate) expected_layout: &'static str, } + +/// SQL holding text the layout does not model, and the reason its check gives. +pub(crate) struct FallbackTestCase { + pub(crate) description: &'static str, + pub(crate) sql: &'static str, + pub(crate) expected_reason: &'static str, +}