topiary_config/
lib.rs

1//! Topiary can be configured using the `Configuration` struct.
2//! A basic configuration, written in Nickel, is included at build time and parsed at runtime.
3//! Additional configuration has to be provided by the user of the library.
4pub mod error;
5pub mod language;
6pub mod source;
7
8use std::{
9    collections::HashMap,
10    fmt,
11    path::{Path, PathBuf},
12};
13
14use language::{Language, LanguageConfiguration};
15use nickel_lang_core::{
16    error::NullReporter, eval::cache::CacheImpl, program::Program, term::RichTerm,
17};
18use serde::Deserialize;
19
20#[cfg(not(target_arch = "wasm32"))]
21use crate::error::TopiaryConfigFetchingError;
22#[cfg(not(target_arch = "wasm32"))]
23use tempfile::tempdir;
24
25use crate::error::{TopiaryConfigError, TopiaryConfigResult};
26
27pub use source::Source;
28
29/// The configuration of the Topiary.
30///
31/// Contains information on how to format every language the user is interested in, modulo what is
32/// supported. It can be provided by the user of the library, or alternatively, Topiary ships with
33/// default configuration that can be accessed using `Configuration::default`.
34#[derive(Debug)]
35pub struct Configuration {
36    languages: Vec<Language>,
37}
38
39/// Internal struct to help with deserialisation, converted to the actual Configuration in deserialization
40#[derive(Debug, serde::Deserialize, PartialEq, serde::Serialize, Clone)]
41struct SerdeConfiguration {
42    languages: HashMap<String, LanguageConfiguration>,
43}
44
45impl Configuration {
46    /// Consume the configuration from the usual sources.
47    /// Which sources exactly can be read in the documentation of `Source`.
48    ///
49    /// # Errors
50    ///
51    /// If the configuration file does not exist, this function will return a `TopiaryConfigError`
52    /// with the path that was not found.
53    /// If the configuration file exists, but cannot be parsed, this function will return a
54    /// `TopiaryConfigError` with the error that occurred.
55    #[allow(clippy::result_large_err)]
56    pub fn fetch(merge: bool, file: &Option<PathBuf>) -> TopiaryConfigResult<(Self, RichTerm)> {
57        // If we have an explicit file, fail if it doesn't exist
58        if let Some(path) = file
59            && !path.exists()
60        {
61            return Err(TopiaryConfigError::FileNotFound(path.to_path_buf()));
62        }
63
64        if merge {
65            // Get all available configuration sources
66            let sources: Vec<Source> = Source::fetch_all(file);
67
68            // And ask Nickel to parse and merge them
69            Self::parse_and_merge(&sources)
70        } else {
71            // Get the available configuration with best priority
72            match Source::fetch_one(file) {
73                Source::Builtin => Self::parse(Source::Builtin),
74                source => Self::parse_and_merge(&[source, Source::Builtin]),
75            }
76        }
77    }
78
79    /// Gets a language configuration from the entire configuration.
80    ///
81    /// # Errors
82    ///
83    /// If the provided language name cannot be found in the `Configuration`, this
84    /// function returns a `TopiaryConfigError`
85    #[allow(clippy::result_large_err)]
86    pub fn get_language<T>(&self, name: T) -> TopiaryConfigResult<&Language>
87    where
88        T: AsRef<str> + fmt::Display,
89    {
90        self.languages
91            .iter()
92            .find(|language| language.name == name.as_ref())
93            .ok_or(TopiaryConfigError::UnknownLanguage(name.to_string()))
94    }
95
96    /// Prefetch a language per its configuration
97    ///
98    /// # Errors
99    ///
100    /// If any grammar could not build, a `TopiaryConfigFetchingError` is returned.
101    #[cfg(not(target_arch = "wasm32"))]
102    fn fetch_language(
103        language: &Language,
104        force: bool,
105        tmp_dir: &Path,
106    ) -> Result<(), TopiaryConfigFetchingError> {
107        match &language.config.grammar.source {
108            language::GrammarSource::Git(git_source) => {
109                let library_path = language.library_path()?;
110
111                log::info!(
112                    "Fetch \"{}\": Configured via Git ({} ({})); to {}",
113                    language.name,
114                    git_source.git,
115                    git_source.rev,
116                    library_path.display()
117                );
118
119                git_source.fetch_and_compile_with_dir(
120                    &language.name,
121                    library_path,
122                    force,
123                    tmp_dir.to_path_buf(),
124                )
125            }
126
127            language::GrammarSource::Path(path) => {
128                log::info!(
129                    "Fetch \"{}\": Configured via filesystem ({}); nothing to do",
130                    language.name,
131                    path.display(),
132                );
133
134                if !path.exists() {
135                    Err(TopiaryConfigFetchingError::GrammarFileNotFound(
136                        path.to_path_buf(),
137                    ))
138                } else {
139                    Ok(())
140                }
141            }
142        }
143    }
144
145    /// Prefetches and builds the desired language.
146    /// This can be beneficial to speed up future startup time.
147    ///
148    /// # Errors
149    ///
150    /// If the language could not be found or the Grammar could not be build, a `TopiaryConfigError` is returned.
151    #[cfg(not(target_arch = "wasm32"))]
152    #[allow(clippy::result_large_err)]
153    pub fn prefetch_language<T>(&self, language: T, force: bool) -> TopiaryConfigResult<()>
154    where
155        T: AsRef<str> + fmt::Display,
156    {
157        let tmp_dir = tempdir()?;
158        let tmp_dir_path = tmp_dir.path().to_owned();
159        let l = self.get_language(language)?;
160        Configuration::fetch_language(l, force, &tmp_dir_path)?;
161        Ok(())
162    }
163
164    /// Prefetches and builds all known languages.
165    /// This can be beneficial to speed up future startup time.
166    ///
167    /// # Errors
168    ///
169    /// If any Grammar could not be build, a `TopiaryConfigError` is returned.
170    #[cfg(not(target_arch = "wasm32"))]
171    #[allow(clippy::result_large_err)]
172    pub fn prefetch_languages(&self, force: bool) -> TopiaryConfigResult<()> {
173        let tmp_dir = tempdir()?;
174        let tmp_dir_path = tmp_dir.path().to_owned();
175
176        // When the `parallel` feature is enabled (which it is by default), we use Rayon to fetch
177        // and compile all found grammars concurrently.
178        // NOTE The MSVC linker does not seem to like concurrent builds, so concurrency is disabled
179        // on Windows (see https://github.com/tweag/topiary/issues/868)
180        #[cfg(all(feature = "parallel", not(windows)))]
181        {
182            use rayon::prelude::*;
183            self.languages
184                .par_iter()
185                .map(|l| Configuration::fetch_language(l, force, &tmp_dir_path))
186                .collect::<Result<Vec<_>, TopiaryConfigFetchingError>>()?;
187        }
188
189        #[cfg(any(not(feature = "parallel"), windows))]
190        {
191            self.languages
192                .iter()
193                .map(|l| Configuration::fetch_language(l, force, &tmp_dir_path))
194                .collect::<Result<Vec<_>, TopiaryConfigFetchingError>>()?;
195        }
196
197        tmp_dir.close()?;
198        Ok(())
199    }
200
201    /// Convenience alias to detect the Language from a Path-like value's extension.
202    ///
203    /// # Errors
204    ///
205    /// If the file extension is not supported, a `FormatterError` will be returned.
206    #[allow(clippy::result_large_err)]
207    pub fn detect<P: AsRef<Path>>(&self, path: P) -> TopiaryConfigResult<&Language> {
208        let pb = &path.as_ref().to_path_buf();
209        if let Some(extension) = pb.extension().and_then(|ext| ext.to_str()) {
210            for lang in &self.languages {
211                if lang.config.extensions.contains(extension) {
212                    return Ok(lang);
213                }
214            }
215            return Err(TopiaryConfigError::UnknownExtension(extension.to_string()));
216        }
217        Err(TopiaryConfigError::NoExtension(pb.clone()))
218    }
219
220    #[allow(clippy::result_large_err)]
221    fn parse_and_merge(sources: &[Source]) -> TopiaryConfigResult<(Self, RichTerm)> {
222        let inputs = sources.iter().map(|s| s.clone().into());
223
224        let mut program =
225            Program::<CacheImpl>::new_from_inputs(inputs, std::io::stderr(), NullReporter {})?;
226
227        let term = program.eval_full_for_export()?;
228
229        let serde_config = SerdeConfiguration::deserialize(term.clone())?;
230
231        Ok((serde_config.into(), term))
232    }
233
234    #[allow(clippy::result_large_err)]
235    fn parse(source: Source) -> TopiaryConfigResult<(Self, RichTerm)> {
236        let mut program = Program::<CacheImpl>::new_from_input(
237            source.into(),
238            std::io::stderr(),
239            NullReporter {},
240        )?;
241
242        let term = program.eval_full_for_export()?;
243
244        let serde_config = SerdeConfiguration::deserialize(term.clone())?;
245
246        Ok((serde_config.into(), term))
247    }
248}
249
250impl Default for Configuration {
251    /// Return the built-in configuration
252    // This is particularly useful for testing
253    fn default() -> Self {
254        let mut program = Program::<CacheImpl>::new_from_source(
255            Source::Builtin
256                .read()
257                .expect("Evaluating the builtin configuration should be safe")
258                .as_slice(),
259            "built-in",
260            std::io::empty(),
261            NullReporter {},
262        )
263        .expect("Evaluating the builtin configuration should be safe");
264        let term = program
265            .eval_full_for_export()
266            .expect("Evaluating the builtin configuration should be safe");
267        let serde_config = SerdeConfiguration::deserialize(term)
268            .expect("Evaluating the builtin configuration should be safe");
269
270        serde_config.into()
271    }
272}
273
274/// Convert `Serialisation` values into `HashMap`s, keyed on `Language::name`
275impl From<&Configuration> for HashMap<String, Language> {
276    fn from(config: &Configuration) -> Self {
277        HashMap::from_iter(
278            config
279                .languages
280                .iter()
281                .map(|language| (language.name.clone(), language.clone())),
282        )
283    }
284}
285
286// Order-invariant equality; required for unit testing
287impl PartialEq for Configuration {
288    fn eq(&self, other: &Self) -> bool {
289        let lhs: HashMap<String, Language> = self.into();
290        let rhs: HashMap<String, Language> = other.into();
291
292        lhs == rhs
293    }
294}
295
296impl From<SerdeConfiguration> for Configuration {
297    fn from(value: SerdeConfiguration) -> Self {
298        let languages = value
299            .languages
300            .into_iter()
301            .map(|(name, config)| Language::new(name, config))
302            .collect();
303
304        Self { languages }
305    }
306}
307
308pub(crate) fn project_dirs() -> directories::ProjectDirs {
309    directories::ProjectDirs::from("", "", "topiary")
310        .expect("Could not access the OS's Home directory")
311}