dotenv_linter/
lib.rs

1use crate::{common::*, quote_type::QuoteType};
2use clap::Values;
3use std::{
4    collections::{BTreeMap, HashSet},
5    path::{Path, PathBuf},
6    str::FromStr,
7};
8
9pub use checks::available_check_names;
10
11mod checks;
12mod common;
13mod fixes;
14mod fs_utils;
15
16pub mod cli;
17
18pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
19
20pub fn check(args: &clap::ArgMatches, current_dir: &Path) -> Result<usize> {
21    let lines_map = get_lines(
22        args,
23        current_dir,
24        args.is_present("recursive"),
25        args.values_of("exclude"),
26    );
27    let output = CheckOutput::new(args.is_present("quiet"), lines_map.len());
28
29    if lines_map.is_empty() {
30        output.print_nothing_to_check();
31
32        #[cfg(feature = "update-informer")]
33        print_new_version_if_available(args);
34
35        return Ok(0);
36    }
37
38    let skip_checks: Vec<LintKind> = args
39        .values_of("skip")
40        .unwrap_or_default()
41        .filter_map(|check: &str| LintKind::from_str(check).ok())
42        .collect();
43
44    let warnings_count =
45        lines_map
46            .into_iter()
47            .enumerate()
48            .fold(0, |acc, (index, (fe, strings))| {
49                output.print_processing_info(&fe);
50
51                let lines = get_line_entries(strings);
52                let result = checks::run(&lines, &skip_checks);
53
54                output.print_warnings(&fe, &result, index);
55                acc + result.len()
56            });
57
58    output.print_total(warnings_count);
59
60    #[cfg(feature = "update-informer")]
61    print_new_version_if_available(args);
62
63    Ok(warnings_count)
64}
65
66pub fn fix(args: &clap::ArgMatches, current_dir: &Path) -> Result<()> {
67    let mut warnings_count = 0;
68    let lines_map = get_lines(
69        args,
70        current_dir,
71        args.is_present("recursive"),
72        args.values_of("exclude"),
73    );
74    let output = FixOutput::new(args.is_present("quiet"), lines_map.len());
75
76    // Nothing to fix
77    if lines_map.is_empty() {
78        output.print_nothing_to_fix();
79        return Ok(());
80    }
81
82    let skip_checks: Vec<LintKind> = args
83        .values_of("skip")
84        .unwrap_or_default()
85        .filter_map(|check: &str| LintKind::from_str(check).ok())
86        .collect();
87
88    for (index, (fe, strings)) in lines_map.into_iter().enumerate() {
89        output.print_processing_info(&fe);
90
91        let mut lines = get_line_entries(strings);
92        let result = checks::run(&lines, &skip_checks);
93        if result.is_empty() {
94            continue;
95        }
96        let fixes_done = fixes::run(&result, &mut lines, &skip_checks);
97        if fixes_done != result.len() {
98            output.print_not_all_warnings_fixed();
99        }
100        if fixes_done > 0 {
101            let should_backup = !args.is_present("no-backup");
102            // create backup copy unless user specifies not to
103            if should_backup {
104                let backup_file = fs_utils::backup_file(&fe)?;
105                output.print_backup(&backup_file);
106            }
107
108            // write corrected file
109            fs_utils::write_file(&fe.path, lines)?;
110        }
111
112        output.print_warnings(&fe, &result, index);
113        warnings_count += result.len();
114    }
115
116    output.print_total(warnings_count);
117    Ok(())
118}
119
120// Compares if different environment files contains the same variables and returns warnings if not
121pub fn compare(args: &clap::ArgMatches, current_dir: &Path) -> Result<Vec<CompareWarning>> {
122    let mut all_keys: HashSet<String> = HashSet::new();
123    let lines_map = get_lines(args, current_dir, false, None);
124    let output = CompareOutput::new(args.is_present("quiet"));
125
126    let mut warnings: Vec<CompareWarning> = Vec::new();
127    let mut files_to_compare: Vec<CompareFileType> = Vec::new();
128
129    // Nothing to check
130    if lines_map.is_empty() {
131        output.print_nothing_to_compare();
132        return Ok(warnings);
133    }
134
135    // Create CompareFileType structures for each file
136    for (_, (fe, strings)) in lines_map.into_iter().enumerate() {
137        output.print_processing_info(&fe);
138        let lines = get_line_entries(strings);
139        let mut keys: Vec<String> = Vec::new();
140
141        for line in lines {
142            if let Some(key) = line.get_key() {
143                all_keys.insert(key.to_string());
144                keys.push(key.to_string());
145            }
146        }
147
148        let file_to_compare: CompareFileType = CompareFileType {
149            path: fe.path,
150            keys,
151            missing: Vec::new(),
152        };
153
154        files_to_compare.push(file_to_compare);
155    }
156
157    // Create warnings if any file misses any key
158    for file in files_to_compare {
159        let missing_keys: Vec<_> = all_keys
160            .iter()
161            .filter(|key| !file.keys.contains(key))
162            .map(|key| key.to_owned())
163            .collect();
164
165        if !missing_keys.is_empty() {
166            let warning = CompareWarning {
167                path: file.path,
168                missing_keys,
169            };
170
171            warnings.push(warning)
172        }
173    }
174
175    output.print_warnings(&warnings);
176    Ok(warnings)
177}
178
179fn get_lines(
180    args: &clap::ArgMatches,
181    current_dir: &Path,
182    is_recursive: bool,
183    exclude: Option<Values>,
184) -> BTreeMap<FileEntry, Vec<String>> {
185    let file_paths: Vec<PathBuf> = get_needed_file_paths(args, is_recursive, exclude);
186
187    file_paths
188        .iter()
189        .filter_map(|path: &PathBuf| -> Option<(FileEntry, Vec<String>)> {
190            fs_utils::get_relative_path(path, current_dir).and_then(FileEntry::from)
191        })
192        .collect()
193}
194
195/// Getting a list of all files for checking/fixing without custom exclusion files
196fn get_needed_file_paths(
197    args: &clap::ArgMatches,
198    is_recursive: bool,
199    exclude: Option<Values>,
200) -> Vec<PathBuf> {
201    let mut file_paths: Vec<PathBuf> = Vec::new();
202    let mut excluded_paths: Vec<PathBuf> = Vec::new();
203
204    if let Some(excluded) = exclude {
205        excluded_paths = excluded
206            .filter_map(|f| fs_utils::canonicalize(f).ok())
207            .collect();
208    }
209
210    if let Some(inputs) = args.values_of("input") {
211        let input_paths = inputs
212            .filter_map(|s| fs_utils::canonicalize(s).ok())
213            .collect();
214
215        file_paths.extend(get_file_paths(input_paths, &excluded_paths, is_recursive));
216    }
217
218    file_paths
219}
220
221fn get_file_paths(
222    dir_entries: Vec<PathBuf>,
223    excludes: &[PathBuf],
224    is_recursive: bool,
225) -> Vec<PathBuf> {
226    let nested_paths: Vec<PathBuf> = dir_entries
227        .iter()
228        .filter(|entry| entry.is_dir())
229        .filter(|entry| !excludes.contains(entry))
230        .filter_map(|dir| dir.read_dir().ok())
231        .map(|read_dir| {
232            read_dir
233                .filter_map(|e| e.ok())
234                .map(|e| e.path())
235                .filter(|path| {
236                    FileEntry::is_env_file(path)
237                        || (is_recursive && path.is_dir() && path.read_link().is_err())
238                })
239                .collect()
240        })
241        .flat_map(|dir_entries| get_file_paths(dir_entries, excludes, is_recursive))
242        .collect();
243
244    let mut file_paths: Vec<PathBuf> = dir_entries
245        .into_iter()
246        .filter(|entry| entry.is_file())
247        .filter(|entry| !excludes.contains(entry))
248        .collect();
249
250    file_paths.extend(nested_paths);
251    file_paths.sort();
252    file_paths.dedup();
253    file_paths
254}
255
256fn get_line_entries(lines: Vec<String>) -> Vec<LineEntry> {
257    let length = lines.len();
258
259    let mut lines: Vec<LineEntry> = lines
260        .into_iter()
261        .enumerate()
262        .map(|(index, line)| LineEntry::new(index + 1, line, length == (index + 1)))
263        .collect();
264
265    reduce_multiline_entries(&mut lines);
266    lines
267}
268
269fn reduce_multiline_entries(lines: &mut Vec<LineEntry>) {
270    let length = lines.len();
271    let multiline_ranges = find_multiline_ranges(lines);
272
273    // Replace multiline value to one line-entry for checking
274    let mut offset = 1; // index offset to account deleted lines (for access by index)
275    for (start, end) in multiline_ranges {
276        let result = lines
277            .drain(start - offset..end - offset + 1) // TODO: consider `drain_filter` (after stabilization in rust std)
278            .map(|entry| entry.raw_string)
279            .reduce(|result, line| result + "\n" + &line); // TODO: `intersperse` (after stabilization in rust std)
280
281        if let Some(value) = result {
282            lines.insert(start - offset, LineEntry::new(start, value, length == end));
283        }
284
285        offset += end - start;
286    }
287}
288
289fn find_multiline_ranges(lines: &[LineEntry]) -> Vec<(usize, usize)> {
290    let mut multiline_ranges: Vec<(usize, usize)> = Vec::new();
291    let mut start_number: Option<usize> = None;
292    let mut quote_char: Option<char> = None;
293
294    // here we find ranges of multi-line values
295    lines.iter().for_each(|entry| {
296        if let Some(start) = start_number {
297            if let Some(quote_char) = quote_char {
298                if let Some(idx) = entry.raw_string.find(quote_char) {
299                    if !is_escaped(&entry.raw_string[..idx]) {
300                        multiline_ranges.push((start, entry.number));
301                        start_number = None;
302                    }
303                }
304            }
305        } else if let Some(trimmed_value) = entry.get_value().map(|val| val.trim()) {
306            if let Some(quote_type) = is_multiline_start(trimmed_value) {
307                quote_char = Some(quote_type.char());
308                start_number = Some(entry.number);
309            }
310        }
311    });
312
313    multiline_ranges
314}
315
316/// Returns the `QuoteType` for a `&str` starting with a quote-char
317fn is_multiline_start(val: &str) -> Option<QuoteType> {
318    [QuoteType::Single, QuoteType::Double]
319        .into_iter()
320        .find(|quote_type| quote_type.is_quoted_value(val))
321}
322
323/// Prints information about the new version to `STDOUT` if a new version is available
324#[cfg(feature = "update-informer")]
325fn print_new_version_if_available(args: &clap::ArgMatches) {
326    use colored::*;
327    use update_informer::{registry, Check};
328
329    if args.is_present("not-check-updates") || args.is_present("quiet") {
330        return;
331    }
332
333    let pkg_name = env!("CARGO_PKG_NAME");
334
335    #[cfg(not(feature = "stub_check_version"))]
336    let current_version = env!("CARGO_PKG_VERSION");
337    #[cfg(feature = "stub_check_version")]
338    let current_version = "3.0.0";
339
340    #[cfg(not(feature = "stub_check_version"))]
341    let informer = update_informer::new(registry::Crates, pkg_name, current_version);
342    #[cfg(feature = "stub_check_version")]
343    let informer = update_informer::fake(registry::Crates, pkg_name, current_version, "3.1.1");
344
345    if let Ok(Some(version)) = informer.check_version() {
346        let msg = format!(
347            "A new release of {pkg_name} is available: v{current_version} -> {new_version}",
348            pkg_name = pkg_name.italic().cyan(),
349            current_version = current_version,
350            new_version = version.to_string().green()
351        );
352
353        let release_url = format!(
354            "https://github.com/{pkg_name}/{pkg_name}/releases/tag/{version}",
355            pkg_name = pkg_name,
356            version = version
357        )
358        .yellow();
359
360        println!("\n{msg}\n{url}", msg = msg, url = release_url);
361    }
362}