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