libdiffsitter/
config.rs

1//! Utilities and definitions for config handling
2
3use crate::input_processing::TreeSitterProcessor;
4use crate::{parse::GrammarConfig, render::RenderConfig};
5use anyhow::{Context, Result};
6use json5 as json;
7use log::info;
8use serde::{Deserialize, Serialize};
9use std::{
10    collections::HashMap,
11    fs, io,
12    path::{Path, PathBuf},
13};
14use thiserror::Error;
15
16#[cfg(target_os = "windows")]
17use directories_next::ProjectDirs;
18
19/// The expected filename for the config file
20const CFG_FILE_NAME: &str = "config.json5";
21
22/// The config struct for the application
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
24#[serde(rename_all = "kebab-case", default)]
25pub struct Config {
26    /// Custom file extension mappings between a file extension and a language
27    ///
28    /// These will be merged with the existing defaults, with the user-defined mappings taking
29    /// precedence. The existing mappings are available at: `parse::FILE_EXTS` and the user can
30    /// list all available langauges with `diffsitter --cmd list`
31    pub file_associations: Option<HashMap<String, String>>,
32
33    /// Formatting options for display
34    pub formatting: RenderConfig,
35
36    /// Options for loading
37    pub grammar: GrammarConfig,
38
39    /// Options for processing tree-sitter input.
40    pub input_processing: TreeSitterProcessor,
41
42    /// The program to invoke if the given files can not be parsed by the available tree-sitter
43    /// parsers.
44    ///
45    /// This will invoke the program with with the old and new file as arguments, like so:
46    ///
47    /// ```sh
48    /// ${FALLBACK_PROGRAM} ${OLD} ${NEW}
49    /// ```
50    pub fallback_cmd: Option<String>,
51}
52
53/// The possible errors that can arise when attempting to read a config
54#[derive(Error, Debug)]
55pub enum ReadError {
56    #[error("The file failed to deserialize")]
57    DeserializationFailure(#[from] anyhow::Error),
58    #[error("Failed to read the config file")]
59    ReadFileFailure(#[from] io::Error),
60    #[error("Unable to compute the default config file path")]
61    NoDefault,
62}
63
64impl Config {
65    /// Read a config from a given filepath, or fall back to the default file paths
66    ///
67    /// If a path is supplied, this method will attempt to read the contents of that path and parse
68    /// it to a string. If a path isn't supplied, the function will attempt to figure out what the
69    /// default config file path is supposed to be (based on OS conventions, see
70    /// [`default_config_file_path`]).
71    ///
72    /// # Errors
73    ///
74    /// This method will return an error if the config cannot be parsed or if no default config
75    /// exists.
76    pub fn try_from_file<P: AsRef<Path>>(path: Option<&P>) -> Result<Self, ReadError> {
77        // rustc will emit an incorrect warning that this variable isn't used, which is untrue.
78        // While the variable isn't read *directly*, it is used to store the owned PathBuf from
79        // `default_config_file_path` so we can use the reference to the variable in `config_fp`.
80        #[allow(unused_assignments)]
81        let mut default_config_fp = PathBuf::new();
82
83        let config_fp = if let Some(p) = path {
84            p.as_ref()
85        } else {
86            default_config_fp = default_config_file_path().map_err(|_| ReadError::NoDefault)?;
87            default_config_fp.as_ref()
88        };
89        info!("Reading config at {}", config_fp.to_string_lossy());
90        let config_contents = fs::read_to_string(config_fp)?;
91        let config = json::from_str(&config_contents)
92            .with_context(|| format!("Failed to parse config at {}", config_fp.to_string_lossy()))
93            .map_err(ReadError::DeserializationFailure)?;
94        Ok(config)
95    }
96}
97
98/// Return the default location for the config file (for *nix, Linux and `MacOS`), this will use
99/// $`XDG_CONFIG/.config`, where `$XDG_CONFIG` is `$HOME/.config` by default.
100#[cfg(not(target_os = "windows"))]
101fn default_config_file_path() -> Result<PathBuf> {
102    let xdg_dirs = xdg::BaseDirectories::with_prefix("diffsitter")?;
103    let file_path = xdg_dirs.place_config_file(CFG_FILE_NAME)?;
104    Ok(file_path)
105}
106
107/// Return the default location for the config file (for windows), this will use
108/// $XDG_CONFIG_HOME/.config, where `$XDG_CONFIG_HOME` is `$HOME/.config` by default.
109#[cfg(target_os = "windows")]
110fn default_config_file_path() -> Result<PathBuf> {
111    use anyhow::ensure;
112
113    let proj_dirs = ProjectDirs::from("io", "afnan", "diffsitter");
114    ensure!(proj_dirs.is_some(), "Was not able to retrieve config path");
115    let proj_dirs = proj_dirs.unwrap();
116    let mut config_file: PathBuf = proj_dirs.config_dir().into();
117    config_file.push(CFG_FILE_NAME);
118    Ok(config_file)
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use anyhow::Context;
125    use std::{env, fs::read_dir};
126
127    #[test]
128    fn test_sample_config() {
129        let repo_root =
130            env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| env::var("BUILD_DIR").unwrap());
131        assert!(!repo_root.is_empty());
132        let sample_config_path = [repo_root, "assets".into(), "sample_config.json5".into()]
133            .iter()
134            .collect::<PathBuf>();
135        assert!(sample_config_path.exists());
136        Config::try_from_file(Some(sample_config_path).as_ref()).unwrap();
137    }
138
139    #[test]
140    fn test_configs() {
141        let mut test_config_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
142        test_config_dir.push("resources/test_configs");
143        assert!(test_config_dir.is_dir());
144
145        for config_file_path in read_dir(test_config_dir).unwrap() {
146            let config_file_path = config_file_path.unwrap().path();
147            let has_correct_ext = if let Some(ext) = config_file_path.extension() {
148                ext == "json5"
149            } else {
150                false
151            };
152            if !config_file_path.is_file() || !has_correct_ext {
153                continue;
154            }
155            // We add the context so if there is an error you'll see the actual deserialization
156            // error from serde and which file it failed on, which makes for a much more
157            // informative error message in the test logs.
158            Config::try_from_file(Some(&config_file_path))
159                .with_context(|| {
160                    format!(
161                        "Parsing file {}",
162                        &config_file_path.file_name().unwrap().to_string_lossy()
163                    )
164                })
165                .unwrap();
166        }
167    }
168}