sql_dialect_fmt_formatter/lib.rs
1//! **sql-dialect-fmt-formatter** — the formatting half of the toolchain.
2//!
3//! Two layers:
4//! * [`doc`] — a grammar-agnostic pretty-printing engine (a `Doc` IR + width-aware printer) in the
5//! Wadler → Prettier → biome/ruff lineage. Depends on nothing SQL-specific.
6//! * [`sql`] — the Snowflake SQL rules that lower the parser's lossless CST into `Doc`s.
7//!
8//! [`format`] ties them together: parse → lower → print. Like the parser, it never panics; input
9//! it cannot model structurally is preserved verbatim, so formatting is always content-preserving.
10//!
11//! ```
12//! use sql_dialect_fmt_formatter::{format, FormatOptions};
13//! let out = format("select a,b from t", &FormatOptions::default());
14//! assert_eq!(out, "SELECT a, b\nFROM t;\n");
15//! ```
16//!
17//! ## Public API stability
18//!
19//! [`FormatOptions`] is the configuration entry point and is `#[non_exhaustive]`: build it from
20//! [`FormatOptions::default`] and refine it with the `with_*` methods so adding future knobs stays
21//! backwards compatible.
22
23pub mod doc;
24mod sql;
25
26#[doc(inline)]
27pub use doc::{print, Doc, PrintOptions};
28pub use sql_dialect_fmt_syntax::Dialect;
29
30use sql::Ctx;
31
32/// Options controlling formatting. Opinionated and intentionally small.
33///
34/// This type is `#[non_exhaustive]`: future releases may add knobs without it being a breaking
35/// change. Consequently, callers in other crates cannot build it with a struct literal. Start from
36/// [`FormatOptions::default`] and adjust it through the `with_*` builder methods instead:
37///
38/// ```
39/// use sql_dialect_fmt_formatter::FormatOptions;
40/// let options = FormatOptions::default()
41/// .with_line_width(80)
42/// .with_indent_width(2)
43/// .with_uppercase_keywords(false);
44/// assert_eq!(options.line_width, 80);
45/// assert_eq!(options.indent_width, 2);
46/// assert!(!options.uppercase_keywords);
47/// ```
48#[derive(Clone, Copy, Debug)]
49#[non_exhaustive]
50pub struct FormatOptions {
51 /// Target line width the printer keeps within where it can.
52 pub line_width: usize,
53 /// Spaces per indentation level.
54 pub indent_width: usize,
55 /// Upper-case SQL keywords.
56 pub uppercase_keywords: bool,
57 /// The SQL dialect to parse and format. Defaults to [`Dialect::Snowflake`].
58 pub dialect: Dialect,
59}
60
61impl Default for FormatOptions {
62 fn default() -> Self {
63 FormatOptions {
64 line_width: 100,
65 indent_width: 4,
66 uppercase_keywords: true,
67 dialect: Dialect::Snowflake,
68 }
69 }
70}
71
72impl FormatOptions {
73 /// Set the target line width (the column the printer tries to keep lines within), returning the
74 /// updated options so calls can be chained.
75 #[must_use]
76 pub fn with_line_width(mut self, line_width: usize) -> Self {
77 self.line_width = line_width;
78 self
79 }
80
81 /// Set the number of spaces per indentation level, returning the updated options so calls can be
82 /// chained.
83 #[must_use]
84 pub fn with_indent_width(mut self, indent_width: usize) -> Self {
85 self.indent_width = indent_width;
86 self
87 }
88
89 /// Choose whether SQL keywords are upper-cased, returning the updated options so calls can be
90 /// chained.
91 #[must_use]
92 pub fn with_uppercase_keywords(mut self, uppercase_keywords: bool) -> Self {
93 self.uppercase_keywords = uppercase_keywords;
94 self
95 }
96
97 /// Set the SQL dialect to parse and format, returning the updated options so calls can be
98 /// chained.
99 #[must_use]
100 pub fn with_dialect(mut self, dialect: Dialect) -> Self {
101 self.dialect = dialect;
102 self
103 }
104
105 fn print_options(&self) -> PrintOptions {
106 PrintOptions {
107 line_width: self.line_width,
108 indent_width: self.indent_width,
109 }
110 }
111
112 fn ctx(&self) -> Ctx {
113 Ctx {
114 uppercase_keywords: self.uppercase_keywords,
115 line_width: self.line_width,
116 indent_width: self.indent_width,
117 dialect: self.dialect,
118 }
119 }
120}
121
122/// Format Snowflake SQL source text. Never panics; never drops content.
123///
124/// Phase 3 scope: we only reflow input the lexer and parser fully accept. If lexing or parsing
125/// reports any error (i.e. the grammar does not yet model some construct, or a token is
126/// unterminated), the source is returned **unchanged** — trivially lossless and idempotent — rather
127/// than risking a mangled reflow of a fragmented tree.
128pub fn format(source: &str, options: &FormatOptions) -> String {
129 let ctx = options.ctx();
130 let lexed = sql_dialect_fmt_lexer::tokenize_for_dialect(source, ctx.dialect);
131 if !lexed.errors.is_empty() {
132 return source.to_string();
133 }
134 if lexed
135 .tokens
136 .iter()
137 .any(|token| !token.kind.is_trivia() && multiline_token_has_line_trailing_space(token.text))
138 {
139 return source.to_string();
140 }
141 let parse = sql_dialect_fmt_parser::parse_with_dialect(source, ctx.dialect);
142 if !parse.errors().is_empty() {
143 return source.to_string();
144 }
145 let root = parse.syntax();
146 let doc = sql::lower_source(&root, ctx);
147 print(&doc, &options.print_options())
148}
149
150pub(crate) fn multiline_token_has_line_trailing_space(text: &str) -> bool {
151 text.lines()
152 .any(|line| line.ends_with(' ') || line.ends_with('\t'))
153}