Skip to main content

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}