toml_maid/
lib.rs

1use {
2    colored::*,
3    serde::{Deserialize, Serialize},
4    std::{
5        cmp::Ordering,
6        collections::BTreeMap,
7        error::Error,
8        ffi::OsString,
9        fs::File,
10        io::Write,
11        path::{Path, PathBuf},
12    },
13    structopt::StructOpt,
14    toml_edit::{Array, Decor, Document, InlineTable, Item, Table, Value},
15};
16
17/// Type alias for shorter return types.
18pub type Res<T = ()> = Result<T, Box<dyn Error>>;
19
20pub fn run(mut opt: Opt, config: Config) -> Res {
21    let config: ProcessedConfig = config.into();
22
23    if opt.files.is_empty() && opt.folder.is_empty() {
24        opt.folder.push(std::env::current_dir()?);
25    }
26
27    for folder in opt.folder {
28        let files = find_files_recursively(folder, "toml", !opt.silent, &config.excludes);
29        opt.files.extend(files);
30    }
31
32    for file in opt.files {
33        config.process_file(file, opt.check, !opt.silent)?;
34    }
35
36    Ok(())
37}
38
39/// A TOML entry. Generic to support both `Item` and `Value` entries.
40struct Entry<T> {
41    key: String,
42    value: T,
43    decor: Decor,
44}
45
46#[derive(StructOpt, Debug, Clone)]
47pub struct Opt {
48    /// List of .toml files to format.
49    /// If no files are provided, and `--scan-folder` is not used then
50    /// it will scan for all .toml files in the current directory.
51    #[structopt(name = "FILE", parse(from_os_str))]
52    pub files: Vec<PathBuf>,
53
54    /// Scan provided folder(s) recursively for .toml files.
55    #[structopt(long, parse(from_os_str))]
56    pub folder: Vec<PathBuf>,
57
58    /// Only check the formatting, returns an error if the file is not formatted.
59    /// If not provide the files will be overritten.
60    #[structopt(short, long)]
61    pub check: bool,
62
63    /// Disables verbose messages.
64    #[structopt(short, long)]
65    pub silent: bool,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, Default)]
69pub struct GenericConfig<Keys> {
70    /// Important keys in non-inline tables.
71    /// Will be sorted first, then any non-important keys will be
72    /// sorted lexicographically.
73    #[serde(default)]
74    pub keys: Keys,
75
76    /// Important keys in inline tables.
77    /// Will be sorted first, then any non-important keys will be
78    /// sorted lexicographically.
79    #[serde(default)]
80    pub inline_keys: Keys,
81
82    /// Does it sort arrays?
83    /// In case of mixed types, string will be ordered first, then
84    /// other values in original order.
85    #[serde(default)]
86    pub sort_arrays: bool,
87
88    #[serde(default)]
89    /// Paths to ignore when scanning directories.
90    pub excludes: Vec<String>,
91}
92
93pub type Config = GenericConfig<Vec<String>>;
94pub type ProcessedConfig = GenericConfig<BTreeMap<String, usize>>;
95
96const CONFIG_FILE: &str = "toml-maid.toml";
97
98impl Config {
99    pub fn read_from_file() -> Option<Config> {
100        let mut path: PathBuf = std::env::current_dir().ok()?;
101        let filename = Path::new(CONFIG_FILE);
102
103        loop {
104            path.push(filename);
105
106            if path.is_file() {
107                let text = std::fs::read_to_string(&path).ok()?;
108                let config: Self = toml::from_str(&text).ok()?;
109                return Some(config);
110            }
111
112            if !(path.pop() && path.pop()) {
113                // remove file && remove parent
114                return None;
115            }
116        }
117    }
118}
119
120impl From<Config> for ProcessedConfig {
121    fn from(x: Config) -> Self {
122        let mut res = Self {
123            keys: BTreeMap::new(),
124            inline_keys: BTreeMap::new(),
125            sort_arrays: x.sort_arrays,
126            excludes: x.excludes,
127        };
128
129        for (i, key) in x.keys.iter().enumerate() {
130            res.keys.insert(key.clone(), i);
131        }
132
133        for (i, key) in x.inline_keys.iter().enumerate() {
134            res.inline_keys.insert(key.clone(), i);
135        }
136
137        res
138    }
139}
140
141fn absolute_path(path: impl AsRef<Path>) -> Res<String> {
142    Ok(std::fs::canonicalize(&path)?.to_string_lossy().to_string())
143}
144
145pub fn find_files_recursively(
146    dir_path: impl AsRef<Path>,
147    extension: &str,
148    verbose: bool,
149    excludes: &[String],
150) -> Vec<PathBuf> {
151    macro_rules! continue_on_err {
152        ($in:expr, $context:expr) => {
153            match $in {
154                Ok(e) => e,
155                Err(e) => {
156                    if verbose {
157                        eprintln!("Error while {}: {}", $context, e);
158                    }
159                    continue;
160                }
161            }
162        };
163    }
164
165    let dir_path: PathBuf = dir_path.as_ref().to_owned();
166    let mut matches = vec![];
167    let extension: OsString = extension.into();
168    let config_file: OsString = CONFIG_FILE.into();
169
170    let excludes: Vec<_> = excludes
171        .iter()
172        .map(|v| glob::Pattern::new(&v).expect("invalid pattern in 'excludes'"))
173        .collect();
174
175    for entry in ignore::WalkBuilder::new(&dir_path)
176        .skip_stdout(true)
177        .filter_entry(move |entry| {
178            let path = entry.path();
179            let relative_path = path
180                .strip_prefix(&dir_path)
181                .expect("scanned file should be inside scanned dir");
182
183            for exclude in &excludes {
184                if exclude.matches_path(&relative_path) {
185                    return false;
186                }
187            }
188
189            true
190        })
191        .build()
192    {
193        let entry = continue_on_err!(entry, "getting file info");
194        let file_type =
195            continue_on_err!(entry.file_type().ok_or("no file type"), "getting file type");
196        let path = entry.path().to_owned();
197
198        // We ignore directories, as `ignore::Walk` performs the recursive search.
199        if file_type.is_dir() {
200            continue;
201        }
202
203        // We ignore non .toml files
204        if path.extension() != Some(&extension) {
205            continue;
206        }
207
208        // We don't format `toml-maid.toml` files as the order is important.
209        // TODO: Still format but override `sort_arrays`.
210        if path.file_name() == Some(&config_file) {
211            continue;
212        }
213
214        matches.push(path);
215    }
216
217    matches
218}
219
220impl ProcessedConfig {
221    /// Process the provided file.
222    pub fn process_file(&self, path: impl AsRef<Path>, check: bool, verbose: bool) -> Res<()> {
223        let absolute_path = absolute_path(&path)?;
224        let text = std::fs::read_to_string(&path).unwrap_or_else(|e| {
225            eprintln!(
226                "Error while reading file \"{}\" : {}",
227                absolute_path,
228                e.to_string().red()
229            );
230            std::process::exit(3);
231        });
232
233        let doc = text.parse::<Document>()?;
234        let trailing = doc.trailing().trim_end();
235
236        let output_table = self.format_table(&doc)?;
237        let mut output_doc: Document = output_table.into();
238        output_doc.set_trailing(trailing); // Insert back trailing content (comments).
239        let output_text = format!("{}\n", output_doc.to_string().trim());
240
241        if check {
242            if text != output_text {
243                eprintln!("Check fails : {}", absolute_path.red());
244                std::process::exit(2);
245            } else if verbose {
246                println!("Check succeed: {}", absolute_path.green());
247            }
248        } else if text != output_text {
249            let mut file = File::create(&path)?;
250            file.write_all(output_text.as_bytes())?;
251            file.flush()?;
252            if verbose {
253                println!("Overwritten: {}", absolute_path.blue());
254            }
255        } else if verbose {
256            println!("Unchanged: {}", absolute_path.green());
257        }
258
259        Ok(())
260    }
261
262    /// Format a `Table`.
263    /// Consider empty lines as "sections" and will not sort accross sections.
264    /// Comments at the start of the section will stay at the start, while
265    /// comments attached to any other line will stay attached to that line.
266    fn format_table(&self, table: &Table) -> Res<Table> {
267        let mut formated_table = Table::new();
268        formated_table.set_implicit(true); // avoid empty `[dotted.keys]`
269        let prefix = table.decor().prefix().unwrap_or("");
270        let suffix = table.decor().suffix().unwrap_or("");
271        formated_table.decor_mut().set_prefix(prefix);
272        formated_table.decor_mut().set_suffix(suffix);
273
274        let mut section_decor = Decor::default();
275        let mut section = Vec::<Entry<Item>>::new();
276
277        let sort = |x: &Entry<Item>, y: &Entry<Item>| {
278            let xord = self.keys.get(&x.key);
279            let yord = self.keys.get(&y.key);
280
281            match (xord, yord) {
282                (Some(_), None) => Ordering::Less,
283                (None, Some(_)) => Ordering::Greater,
284                (Some(x), Some(y)) => x.cmp(y),
285                (None, None) => x.key.cmp(&y.key),
286            }
287        };
288
289        // Iterate over all original entries.
290        for (i, (key, item)) in table.iter().enumerate() {
291            let mut key_decor = table.key_decor(key).unwrap().clone();
292
293            // First entry can be decored (prefix).
294            // In that case we want to keep that decoration at the start of the section.
295            if i == 0 {
296                if let Some(prefix) = key_decor.prefix() {
297                    if !prefix.is_empty() {
298                        section_decor.set_prefix(prefix);
299                        key_decor.set_prefix("".to_string());
300                    }
301                }
302            }
303            // Later entries can contain a new-line prefix decor.
304            // It means it is a new section, and sorting must not cross
305            // section boundaries.
306            else if let Some(prefix) = key_decor.prefix() {
307                if prefix.starts_with('\n') {
308                    // Sort keys and insert them.
309                    section.sort_by(sort);
310
311                    for (i, mut entry) in section.into_iter().enumerate() {
312                        // Add section prefix.
313                        if i == 0 {
314                            if let Some(prefix) = section_decor.prefix() {
315                                entry.decor.set_prefix(prefix);
316                            }
317                        }
318
319                        formated_table.insert(&entry.key, entry.value);
320                        *formated_table.key_decor_mut(&entry.key).unwrap() = entry.decor;
321                    }
322
323                    // Cleanup for next sections.
324                    section = Vec::new();
325                    section_decor = Decor::default();
326                    section_decor.set_prefix(prefix);
327                    key_decor.set_prefix("".to_string());
328                }
329            }
330
331            // Remove any trailing newline in decor suffix.
332            if let Some(suffix) = key_decor.suffix().map(|x| x.to_owned()) {
333                key_decor.set_suffix(suffix.trim_end_matches('\n'));
334            }
335
336            // Format inner item.
337            let new_item = match item {
338                Item::None => Item::None,
339                Item::Value(inner) => Item::Value(self.format_value(inner, false)?),
340                Item::Table(inner) => Item::Table(self.format_table(inner)?),
341                // TODO : Doesn't seem we have any of those.
342                Item::ArrayOfTables(inner) => Item::ArrayOfTables(inner.clone()),
343            };
344
345            section.push(Entry {
346                key: key.to_string(),
347                value: new_item,
348                decor: key_decor,
349            });
350        }
351
352        // End of entries, we insert remaining section.
353        section.sort_by(sort);
354
355        for (i, mut entry) in section.into_iter().enumerate() {
356            // Add section prefix.
357            if i == 0 {
358                if let Some(prefix) = section_decor.prefix() {
359                    entry.decor.set_prefix(prefix);
360                }
361            }
362
363            formated_table.insert(&entry.key, entry.value);
364            *formated_table.key_decor_mut(&entry.key).unwrap() = entry.decor;
365        }
366
367        Ok(formated_table)
368    }
369
370    /// Format inline tables `{ key = value, key = value }`.
371    /// TOML doesn't seem to support inline comments, so we just override entries decors
372    /// to respect proper spaces.
373    pub fn format_inline_table(&self, table: &InlineTable, last: bool) -> Res<InlineTable> {
374        let mut formated_table = InlineTable::new();
375        if last {
376            formated_table.decor_mut().set_suffix(" ");
377        }
378
379        let mut entries = Vec::<Entry<Value>>::new();
380
381        let sort = |x: &Entry<Value>, y: &Entry<Value>| {
382            let xord = self.inline_keys.get(&x.key);
383            let yord = self.inline_keys.get(&y.key);
384
385            match (xord, yord) {
386                (Some(_), None) => Ordering::Less,
387                (None, Some(_)) => Ordering::Greater,
388                (Some(x), Some(y)) => x.cmp(y),
389                (None, None) => x.key.cmp(&y.key),
390            }
391        };
392
393        for (key, value) in table.iter() {
394            let mut key_decor = table.key_decor(key).unwrap().clone();
395
396            // Trim decor.
397            key_decor.set_prefix(" ");
398            key_decor.set_suffix(" ");
399
400            let new_value = value.clone();
401
402            entries.push(Entry {
403                key: key.to_string(),
404                value: new_value,
405                decor: key_decor,
406            });
407        }
408
409        entries.sort_by(sort);
410
411        let len = entries.len();
412        for (i, entry) in entries.into_iter().enumerate() {
413            let new_value = self.format_value(&entry.value, i + 1 == len)?;
414
415            formated_table.insert(&entry.key, new_value);
416            *formated_table.key_decor_mut(&entry.key).unwrap() = entry.decor;
417        }
418
419        Ok(formated_table)
420    }
421
422    /// Format a `Value`.
423    pub fn format_value(&self, value: &Value, last: bool) -> Res<Value> {
424        Ok(match value {
425            Value::Array(inner) => Value::Array(self.format_array(inner, last)?),
426            Value::InlineTable(inner) => Value::InlineTable(self.format_inline_table(inner, last)?),
427            v => {
428                let mut v = v.clone();
429
430                // Keep existing prefix/suffix with correct format.
431                let prefix = v.decor().prefix().map(|x| x.trim()).unwrap_or("");
432
433                let prefix = if prefix.is_empty() {
434                    prefix.to_string()
435                } else {
436                    format!(" {}", prefix)
437                };
438
439                let suffix = v.decor().suffix().map(|x| x.trim()).unwrap_or("");
440
441                let suffix = if suffix.is_empty() {
442                    suffix.to_string()
443                } else {
444                    format!(" {}", suffix)
445                };
446
447                // Convert simple '...' to "..."
448                // Doesn't modify strings starting with multiple ' as they
449                // could be multiline literals.
450                // Doesn't modify strings containing \ or "
451                let mut display = v.clone().decorated("", "").to_string();
452                if display.starts_with('\'')
453                    && !display.starts_with("''")
454                    && display.find(&['\\', '"'][..]).is_none()
455                {
456                    if let Some(s) = display.strip_prefix('\'') {
457                        display = s.to_string();
458                    }
459
460                    if let Some(s) = display.strip_suffix('\'') {
461                        display = s.to_string();
462                    }
463
464                    v = display.into();
465                }
466
467                // Handle surrounding spaces.
468                if last {
469                    v.decorated(&format!("{} ", prefix), &format!("{} ", suffix))
470                } else {
471                    v.decorated(&format!("{} ", prefix), &suffix.to_string())
472                }
473            }
474        })
475    }
476
477    /// Format an `Array`.
478    /// Detect if the array is inline or multi-line, and format it accordingly.
479    /// Support comments in multi-line arrays.
480    /// With config `sort_string_arrays` the array String entries will be sorted, otherwise will be kept
481    /// as is.
482    fn format_array(&self, array: &Array, last: bool) -> Res<Array> {
483        let mut values: Vec<_> = array.iter().cloned().collect();
484
485        if self.sort_arrays {
486            values.sort_by(|x, y| match (x, y) {
487                (Value::String(x), Value::String(y)) => x.value().cmp(y.value()),
488                (Value::String(_), _) => Ordering::Less,
489                (_, Value::String(_)) => Ordering::Greater,
490                (_, _) => Ordering::Equal,
491            });
492        }
493
494        let mut new_array = Array::new();
495
496        for value in values.into_iter() {
497            new_array.push_formatted(value);
498        }
499
500        let mut multiline = array.trailing().starts_with('\n');
501        if !multiline {
502            for item in array.iter() {
503                if let Some(prefix) = item.decor().prefix() {
504                    if prefix.contains('\n') {
505                        multiline = true;
506                        break;
507                    }
508                }
509
510                if let Some(suffix) = item.decor().suffix() {
511                    if suffix.contains('\n') {
512                        multiline = true;
513                        break;
514                    }
515                }
516            }
517        }
518
519        // Multiline array
520        if multiline {
521            let mut trailing = format!(
522                "{}\n",
523                array.trailing().trim_matches(&[' ', '\t'][..]).trim_end()
524            );
525
526            if !trailing.starts_with('\n') {
527                trailing = format!(" {trailing}");
528            }
529
530            new_array.set_trailing(&trailing);
531            new_array.set_trailing_comma(true);
532
533            for value in new_array.iter_mut() {
534                let prefix = value
535                    .decor()
536                    .prefix()
537                    .unwrap_or("")
538                    .trim_matches(&[' ', '\t'][..])
539                    .trim_end_matches('\n');
540
541                let mut prefix = if !prefix.is_empty() {
542                    format!("{}\n\t", prefix)
543                } else {
544                    "\n\t".to_string()
545                };
546
547                if !prefix.starts_with('\n') {
548                    prefix = format!(" {prefix}");
549                }
550
551                let mut suffix = value
552                    .decor()
553                    .suffix()
554                    .unwrap_or("")
555                    .trim_matches(&[' ', '\t', '\n'][..])
556                    .to_string();
557
558                // If the suffix is non-empty it is important to add a trailing
559                // new line so that the comma is not part of a comment
560                if !suffix.is_empty() {
561                    suffix.push('\n');
562                }
563
564                let formatted_value = self.format_value(value, false)?;
565                *value = formatted_value.decorated(&prefix, &suffix);
566            }
567        }
568        // Inline array
569        else {
570            new_array.set_trailing("");
571            new_array.set_trailing_comma(false);
572
573            let len = new_array.len();
574            for (i, value) in new_array.iter_mut().enumerate() {
575                *value = self.format_value(value, i + 1 == len)?;
576            }
577        }
578
579        new_array.decor_mut().set_prefix(" ");
580        new_array
581            .decor_mut()
582            .set_suffix(if last { " " } else { "" });
583
584        Ok(new_array)
585    }
586}