Skip to main content

sql_dialect_fmt_config/
lib.rs

1//! `sql-dialect-fmt.toml` configuration files.
2//!
3//! Shared by the CLI and the language server so both discover and apply project configuration the
4//! same way. A config file maps onto the formatter's [`FormatOptions`] plus the CLI-only `exclude`
5//! file-discovery knob. Every key is optional, so a file may set only the knobs it cares about.
6//! Discovery walks up the directory tree from a start point (an input file's parent, or the current
7//! working directory) and uses the **nearest** `sql-dialect-fmt.toml` — the first one found on the
8//! way up — mirroring how `rustfmt`, `prettier`, and friends scope project configuration. Explicit
9//! overrides (CLI flags, or LSP editor settings) always win over formatter options from the file.
10
11use std::path::{Path, PathBuf};
12
13use serde::{Deserialize, Deserializer};
14use sql_dialect_fmt_formatter::{FormatOptions, KeywordCase, LineEnding};
15use sql_dialect_fmt_parser::Dialect;
16
17/// The file name the CLI looks for when walking up directories.
18pub const CONFIG_FILE_NAME: &str = "sql-dialect-fmt.toml";
19
20/// A parsed `sql-dialect-fmt.toml`. Every field is optional; absent fields fall back to the formatter
21/// defaults (or to a CLI flag, which is layered on top afterwards).
22#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)]
23#[serde(deny_unknown_fields, rename_all = "snake_case")]
24pub struct Config {
25    /// Target line width the printer keeps within where it can.
26    pub line_width: Option<usize>,
27    /// Spaces per indentation level.
28    pub indent_width: Option<usize>,
29    /// Upper-case SQL keywords.
30    pub uppercase_keywords: Option<bool>,
31    /// Keyword casing policy.
32    #[serde(default, deserialize_with = "deserialize_keyword_case")]
33    pub keyword_case: Option<KeywordCase>,
34    /// Output line-ending policy.
35    #[serde(default, deserialize_with = "deserialize_line_ending")]
36    pub line_ending: Option<LineEnding>,
37    /// SQL dialect to parse and format.
38    #[serde(default, deserialize_with = "deserialize_dialect")]
39    pub dialect: Option<Dialect>,
40    /// Glob patterns skipped during recursive directory discovery.
41    #[serde(default)]
42    pub exclude: Vec<String>,
43}
44
45pub fn parse_dialect(value: &str) -> Result<Dialect, String> {
46    match value.to_ascii_lowercase().as_str() {
47        "snowflake" => Ok(Dialect::Snowflake),
48        "databricks" => Ok(Dialect::Databricks),
49        _ => Err(format!(
50            "dialect expects one of: snowflake, databricks; got {value:?}"
51        )),
52    }
53}
54
55pub fn parse_keyword_case(value: &str) -> Result<KeywordCase, String> {
56    match value.to_ascii_lowercase().as_str() {
57        "upper" => Ok(KeywordCase::Upper),
58        "lower" => Ok(KeywordCase::Lower),
59        "preserve" => Ok(KeywordCase::Preserve),
60        _ => Err(format!(
61            "keyword_case expects one of: upper, lower, preserve; got {value:?}"
62        )),
63    }
64}
65
66pub fn parse_line_ending(value: &str) -> Result<LineEnding, String> {
67    match value.to_ascii_lowercase().as_str() {
68        "auto" => Ok(LineEnding::Auto),
69        "lf" => Ok(LineEnding::Lf),
70        "crlf" => Ok(LineEnding::Crlf),
71        _ => Err(format!(
72            "line_ending expects one of: auto, lf, crlf; got {value:?}"
73        )),
74    }
75}
76
77fn deserialize_dialect<'de, D>(deserializer: D) -> Result<Option<Dialect>, D::Error>
78where
79    D: Deserializer<'de>,
80{
81    let value = Option::<String>::deserialize(deserializer)?;
82    value
83        .map(|value| parse_dialect(&value).map_err(serde::de::Error::custom))
84        .transpose()
85}
86
87fn deserialize_keyword_case<'de, D>(deserializer: D) -> Result<Option<KeywordCase>, D::Error>
88where
89    D: Deserializer<'de>,
90{
91    let value = Option::<String>::deserialize(deserializer)?;
92    value
93        .map(|value| parse_keyword_case(&value).map_err(serde::de::Error::custom))
94        .transpose()
95}
96
97fn deserialize_line_ending<'de, D>(deserializer: D) -> Result<Option<LineEnding>, D::Error>
98where
99    D: Deserializer<'de>,
100{
101    let value = Option::<String>::deserialize(deserializer)?;
102    value
103        .map(|value| parse_line_ending(&value).map_err(serde::de::Error::custom))
104        .transpose()
105}
106
107impl Config {
108    /// Parse a config from TOML source text.
109    pub fn parse(text: &str) -> Result<Config, String> {
110        toml::from_str(text).map_err(|err| {
111            // `toml`'s message already carries line/column; strip the trailing newline it adds.
112            err.message().trim_end().to_string()
113        })
114    }
115
116    /// Read and parse a config file, attributing any error to `path`.
117    pub fn load(path: &Path) -> Result<Config, String> {
118        let text = std::fs::read_to_string(path)
119            .map_err(|err| format!("failed to read {}: {err}", path.display()))?;
120        Config::parse(&text).map_err(|err| format!("invalid config {}: {err}", path.display()))
121    }
122
123    /// Layer this config onto `options`, overwriting only the fields the file actually set.
124    pub fn apply_to(&self, options: &mut FormatOptions) {
125        if let Some(line_width) = self.line_width {
126            options.line_width = line_width;
127        }
128        if let Some(indent_width) = self.indent_width {
129            options.indent_width = indent_width;
130        }
131        if let Some(uppercase_keywords) = self.uppercase_keywords {
132            *options = (*options).with_uppercase_keywords(uppercase_keywords);
133        }
134        if let Some(keyword_case) = self.keyword_case {
135            *options = (*options).with_keyword_case(keyword_case);
136        }
137        if let Some(line_ending) = self.line_ending {
138            options.line_ending = line_ending;
139        }
140        if let Some(dialect) = self.dialect {
141            options.dialect = dialect;
142        }
143    }
144}
145
146/// Find the nearest `sql-dialect-fmt.toml` at or above `start`, returning its path (not its contents).
147///
148/// `start` may be a file or a directory; if it is a file we begin the walk at its parent. Returns
149/// `None` when no config exists anywhere up to the filesystem root. Never panics on odd paths.
150pub fn discover(start: &Path) -> Option<PathBuf> {
151    let mut dir: PathBuf = if start.is_dir() {
152        start.to_path_buf()
153    } else {
154        start.parent().map(Path::to_path_buf).unwrap_or_default()
155    };
156
157    // An empty directory means "current directory"; normalize so the walk-up terminates.
158    if dir.as_os_str().is_empty() {
159        dir = PathBuf::from(".");
160    }
161
162    loop {
163        let candidate = dir.join(CONFIG_FILE_NAME);
164        if candidate.is_file() {
165            return Some(candidate);
166        }
167        if !dir.pop() {
168            // Reached a relative-path origin like "."; try the absolute CWD chain once more.
169            return discover_from_cwd_if_relative(start);
170        }
171    }
172}
173
174/// When `start` was a relative path we may have walked up only to ".". Resolve the real working
175/// directory and continue the walk so a config in an ancestor of the CWD is still found.
176fn discover_from_cwd_if_relative(start: &Path) -> Option<PathBuf> {
177    if start.is_absolute() {
178        return None;
179    }
180    let cwd = std::env::current_dir().ok()?;
181    let mut dir = if start.is_dir() {
182        cwd.join(start)
183    } else {
184        match start.parent() {
185            Some(parent) if !parent.as_os_str().is_empty() => cwd.join(parent),
186            _ => cwd,
187        }
188    };
189    loop {
190        let candidate = dir.join(CONFIG_FILE_NAME);
191        if candidate.is_file() {
192            return Some(candidate);
193        }
194        if !dir.pop() {
195            return None;
196        }
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn parses_all_keys() {
206        let cfg = Config::parse(
207            "line_width = 80\nindent_width = 2\nuppercase_keywords = false\nkeyword_case = \"lower\"\nline_ending = \"crlf\"\ndialect = \"databricks\"\nexclude = [\"target/**\"]\n",
208        )
209        .expect("valid");
210        assert_eq!(cfg.line_width, Some(80));
211        assert_eq!(cfg.indent_width, Some(2));
212        assert_eq!(cfg.uppercase_keywords, Some(false));
213        assert_eq!(cfg.keyword_case, Some(KeywordCase::Lower));
214        assert_eq!(cfg.line_ending, Some(LineEnding::Crlf));
215        assert_eq!(cfg.dialect, Some(Dialect::Databricks));
216        assert_eq!(cfg.exclude, vec!["target/**"]);
217    }
218
219    #[test]
220    fn empty_config_is_all_none() {
221        assert_eq!(Config::parse("").expect("valid"), Config::default());
222    }
223
224    #[test]
225    fn partial_config_leaves_other_fields_default() {
226        let cfg = Config::parse("indent_width = 8\n").expect("valid");
227        assert_eq!(cfg.indent_width, Some(8));
228        assert_eq!(cfg.line_width, None);
229        assert_eq!(cfg.uppercase_keywords, None);
230        assert_eq!(cfg.keyword_case, None);
231        assert_eq!(cfg.line_ending, None);
232        assert_eq!(cfg.dialect, None);
233        assert!(cfg.exclude.is_empty());
234    }
235
236    #[test]
237    fn unknown_keys_are_rejected() {
238        assert!(Config::parse("tab_width = 4\n").is_err());
239    }
240
241    #[test]
242    fn malformed_toml_is_rejected() {
243        assert!(Config::parse("line_width = \n").is_err());
244    }
245
246    #[test]
247    fn apply_overrides_only_set_fields() {
248        let mut options = FormatOptions::default();
249        let cfg = Config::parse(
250            "line_width = 60\ndialect = \"databricks\"\nkeyword_case = \"lower\"\nline_ending = \"crlf\"\n",
251        )
252        .expect("valid");
253        cfg.apply_to(&mut options);
254        assert_eq!(options.line_width, 60);
255        assert_eq!(options.dialect, Dialect::Databricks);
256        assert_eq!(options.keyword_case, KeywordCase::Lower);
257        assert_eq!(options.line_ending, LineEnding::Crlf);
258        // Untouched fields keep their defaults.
259        assert_eq!(options.indent_width, 4);
260    }
261
262    #[test]
263    fn invalid_dialect_is_rejected() {
264        assert!(Config::parse("dialect = \"oracle\"\n").is_err());
265        assert!(parse_dialect("oracle").is_err());
266    }
267
268    #[test]
269    fn invalid_keyword_case_and_line_ending_are_rejected() {
270        assert!(Config::parse("keyword_case = \"title\"\n").is_err());
271        assert!(Config::parse("line_ending = \"native\"\n").is_err());
272        assert!(parse_keyword_case("title").is_err());
273        assert!(parse_line_ending("native").is_err());
274    }
275}