Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions crates/sqlquack-duckdb/src/layout/_helpers/breaks.rs
Original file line number Diff line number Diff line change
@@ -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<Token<'_>> = 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,
Expand Down
24 changes: 24 additions & 0 deletions crates/sqlquack-duckdb/src/layout/_helpers/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,30 @@ pub(crate) fn tokens(sql: &str) -> Vec<Token<'_>> {
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()
Expand Down
15 changes: 10 additions & 5 deletions crates/sqlquack-duckdb/src/layout/main/checked_layout.rs
Original file line number Diff line number Diff line change
@@ -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<String, String> {
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<String> {
tokens(sql)
.iter()
Expand All @@ -19,7 +24,7 @@ pub fn checked_layout(sql: &str) -> Result<String, String> {
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)
Expand Down
19 changes: 4 additions & 15 deletions crates/sqlquack-duckdb/src/layout/main/layout_sql.rs
Original file line number Diff line number Diff line change
@@ -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<Token<'_>> = 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())
}
38 changes: 37 additions & 1 deletion crates/sqlquack-duckdb/src/layout/tests/test_layout_sql.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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
);
}
}
7 changes: 7 additions & 0 deletions crates/sqlquack-duckdb/src/layout/tests/test_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Loading