sql_dialect_fmt_formatter/
lib.rs1pub mod doc;
24pub mod range;
25mod sql;
26
27#[doc(inline)]
28pub use doc::{print, Doc, PrintOptions};
29pub use range::{format_range, RangeEdit};
30use sql_dialect_fmt_parser::ParseError;
31pub use sql_dialect_fmt_syntax::Dialect;
32
33use sql::Ctx;
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum KeywordCase {
39 Upper,
41 Lower,
43 Preserve,
45}
46
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49#[non_exhaustive]
50pub enum LineEnding {
51 Auto,
53 Lf,
55 Crlf,
57}
58
59#[derive(Clone, Copy, Debug)]
76#[non_exhaustive]
77pub struct FormatOptions {
78 pub line_width: usize,
80 pub indent_width: usize,
82 pub uppercase_keywords: bool,
88 pub keyword_case: KeywordCase,
90 pub line_ending: LineEnding,
92 pub dialect: Dialect,
94}
95
96impl Default for FormatOptions {
97 fn default() -> Self {
98 FormatOptions {
99 line_width: 100,
100 indent_width: 4,
101 uppercase_keywords: true,
102 keyword_case: KeywordCase::Upper,
103 line_ending: LineEnding::Lf,
104 dialect: Dialect::Snowflake,
105 }
106 }
107}
108
109impl FormatOptions {
110 #[must_use]
113 pub fn with_line_width(mut self, line_width: usize) -> Self {
114 self.line_width = line_width;
115 self
116 }
117
118 #[must_use]
121 pub fn with_indent_width(mut self, indent_width: usize) -> Self {
122 self.indent_width = indent_width;
123 self
124 }
125
126 #[must_use]
129 pub fn with_uppercase_keywords(mut self, uppercase_keywords: bool) -> Self {
130 self.uppercase_keywords = uppercase_keywords;
131 self.keyword_case = if uppercase_keywords {
132 KeywordCase::Upper
133 } else {
134 KeywordCase::Preserve
135 };
136 self
137 }
138
139 #[must_use]
141 pub fn with_keyword_case(mut self, keyword_case: KeywordCase) -> Self {
142 self.keyword_case = keyword_case;
143 self.uppercase_keywords = matches!(keyword_case, KeywordCase::Upper);
144 self
145 }
146
147 #[must_use]
149 pub fn with_line_ending(mut self, line_ending: LineEnding) -> Self {
150 self.line_ending = line_ending;
151 self
152 }
153
154 #[must_use]
157 pub fn with_dialect(mut self, dialect: Dialect) -> Self {
158 self.dialect = dialect;
159 self
160 }
161
162 fn print_options(&self) -> PrintOptions {
163 PrintOptions {
164 line_width: self.line_width,
165 indent_width: self.indent_width,
166 }
167 }
168
169 fn ctx(&self) -> Ctx {
170 Ctx {
171 keyword_case: self.effective_keyword_case(),
172 line_width: self.line_width,
173 indent_width: self.indent_width,
174 dialect: self.dialect,
175 }
176 }
177
178 fn effective_keyword_case(&self) -> KeywordCase {
179 if !self.uppercase_keywords && self.keyword_case == KeywordCase::Upper {
180 KeywordCase::Preserve
181 } else {
182 self.keyword_case
183 }
184 }
185}
186
187#[derive(Clone, Debug)]
189pub struct FormatResult {
190 pub formatted: String,
193 pub parse_errors: Vec<ParseError>,
195}
196
197pub fn format(source: &str, options: &FormatOptions) -> String {
204 format_with_diagnostics(source, options).formatted
205}
206
207pub fn format_with_diagnostics(source: &str, options: &FormatOptions) -> FormatResult {
209 if let Some(result) = format_directive_regions(source, options) {
210 return result;
211 }
212 format_plain_with_diagnostics(source, options, 0)
213}
214
215fn format_plain_with_diagnostics(
216 source: &str,
217 options: &FormatOptions,
218 base_offset: usize,
219) -> FormatResult {
220 let ctx = options.ctx();
221 let lexed = sql_dialect_fmt_lexer::tokenize_for_dialect(source, ctx.dialect);
222 let has_lex_errors = !lexed.errors.is_empty();
223 let has_multiline_trailing_space = lexed.tokens.iter().any(|token| {
224 !token.kind.is_trivia() && multiline_token_has_line_trailing_space(token.text)
225 });
226 let parse = sql_dialect_fmt_parser::parse_lexed(source, ctx.dialect, lexed);
227 let mut parse_errors = parse.errors().to_vec();
228 if base_offset > 0 {
229 adjust_parse_errors(&mut parse_errors, base_offset);
230 }
231 if has_lex_errors || has_multiline_trailing_space || !parse_errors.is_empty() {
232 return FormatResult {
233 formatted: source.to_string(),
234 parse_errors,
235 };
236 }
237 let root = parse.syntax();
238 let doc = sql::lower_source(&root, ctx);
239 let printed = print(&doc, &options.print_options());
240 FormatResult {
241 formatted: apply_line_ending(&printed, source, options.line_ending),
242 parse_errors,
243 }
244}
245
246fn format_directive_regions(source: &str, options: &FormatOptions) -> Option<FormatResult> {
247 let regions = directive_regions(source)?;
248 let mut formatted = String::new();
249 let mut parse_errors = Vec::new();
250 for region in regions {
251 let text = &source[region.start..region.end];
252 if region.enabled {
253 let result = format_plain_with_diagnostics(text, options, region.start);
254 formatted.push_str(&result.formatted);
255 parse_errors.extend(result.parse_errors);
256 } else {
257 formatted.push_str(text);
258 }
259 }
260 let index = sql_dialect_fmt_text::LineIndex::new(source);
261 for error in &mut parse_errors {
262 error.line_column = Some(index.line_column(error.offset));
263 }
264 let formatted = apply_line_ending(&formatted, source, options.line_ending);
265 Some(FormatResult {
266 formatted,
267 parse_errors,
268 })
269}
270
271#[derive(Clone, Copy)]
272struct FormatRegion {
273 start: usize,
274 end: usize,
275 enabled: bool,
276}
277
278fn directive_regions(source: &str) -> Option<Vec<FormatRegion>> {
279 let mut regions = Vec::new();
280 let mut disabled = false;
281 let mut region_start = 0usize;
282 let mut line_start = 0usize;
283 let mut saw_directive = false;
284
285 for line in source.split_inclusive('\n') {
286 let line_end = line_start + line.len();
287 match format_directive(line) {
288 Some(FormatDirective::Off) if !disabled => {
289 saw_directive = true;
290 if region_start < line_start {
291 regions.push(FormatRegion {
292 start: region_start,
293 end: line_start,
294 enabled: true,
295 });
296 }
297 disabled = true;
298 region_start = line_start;
299 }
300 Some(FormatDirective::On) if disabled => {
301 saw_directive = true;
302 regions.push(FormatRegion {
303 start: region_start,
304 end: line_end,
305 enabled: false,
306 });
307 disabled = false;
308 region_start = line_end;
309 }
310 _ => {}
311 }
312 line_start = line_end;
313 }
314
315 if line_start < source.len() {
316 let line = &source[line_start..];
317 match format_directive(line) {
318 Some(FormatDirective::Off) if !disabled => {
319 saw_directive = true;
320 if region_start < line_start {
321 regions.push(FormatRegion {
322 start: region_start,
323 end: line_start,
324 enabled: true,
325 });
326 }
327 disabled = true;
328 region_start = line_start;
329 }
330 Some(FormatDirective::On) if disabled => {
331 saw_directive = true;
332 regions.push(FormatRegion {
333 start: region_start,
334 end: source.len(),
335 enabled: false,
336 });
337 disabled = false;
338 region_start = source.len();
339 }
340 _ => {}
341 }
342 }
343
344 if region_start < source.len() {
345 regions.push(FormatRegion {
346 start: region_start,
347 end: source.len(),
348 enabled: !disabled,
349 });
350 }
351 saw_directive.then_some(regions)
352}
353
354#[derive(Clone, Copy)]
355enum FormatDirective {
356 Off,
357 On,
358}
359
360fn format_directive(line: &str) -> Option<FormatDirective> {
361 let trimmed = line.trim_start();
362 let body = trimmed.strip_prefix("--")?.trim_start();
363 for prefix in ["sql-dialect-fmt:", "snowfmt:", "fmt:"] {
364 if let Some(rest) = body.strip_prefix(prefix) {
365 let word = rest.split_whitespace().next()?;
366 if word.eq_ignore_ascii_case("off") {
367 return Some(FormatDirective::Off);
368 }
369 if word.eq_ignore_ascii_case("on") {
370 return Some(FormatDirective::On);
371 }
372 }
373 }
374 None
375}
376
377fn adjust_parse_errors(errors: &mut [ParseError], base_offset: usize) {
378 for error in errors {
379 error.offset += base_offset;
380 }
381}
382
383fn apply_line_ending(text: &str, source: &str, line_ending: LineEnding) -> String {
384 let target = match line_ending {
385 LineEnding::Lf => "\n",
386 LineEnding::Crlf => "\r\n",
387 LineEnding::Auto => first_line_ending(source).unwrap_or("\n"),
388 };
389 if target == "\n" {
390 text.replace("\r\n", "\n")
391 } else {
392 text.replace("\r\n", "\n").replace('\n', "\r\n")
393 }
394}
395
396fn first_line_ending(source: &str) -> Option<&'static str> {
397 let bytes = source.as_bytes();
398 let mut i = 0;
399 while i < bytes.len() {
400 match bytes[i] {
401 b'\n' => return Some("\n"),
402 b'\r' if bytes.get(i + 1) == Some(&b'\n') => return Some("\r\n"),
403 _ => i += 1,
404 }
405 }
406 None
407}
408
409pub(crate) fn multiline_token_has_line_trailing_space(text: &str) -> bool {
410 text.lines()
411 .any(|line| line.ends_with(' ') || line.ends_with('\t'))
412}