Skip to main content

sqruff_lib/core/
config.rs

1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3
4use configparser::ini::Ini;
5use hashbrown::HashMap;
6use sqruff_lib_core::dialects::Dialect;
7use sqruff_lib_core::dialects::init::{DialectKind, dialect_readout};
8use sqruff_lib_core::errors::SQLFluffUserError;
9use sqruff_lib_core::parser::{IndentationConfig, Parser};
10pub use sqruff_lib_core::value::Value;
11use sqruff_lib_dialects::kind_to_dialect;
12
13use crate::templaters::TemplaterKind;
14use crate::utils::reflow::config::ReflowConfig;
15
16/// split_comma_separated_string takes a string and splits it on commas and
17/// trims and filters out empty strings.
18pub fn split_comma_separated_string(raw_str: &str) -> Value {
19    let values = raw_str
20        .split(',')
21        .filter_map(|x| {
22            let trimmed = x.trim();
23            (!trimmed.is_empty()).then(|| Value::String(trimmed.into()))
24        })
25        .collect();
26    Value::Array(values)
27}
28
29fn split_string_or_array(value: &Value) -> Option<Value> {
30    match value {
31        Value::String(raw) => Some(split_comma_separated_string(raw)),
32        Value::Array(values) => Some(Value::Array(
33            values
34                .iter()
35                .map(|value| value.as_string().unwrap())
36                .flat_map(|value| match split_comma_separated_string(value) {
37                    Value::Array(values) => values,
38                    _ => unreachable!(),
39                })
40                .collect(),
41        )),
42        _ => None,
43    }
44}
45
46/// The class that actually gets passed around as a config object.
47// TODO This is not a translation that is particularly accurate.
48#[derive(Debug, PartialEq, Clone)]
49pub struct FluffConfig {
50    pub(crate) indentation: FluffConfigIndentation,
51    pub raw: HashMap<String, Value>,
52    extra_config_path: Option<String>,
53    _configs: HashMap<String, HashMap<String, String>>,
54    pub(crate) dialect: Dialect,
55    sql_file_exts: Vec<String>,
56    reflow: ReflowConfig,
57}
58
59impl Default for FluffConfig {
60    fn default() -> Self {
61        Self::new(<_>::default(), None, None)
62    }
63}
64
65impl FluffConfig {
66    fn configured_dialect_kind_from_raw(configs: &HashMap<String, Value>) -> DialectKind {
67        match configs
68            .get("core")
69            .and_then(|map| map.as_map().unwrap().get("dialect"))
70        {
71            None => DialectKind::default(),
72            Some(Value::String(std)) => DialectKind::from_str(std).unwrap(),
73            _value => DialectKind::default(),
74        }
75    }
76
77    fn dialect_section_from_raw(
78        configs: &HashMap<String, Value>,
79        dialect_kind: DialectKind,
80    ) -> Option<&Value> {
81        configs
82            .get("dialect")
83            .and_then(|v| v.as_map())
84            .and_then(|m| m.get(dialect_kind.as_ref()))
85    }
86
87    pub fn override_dialect(&mut self, dialect: DialectKind) -> Result<(), String> {
88        self.dialect = kind_to_dialect(&dialect, None)
89            .ok_or(format!("Invalid dialect: {}", dialect.as_ref()))?;
90        Ok(())
91    }
92
93    pub fn get(&self, key: &str, section: &str) -> &Value {
94        &self.raw[section][key]
95    }
96
97    pub fn reflow(&self) -> &ReflowConfig {
98        &self.reflow
99    }
100
101    fn templater_root_section(&self) -> Option<&HashMap<String, Value>> {
102        self.raw.get("templater").and_then(Value::as_map)
103    }
104
105    pub fn templater_root_value(&self, key: &str) -> Option<&Value> {
106        self.templater_root_section()?.get(key)
107    }
108
109    pub fn templater_section(&self, templater: TemplaterKind) -> Option<&HashMap<String, Value>> {
110        self.templater_root_section()?
111            .get(templater.as_str())
112            .and_then(Value::as_map)
113    }
114
115    pub fn templater_value(&self, templater: TemplaterKind, key: &str) -> Option<&Value> {
116        self.templater_section(templater)?.get(key)
117    }
118
119    pub fn templater_context(&self, templater: TemplaterKind) -> Option<&HashMap<String, Value>> {
120        self.templater_value(templater, "context")
121            .and_then(Value::as_map)
122    }
123
124    pub fn reload_reflow(&mut self) {
125        self.reflow = ReflowConfig::from_fluff_config(self);
126    }
127
128    /// from_file creates a config object from a file path. The path is used both
129    /// to read the file content and to resolve relative `_path`/`_dir` values.
130    pub fn from_file(path: &Path) -> FluffConfig {
131        Self::try_from_file(path).unwrap()
132    }
133
134    pub fn try_from_file(path: &Path) -> Result<FluffConfig, SQLFluffUserError> {
135        let mut configs = HashMap::new();
136        ConfigLoader::try_load_config_file(path, &mut configs)?;
137        Ok(FluffConfig::new(configs, None, None))
138    }
139
140    /// from_source creates a config object from a string. This is used for testing and for
141    /// loading a config from a string.
142    ///
143    /// The optional_path_specification is used to specify a path to use for relative paths in the
144    /// config. This is useful for testing.
145    pub fn from_source(source: &str, optional_path_specification: Option<&Path>) -> FluffConfig {
146        Self::try_from_source(source, optional_path_specification).unwrap()
147    }
148
149    pub fn try_from_source(
150        source: &str,
151        optional_path_specification: Option<&Path>,
152    ) -> Result<FluffConfig, SQLFluffUserError> {
153        let configs = ConfigLoader::try_from_source(source, optional_path_specification)?;
154        Ok(FluffConfig::new(configs, None, None))
155    }
156
157    pub fn get_section(&self, section: &str) -> &HashMap<String, Value> {
158        self.raw[section].as_map().unwrap()
159    }
160
161    pub fn dialect_kind(&self) -> DialectKind {
162        self.dialect.name()
163    }
164
165    pub fn templater_kind(&self) -> Result<TemplaterKind, String> {
166        self.get("templater", "core")
167            .as_string()
168            .map(TemplaterKind::from_name)
169            .transpose()
170            .map(|templater| templater.unwrap_or(TemplaterKind::Raw))
171    }
172
173    pub fn dialect_section(&self, dialect_kind: DialectKind) -> Option<&Value> {
174        Self::dialect_section_from_raw(&self.raw, dialect_kind)
175    }
176
177    // TODO This is not a translation that is particularly accurate.
178    pub fn new(
179        configs: HashMap<String, Value>,
180        extra_config_path: Option<String>,
181        indentation: Option<FluffConfigIndentation>,
182    ) -> Self {
183        fn nested_combine(
184            mut a: HashMap<String, Value>,
185            b: HashMap<String, Value>,
186        ) -> HashMap<String, Value> {
187            for (key, value_b) in b {
188                match (a.get(&key), value_b) {
189                    (Some(Value::Map(map_a)), Value::Map(map_b)) => {
190                        let combined = nested_combine(map_a.clone(), map_b);
191                        a.insert(key, Value::Map(combined));
192                    }
193                    (_, value) => {
194                        a.insert(key, value);
195                    }
196                }
197            }
198            a
199        }
200
201        let values = ConfigLoader::get_config_elems_from_file(
202            None,
203            include_str!("./default_config.cfg").into(),
204        );
205
206        let mut defaults = HashMap::new();
207        ConfigLoader::incorporate_vals(&mut defaults, values);
208
209        let mut configs = nested_combine(defaults, configs);
210
211        let dialect_kind = Self::configured_dialect_kind_from_raw(&configs);
212
213        // Extract dialect-specific configuration section (e.g., [sqruff:dialect:snowflake])
214        let dialect_config = Self::dialect_section_from_raw(&configs, dialect_kind);
215
216        let dialect = kind_to_dialect(&dialect_kind, dialect_config);
217        for (in_key, out_key) in [
218            // Deal with potential ignore & warning parameters
219            ("ignore", "ignore"),
220            ("warnings", "warnings"),
221            ("rules", "rule_allowlist"),
222            // Allowlists and denylistsignore_words
223            ("exclude_rules", "rule_denylist"),
224        ] {
225            match configs["core"].as_map().unwrap().get(in_key) {
226                Some(value) if !value.is_none() => {
227                    let values = split_string_or_array(value).unwrap();
228
229                    configs
230                        .get_mut("core")
231                        .unwrap()
232                        .as_map_mut()
233                        .unwrap()
234                        .insert(out_key.into(), values);
235                }
236                _ => {}
237            }
238        }
239
240        let sql_file_exts = configs["core"]["sql_file_exts"]
241            .as_array()
242            .unwrap()
243            .iter()
244            .map(|it| it.as_string().unwrap().to_owned())
245            .collect();
246
247        let mut this = Self {
248            raw: configs,
249            dialect: dialect
250                .expect("Dialect is disabled. Please enable the corresponding feature."),
251            extra_config_path,
252            _configs: HashMap::new(),
253            indentation: indentation.unwrap_or_default(),
254            sql_file_exts,
255            reflow: ReflowConfig::default(),
256        };
257        this.reflow = ReflowConfig::from_fluff_config(&this);
258        this
259    }
260
261    pub fn with_sql_file_exts(mut self, exts: Vec<String>) -> Self {
262        self.sql_file_exts = exts;
263        self
264    }
265
266    /// Loads a config object just based on the root directory.
267    // TODO This is not a translation that is particularly accurate.
268    pub fn from_root(
269        extra_config_path: Option<String>,
270        ignore_local_config: bool,
271        overrides: Option<HashMap<String, String>>,
272    ) -> Result<FluffConfig, SQLFluffUserError> {
273        let loader = ConfigLoader {};
274        let mut config = loader.try_load_config_up_to_path(
275            ".",
276            extra_config_path.clone(),
277            ignore_local_config,
278        )?;
279
280        if let Some(overrides) = overrides
281            && let Some(dialect) = overrides.get("dialect")
282        {
283            let core = config
284                .entry("core".into())
285                .or_insert_with(|| Value::Map(HashMap::new()));
286
287            core.as_map_mut()
288                .unwrap()
289                .insert("dialect".into(), Value::String(dialect.clone().into()));
290        }
291
292        Ok(FluffConfig::new(config, extra_config_path, None))
293    }
294
295    pub fn from_kwargs(
296        config: Option<FluffConfig>,
297        dialect: Option<Dialect>,
298        rules: Option<Vec<String>>,
299    ) -> Self {
300        if (dialect.is_some() || rules.is_some()) && config.is_some() {
301            panic!(
302                "Cannot specify `config` with `dialect` or `rules`. Any config object specifies \
303                 its own dialect and rules."
304            )
305        } else {
306            config.unwrap()
307        }
308    }
309
310    /// Process a full raw file for inline config and update self.
311    pub fn process_raw_file_for_config(&self, raw_str: &str) {
312        // Scan the raw file for config commands
313        for raw_line in raw_str.lines() {
314            if raw_line.to_string().starts_with("-- sqlfluff") {
315                // Found an in-file config command
316                self.process_inline_config(raw_line)
317            }
318        }
319    }
320
321    /// Process an inline config command and update self.
322    pub fn process_inline_config(&self, _config_line: &str) {
323        panic!("Not implemented")
324    }
325
326    /// Check if the config specifies a dialect, raising an error if not.
327    pub fn verify_dialect_specified(&self) -> Option<SQLFluffUserError> {
328        if self._configs.get("core")?.get("dialect").is_some() {
329            return None;
330        }
331        // Get list of available dialects for the error message. We must
332        // import here rather than at file scope in order to avoid a circular
333        // import.
334        Some(SQLFluffUserError::new(format!(
335            "No dialect was specified. You must configure a dialect or
336specify one on the command line using --dialect after the
337command. Available dialects: {}",
338            dialect_readout().join(", ").as_str()
339        )))
340    }
341
342    pub fn get_dialect(&self) -> &Dialect {
343        &self.dialect
344    }
345
346    pub fn sql_file_exts(&self) -> &[String] {
347        self.sql_file_exts.as_ref()
348    }
349}
350
351#[derive(Debug, PartialEq, Clone)]
352pub struct FluffConfigIndentation {
353    pub template_blocks_indent: bool,
354}
355
356impl Default for FluffConfigIndentation {
357    fn default() -> Self {
358        Self {
359            template_blocks_indent: true,
360        }
361    }
362}
363
364pub struct ConfigLoader;
365
366impl ConfigLoader {
367    #[allow(unused_variables)]
368    fn iter_config_locations_up_to_path(
369        path: &Path,
370        working_path: Option<&Path>,
371        ignore_local_config: bool,
372    ) -> impl Iterator<Item = PathBuf> {
373        let mut given_path = std::path::absolute(path).unwrap();
374        let working_path = std::env::current_dir().unwrap();
375
376        if !given_path.is_dir() {
377            given_path = given_path.parent().unwrap().into();
378        }
379
380        let common_path = common_path::common_path(&given_path, working_path).unwrap();
381        let mut path_to_visit = common_path;
382
383        let head = Some(given_path.canonicalize().unwrap()).into_iter();
384        let tail = std::iter::from_fn(move || {
385            if path_to_visit != given_path {
386                let path = path_to_visit.canonicalize().unwrap();
387
388                let next_path_to_visit = {
389                    // Convert `path_to_visit` & `given_path` to `Path`
390                    let path_to_visit_as_path = path_to_visit.as_path();
391                    let given_path_as_path = given_path.as_path();
392
393                    // Attempt to create a relative path from `given_path` to `path_to_visit`
394                    match given_path_as_path.strip_prefix(path_to_visit_as_path) {
395                        Ok(relative_path) => {
396                            // Get the first component of the relative path
397                            if let Some(first_part) = relative_path.components().next() {
398                                // Combine `path_to_visit` with the first part of the relative path
399                                path_to_visit.join(first_part.as_os_str())
400                            } else {
401                                // If there are no components in the relative path, return
402                                // `path_to_visit`
403                                path_to_visit.clone()
404                            }
405                        }
406                        Err(_) => {
407                            // If `given_path` is not relative to `path_to_visit`, handle the error
408                            // (e.g., return `path_to_visit`)
409                            // This part depends on how you want to handle the error.
410                            path_to_visit.clone()
411                        }
412                    }
413                };
414
415                if next_path_to_visit == path_to_visit {
416                    return None;
417                }
418
419                path_to_visit = next_path_to_visit;
420
421                Some(path)
422            } else {
423                None
424            }
425        });
426
427        head.chain(tail)
428    }
429
430    pub fn load_config_up_to_path(
431        &self,
432        path: impl AsRef<Path>,
433        extra_config_path: Option<String>,
434        ignore_local_config: bool,
435    ) -> HashMap<String, Value> {
436        self.try_load_config_up_to_path(path, extra_config_path, ignore_local_config)
437            .unwrap()
438    }
439
440    pub fn try_load_config_up_to_path(
441        &self,
442        path: impl AsRef<Path>,
443        extra_config_path: Option<String>,
444        ignore_local_config: bool,
445    ) -> Result<HashMap<String, Value>, SQLFluffUserError> {
446        let path = path.as_ref();
447
448        let config_stack = if ignore_local_config {
449            if let Some(path) = extra_config_path {
450                vec![self.try_load_config_at_path(path)?]
451            } else {
452                Vec::new()
453            }
454        } else {
455            let configs = Self::iter_config_locations_up_to_path(path, None, ignore_local_config);
456            configs
457                .map(|path| self.try_load_config_at_path(path))
458                .collect::<Result<Vec<_>, _>>()?
459        };
460
461        Ok(nested_combine(config_stack))
462    }
463
464    pub fn load_config_at_path(&self, path: impl AsRef<Path>) -> HashMap<String, Value> {
465        self.try_load_config_at_path(path).unwrap()
466    }
467
468    pub fn try_load_config_at_path(
469        &self,
470        path: impl AsRef<Path>,
471    ) -> Result<HashMap<String, Value>, SQLFluffUserError> {
472        let path = path.as_ref();
473
474        let filename_options = [
475            /* "setup.cfg", "tox.ini", "pep8.ini", */
476            ".sqlfluff",
477            ".sqruff",
478            ".sqruff.ini",
479            "pyproject.toml",
480            "sqruff.toml",
481        ];
482
483        let mut configs = HashMap::new();
484
485        if path.is_dir() {
486            for fname in filename_options {
487                let path = path.join(fname);
488                if path.exists() {
489                    ConfigLoader::try_load_config_file(path, &mut configs)?;
490                }
491            }
492        } else if path.is_file() {
493            ConfigLoader::try_load_config_file(path, &mut configs)?;
494        };
495
496        Ok(configs)
497    }
498
499    pub fn from_source(source: &str, path: Option<&Path>) -> HashMap<String, Value> {
500        Self::try_from_source(source, path).unwrap()
501    }
502
503    pub fn try_from_source(
504        source: &str,
505        path: Option<&Path>,
506    ) -> Result<HashMap<String, Value>, SQLFluffUserError> {
507        let mut configs = HashMap::new();
508        let elems = ConfigLoader::try_get_config_elems_from_file(path, Some(source))?;
509        ConfigLoader::incorporate_vals(&mut configs, elems);
510        Ok(configs)
511    }
512
513    pub fn load_config_file(path: impl AsRef<Path>, configs: &mut HashMap<String, Value>) {
514        Self::try_load_config_file(path, configs).unwrap();
515    }
516
517    pub fn try_load_config_file(
518        path: impl AsRef<Path>,
519        configs: &mut HashMap<String, Value>,
520    ) -> Result<(), SQLFluffUserError> {
521        let elems = ConfigLoader::try_get_config_elems_from_file(path.as_ref().into(), None)?;
522        ConfigLoader::incorporate_vals(configs, elems);
523        Ok(())
524    }
525
526    fn get_config_elems_from_file(
527        config_path: Option<&Path>,
528        config_string: Option<&str>,
529    ) -> Vec<(Vec<String>, Value)> {
530        Self::try_get_config_elems_from_file(config_path, config_string).unwrap()
531    }
532
533    fn try_get_config_elems_from_file(
534        config_path: Option<&Path>,
535        config_string: Option<&str>,
536    ) -> Result<Vec<(Vec<String>, Value)>, SQLFluffUserError> {
537        let content = match (config_path, config_string) {
538            (None, None) => {
539                unimplemented!("One of fpath or config_string is required.")
540            }
541            (_, Some(text)) => text.to_owned(),
542            (Some(path), None) => std::fs::read_to_string(path).map_err(|err| {
543                config_error(config_path, format!("Unable to read config file: {err}"))
544            })?,
545        };
546
547        if is_toml_config(config_path) {
548            return parse_toml_config_elems(&content, config_path);
549        }
550
551        parse_ini_config_elems(&content, config_path)
552    }
553
554    fn incorporate_vals(ctx: &mut HashMap<String, Value>, values: Vec<(Vec<String>, Value)>) {
555        for (path, value) in values {
556            let mut current_map = &mut *ctx;
557            for key in path.iter().take(path.len() - 1) {
558                match current_map
559                    .entry(key.to_string())
560                    .or_insert_with(|| Value::Map(HashMap::new()))
561                    .as_map_mut()
562                {
563                    Some(slot) => current_map = slot,
564                    None => panic!("Overriding config value with section! [{path:?}]"),
565                }
566            }
567
568            let last_key = path.last().expect("Expected at least one element in path");
569            current_map.insert(last_key.to_string(), value);
570        }
571    }
572}
573
574fn is_toml_config(config_path: Option<&Path>) -> bool {
575    config_path.is_some_and(|path| {
576        path.file_name()
577            .and_then(|name| name.to_str())
578            .is_some_and(|name| name == "pyproject.toml" || name.ends_with(".toml"))
579    })
580}
581
582fn config_error(config_path: Option<&Path>, message: impl std::fmt::Display) -> SQLFluffUserError {
583    let location = config_path
584        .map(|path| path.display().to_string())
585        .unwrap_or_else(|| "config source".to_owned());
586    SQLFluffUserError::new(format!("Error loading config from {location}: {}", message))
587}
588
589fn parse_ini_config_elems(
590    content: &str,
591    config_path: Option<&Path>,
592) -> Result<Vec<(Vec<String>, Value)>, SQLFluffUserError> {
593    let mut buff = Vec::new();
594    let mut config = Ini::new();
595
596    config
597        .read(content.to_owned())
598        .map_err(|err| config_error(config_path, err))?;
599
600    for section in config.sections() {
601        let key = if section == "sqlfluff" || section == "sqruff" {
602            vec!["core".to_owned()]
603        } else if let Some(key) = section
604            .strip_prefix("sqlfluff:")
605            .or_else(|| section.strip_prefix("sqruff:"))
606        {
607            key.split(':').map(ToOwned::to_owned).collect()
608        } else {
609            continue;
610        };
611
612        let config_map = config.get_map_ref();
613        if let Some(section) = config_map.get(&section) {
614            for (name, value) in section {
615                let mut value: Value = value.as_deref().unwrap_or_default().parse().unwrap();
616                let name_lowercase = name.to_lowercase();
617
618                if name_lowercase == "load_macros_from_path" {
619                    unimplemented!()
620                } else if name_lowercase.ends_with("_path") || name_lowercase.ends_with("_dir") {
621                    value = resolve_relative_config_path(value, config_path);
622                }
623
624                let mut key = key.clone();
625                key.push(name.clone());
626                buff.push((key, value));
627            }
628        }
629    }
630
631    Ok(buff)
632}
633
634fn parse_toml_config_elems(
635    content: &str,
636    config_path: Option<&Path>,
637) -> Result<Vec<(Vec<String>, Value)>, SQLFluffUserError> {
638    let root = content
639        .parse::<toml::Table>()
640        .map_err(|err| config_error(config_path, err))?;
641
642    let mut buff = Vec::new();
643
644    for config_root in ["sqlfluff", "sqruff"] {
645        if let Some(table) = root.get(config_root).and_then(toml::Value::as_table) {
646            collect_toml_config_elems(table, Vec::new(), config_path, &mut buff);
647        }
648    }
649
650    if let Some(tool) = root.get("tool").and_then(toml::Value::as_table) {
651        for config_root in ["sqlfluff", "sqruff"] {
652            if let Some(table) = tool.get(config_root).and_then(toml::Value::as_table) {
653                collect_toml_config_elems(table, Vec::new(), config_path, &mut buff);
654            }
655        }
656    }
657
658    Ok(buff)
659}
660
661fn collect_toml_config_elems(
662    table: &toml::Table,
663    section_path: Vec<String>,
664    config_path: Option<&Path>,
665    buff: &mut Vec<(Vec<String>, Value)>,
666) {
667    for (name, value) in table {
668        match value {
669            toml::Value::Table(table) => {
670                let mut section_path = section_path.clone();
671                section_path.push(name.to_owned());
672                collect_toml_config_elems(table, section_path, config_path, buff);
673            }
674            value => {
675                if name == "load_macros_from_path" {
676                    unimplemented!()
677                }
678
679                let mut value = toml_value_to_config_value(value);
680                if name.ends_with("_path") || name.ends_with("_dir") {
681                    value = resolve_relative_config_path(value, config_path);
682                }
683
684                let key = toml_config_key_path(&section_path, name);
685                buff.push((key, value));
686            }
687        }
688    }
689}
690
691fn toml_config_key_path(section_path: &[String], key: &str) -> Vec<String> {
692    if section_path.is_empty()
693        || (section_path.len() == 1
694            && section_path
695                .first()
696                .is_some_and(|section| section == "core"))
697    {
698        vec!["core".to_owned(), key.to_owned()]
699    } else if section_path
700        .first()
701        .is_some_and(|section| section == "rules")
702        && section_path.len() > 1
703    {
704        let rest = &section_path[1..];
705        vec!["rules".to_owned(), rest.join("."), key.to_owned()]
706    } else {
707        section_path
708            .iter()
709            .cloned()
710            .chain(std::iter::once(key.to_owned()))
711            .collect()
712    }
713}
714
715fn toml_value_to_config_value(value: &toml::Value) -> Value {
716    match value {
717        toml::Value::String(value) => Value::String(value.clone().into()),
718        toml::Value::Integer(value) => {
719            Value::Int((*value).try_into().expect("TOML integer out of i32 range"))
720        }
721        toml::Value::Float(value) => Value::Float(*value),
722        toml::Value::Boolean(value) => Value::Bool(*value),
723        toml::Value::Datetime(value) => Value::String(value.to_string().into()),
724        toml::Value::Array(values) => Value::Array(
725            values
726                .iter()
727                .map(toml_value_to_config_value)
728                .collect::<Vec<_>>(),
729        ),
730        toml::Value::Table(values) => Value::Map(
731            values
732                .iter()
733                .map(|(key, value)| (key.clone(), toml_value_to_config_value(value)))
734                .collect(),
735        ),
736    }
737}
738
739fn resolve_relative_config_path(mut value: Value, config_path: Option<&Path>) -> Value {
740    let path = PathBuf::from(value.as_string().unwrap());
741    if !path.is_absolute() {
742        let config_path = config_path.unwrap().parent().unwrap();
743        let current_dir = std::env::current_dir().unwrap();
744        let config_path = current_dir.join(config_path);
745        let config_path = std::path::absolute(config_path).unwrap();
746        let path = config_path.join(path);
747        let path: String = path.to_string_lossy().into();
748        value = Value::String(path.into());
749    }
750    value
751}
752
753fn nested_combine(config_stack: Vec<HashMap<String, Value>>) -> HashMap<String, Value> {
754    let capacity = config_stack.len();
755    let mut result = HashMap::with_capacity(capacity);
756
757    for dict in config_stack {
758        for (key, value) in dict {
759            result.insert(key, value);
760        }
761    }
762
763    result
764}
765
766impl<'a> From<&'a FluffConfig> for Parser<'a> {
767    fn from(config: &'a FluffConfig) -> Self {
768        let dialect = config.get_dialect();
769        let indentation_section = &config.raw["indentation"];
770        let indentation_config =
771            IndentationConfig::from_bool_lookup(|key| indentation_section[key].to_bool());
772        Self::new(dialect, indentation_config)
773    }
774}
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use sqruff_lib_core::dialects::init::DialectKind;
780    use std::fs;
781    use std::time::{SystemTime, UNIX_EPOCH};
782
783    fn temp_config_dir(name: &str) -> PathBuf {
784        let nonce = SystemTime::now()
785            .duration_since(UNIX_EPOCH)
786            .unwrap()
787            .as_nanos();
788        let path = std::env::temp_dir().join(format!(
789            "sqruff-config-{name}-{}-{nonce}",
790            std::process::id()
791        ));
792        fs::create_dir_all(&path).unwrap();
793        path
794    }
795
796    #[test]
797    fn test_dialect_config_section_parsing() {
798        // Test that [sqruff:dialect:snowflake] section is correctly parsed
799        let config = FluffConfig::from_source(
800            r#"
801[sqruff]
802dialect = snowflake
803
804[sqruff:dialect:snowflake]
805some_option = value
806"#,
807            None,
808        );
809
810        // Verify that the dialect config section is accessible
811        let dialect_section = config.raw.get("dialect");
812        assert!(dialect_section.is_some());
813
814        let snowflake_config = dialect_section.unwrap().as_map().unwrap().get("snowflake");
815        assert!(snowflake_config.is_some());
816
817        let snowflake_map = snowflake_config.unwrap().as_map().unwrap();
818        assert_eq!(
819            snowflake_map.get("some_option").unwrap().as_string(),
820            Some("value")
821        );
822    }
823
824    #[test]
825    fn test_dialect_config_empty_section() {
826        // Test that empty [sqruff:dialect:bigquery] section works
827        let config = FluffConfig::from_source(
828            r#"
829[sqruff]
830dialect = bigquery
831
832[sqruff:dialect:bigquery]
833"#,
834            None,
835        );
836
837        // The config should still be valid
838        assert_eq!(config.get_dialect().name, DialectKind::Bigquery);
839    }
840
841    #[test]
842    fn test_dialect_without_config_section() {
843        // Test that a dialect works without a config section
844        let config = FluffConfig::from_source(
845            r#"
846[sqruff]
847dialect = postgres
848"#,
849            None,
850        );
851
852        // The config should still be valid
853        assert_eq!(config.get_dialect().name, DialectKind::Postgres);
854    }
855
856    #[test]
857    fn test_templater_kind_defaults_to_raw() {
858        let config = FluffConfig::from_source("", None);
859        assert_eq!(config.templater_kind().unwrap(), TemplaterKind::Raw);
860    }
861
862    #[test]
863    fn test_templater_kind_parses_placeholder() {
864        let config = FluffConfig::from_source(
865            r#"
866[sqruff]
867templater = placeholder
868"#,
869            None,
870        );
871
872        assert_eq!(config.templater_kind().unwrap(), TemplaterKind::Placeholder);
873    }
874
875    #[test]
876    fn test_templater_section_uses_typed_kind() {
877        let config = FluffConfig::from_source(
878            r#"
879[sqruff]
880templater = placeholder
881
882[sqruff:templater:placeholder]
883param_style = colon
884"#,
885            None,
886        );
887
888        let section = config
889            .templater_section(TemplaterKind::Placeholder)
890            .unwrap();
891        assert_eq!(
892            section.get("param_style").unwrap().as_string(),
893            Some("colon")
894        );
895    }
896
897    #[test]
898    fn test_sqruff_toml_parses_sqlfluff_root_config() {
899        let config = FluffConfig::from_source(
900            r#"
901[sqlfluff]
902dialect = "postgres"
903templater = "placeholder"
904max_line_length = 60
905
906[sqlfluff.indentation]
907indented_joins = false
908
909[sqlfluff.layout.type.comma]
910line_position = "trailing"
911"#,
912            Some(Path::new("sqruff.toml")),
913        );
914
915        assert_eq!(config.get_dialect().name, DialectKind::Postgres);
916        assert_eq!(config.templater_kind().unwrap(), TemplaterKind::Placeholder);
917        assert_eq!(config.raw["core"]["max_line_length"].as_int(), Some(60));
918        assert_eq!(
919            config.raw["indentation"]["indented_joins"].as_bool(),
920            Some(false)
921        );
922        assert_eq!(
923            config.raw["layout"]["type"]["comma"]["line_position"].as_string(),
924            Some("trailing")
925        );
926    }
927
928    #[test]
929    fn test_pyproject_toml_parses_tool_sqlfluff_config() {
930        let config = FluffConfig::from_source(
931            r#"
932[project]
933name = "example"
934
935[tool.sqlfluff.core]
936dialect = "postgres"
937templater = "placeholder"
938max_line_length = 42
939exclude_rules = ["CP01", "LT05"]
940
941[tool.sqlfluff.templater.placeholder]
942param_style = "colon"
943
944[tool.sqlfluff.rules.capitalisation.keywords]
945capitalisation_policy = "upper"
946"#,
947            Some(Path::new("pyproject.toml")),
948        );
949
950        assert_eq!(config.get_dialect().name, DialectKind::Postgres);
951        assert_eq!(config.templater_kind().unwrap(), TemplaterKind::Placeholder);
952        assert_eq!(config.raw["core"]["max_line_length"].as_int(), Some(42));
953        assert_eq!(
954            config.raw["core"]["rule_denylist"].as_array().unwrap(),
955            vec![Value::String("CP01".into()), Value::String("LT05".into())]
956        );
957        assert_eq!(
958            config
959                .templater_section(TemplaterKind::Placeholder)
960                .unwrap()
961                .get("param_style")
962                .unwrap()
963                .as_string(),
964            Some("colon")
965        );
966        assert_eq!(
967            config.raw["rules"]["capitalisation.keywords"]["capitalisation_policy"].as_string(),
968            Some("upper")
969        );
970    }
971
972    #[test]
973    fn test_load_config_at_path_discovers_toml_configs() {
974        let dir = temp_config_dir("toml-discovery");
975        fs::write(
976            dir.join("pyproject.toml"),
977            r#"
978[tool.sqlfluff.core]
979max_line_length = 41
980"#,
981        )
982        .unwrap();
983        fs::write(
984            dir.join("sqruff.toml"),
985            r#"
986[sqruff]
987max_line_length = 39
988"#,
989        )
990        .unwrap();
991
992        let config = FluffConfig::new(ConfigLoader {}.load_config_at_path(&dir), None, None);
993        fs::remove_dir_all(&dir).unwrap();
994
995        assert_eq!(config.raw["core"]["max_line_length"].as_int(), Some(39));
996    }
997
998    #[test]
999    fn test_load_config_at_path_discovers_sqruff_ini() {
1000        let dir = temp_config_dir("sqruff-ini");
1001        fs::write(
1002            dir.join(".sqruff.ini"),
1003            r#"
1004[sqruff]
1005max_line_length = 44
1006"#,
1007        )
1008        .unwrap();
1009
1010        let config = FluffConfig::new(ConfigLoader {}.load_config_at_path(&dir), None, None);
1011        fs::remove_dir_all(&dir).unwrap();
1012
1013        assert_eq!(config.raw["core"]["max_line_length"].as_int(), Some(44));
1014    }
1015
1016    #[test]
1017    fn test_try_from_source_invalid_toml_returns_error() {
1018        let err = FluffConfig::try_from_source(
1019            "[sqlfluff]\ndialect = \"ansi",
1020            Some(Path::new("sqruff.toml")),
1021        )
1022        .unwrap_err();
1023
1024        assert!(
1025            err.to_string()
1026                .contains("Error loading config from sqruff.toml")
1027        );
1028    }
1029
1030    #[cfg(feature = "python")]
1031    #[test]
1032    fn test_templater_context_uses_typed_kind() {
1033        let config = FluffConfig::from_source(
1034            r#"
1035[sqruff]
1036templater = python
1037
1038[sqruff:templater:python:context]
1039blah = foo
1040"#,
1041            None,
1042        );
1043
1044        let context = config.templater_context(TemplaterKind::Python).unwrap();
1045        assert_eq!(context.get("blah").unwrap().as_string(), Some("foo"));
1046    }
1047}