acorn_lib/analyzer/
mod.rs

1//! # Prose analyzer module
2//!
3//! This module provides interfaces for the Vale prose analyzer and functions for analyzing readability.
4use crate::constants::{APPLICATION, VALE_RELEASES_URL, VALE_VERSION};
5use crate::util::{
6    checksum, command_exists, download_binary, extension, make_executable, path_to_string, pretty_print, standard_project_folder, to_string,
7    Constant, Label, ProgrammingLanguage, SemanticVersion,
8};
9use color_eyre::owo_colors::OwoColorize;
10use duct::cmd;
11use flate2::read::GzDecoder;
12use ini::Ini;
13use std::collections::HashMap;
14use std::fs::File;
15use std::fs::{create_dir_all, remove_file};
16use std::io::prelude::*;
17use std::path::PathBuf;
18use tar::Archive;
19use titlecase::Titlecase;
20use tracing::{debug, error, info, trace};
21use which::which;
22
23pub mod readability;
24pub mod vale;
25
26use vale::{parse_vale_output, print_vale_output, Vale, ValeConfig};
27
28/// Trait for static analyzers (e.g. Vale)
29pub trait StaticAnalyzer {
30    /// Get command name (e.g. "vale")
31    fn command(self) -> String;
32    /// Analyze content
33    fn analyze(&self, id: String, content: String, output: Option<String>) -> usize;
34    /// Download binary
35    fn download(self, config: Option<ValeConfig>) -> Self;
36    /// Download checksum values
37    fn download_checksums(self) -> Result<HashMap<String, String>, String>;
38    /// Extract binary
39    fn extract(self, path: PathBuf, destination: Option<PathBuf>) -> PathBuf;
40    /// Perform sync operation (only applies to Vale)
41    fn sync(self) -> Result<(), std::io::Error>;
42    /// Set binary
43    fn with_binary(self, path: PathBuf) -> Self;
44    /// Set config
45    fn with_config(self, value: ValeConfig) -> Self;
46    /// Set system command
47    fn with_system_command(self) -> Self;
48    /// Set version
49    fn with_version(self, value: String) -> Self;
50}
51/// Trait for static analyzer configuration (e.g. .vale.ini)
52pub trait StaticAnalyzerConfig {
53    /// Get default configuration
54    fn default() -> ValeConfig;
55    /// Convert to INI
56    fn ini(self) -> Ini;
57    /// Save configuration
58    fn save(self) -> ValeConfig;
59}
60impl StaticAnalyzer for Vale {
61    fn command(self) -> String {
62        "vale".to_string()
63    }
64    fn analyze(&self, id: String, content: String, output: Option<String>) -> usize {
65        let root = standard_project_folder("check", None);
66        match create_dir_all(root.clone()) {
67            | Ok(_) => {}
68            | Err(why) => error!(path = path_to_string(root.clone()), "=> {} Create - {}", Label::fail(), why),
69        }
70        let path = root.join(&id);
71        let mut file = match File::create(&path) {
72            | Ok(file) => file,
73            | Err(why) => panic!("=> {} Create file {} - {}", Label::fail(), path.display(), why),
74        };
75        file.write_all(content.as_bytes())
76            .expect("Unable to write to cache directory project file");
77        let binary = match &self.binary {
78            | Some(value) => value,
79            | None => {
80                error!("=> {} {} binary", Label::not_found(), self.clone().command());
81                std::process::exit(exitcode::UNAVAILABLE);
82            }
83        };
84        match &self.config {
85            | Some(config) => {
86                let result = match output {
87                    | Some(value) => cmd!(
88                        binary,
89                        "--no-wrap",
90                        "--config",
91                        config.clone().path,
92                        "--output",
93                        value,
94                        path.clone(),
95                        "--ext",
96                        ".md",
97                        "--no-exit",
98                    )
99                    .read(),
100                    | None => cmd!(
101                        binary,
102                        "--no-wrap",
103                        "--config",
104                        config.clone().path,
105                        path.clone(),
106                        "--ext",
107                        ".md",
108                        "--no-exit"
109                    )
110                    .read(),
111                };
112                match result {
113                    | Ok(output) => {
114                        let parsed = parse_vale_output(path, &output);
115                        if parsed.is_empty() {
116                            let message = format!("=> {} {} has {}", Label::pass(), id.to_string().underline(), "no prose issues".green(),);
117                            info!("{}", message);
118                            0
119                        } else {
120                            error!("=> {} {} issues found in {}", Label::fail(), parsed.len(), id.to_string().underline());
121                            print_vale_output(parsed.clone());
122                            let highlight = parsed.clone().into_iter().map(|item| item.line as usize).collect::<Vec<_>>();
123                            println!();
124                            pretty_print(&content, ProgrammingLanguage::Markdown, highlight);
125                            println!("\n");
126                            parsed.len()
127                        }
128                    }
129                    | Err(output) => {
130                        error!("=> {} Analyze - {}", Label::fail(), output);
131                        1
132                    }
133                }
134            }
135            | None => {
136                let title = self.clone().command().titlecase();
137                error!("=> {} {} configuration", Label::not_found(), title);
138                std::process::exit(exitcode::UNAVAILABLE);
139            }
140        }
141    }
142    // TODO: Check if binary has already been downloaded
143    fn download(self, config: Option<ValeConfig>) -> Vale {
144        // https://doc.rust-lang.org/std/env/consts/constant.OS.html
145        let os = std::env::consts::OS.to_lowercase();
146        let platform = match os.as_str() {
147            | "linux" => "Linux_64-bit.tar.gz",
148            | "macos" | "apple" => "macOS_64-bit.tar.gz",
149            | "windows" => "Windows_64-bit.zip",
150            | _ => {
151                error!(os, "=> {}", Label::not_found());
152                std::process::exit(exitcode::UNAVAILABLE);
153            }
154        };
155        let release = match self.version {
156            | Some(value) => value,
157            | None => SemanticVersion::from_string(VALE_VERSION),
158        };
159        let url = format!(
160            "{}/download/v{}/{}_{}_{}",
161            VALE_RELEASES_URL,
162            release,
163            self.clone().command(),
164            release,
165            platform
166        );
167        info!(url, "=> {} Vale release v{}", Label::using(), release);
168        let binary = match download_binary(&url, ".") {
169            | Ok(path) => {
170                let dowloaded_checksums = match self.clone().download_checksums() {
171                    | Ok(value) => value.get(platform).unwrap().to_string(),
172                    | Err(_) => "".to_string(),
173                };
174                let calculated = checksum(path.clone());
175                if !dowloaded_checksums.eq(&calculated) {
176                    error!(dowloaded_checksums, calculated, "=> {}", Label::invalid());
177                    let _cleanup = remove_file(path);
178                    std::process::exit(exitcode::USAGE);
179                } else {
180                    info!(dowloaded_checksums, "=> {}", Label::pass());
181                }
182                // TODO: Provide option to save to cache project directory
183                let destination = match config.clone() {
184                    | Some(value) => value.path.parent().unwrap().to_path_buf(),
185                    | None => PathBuf::from("./.vale/"),
186                };
187                let binary = self.clone().extract(path.clone(), Some(destination));
188                if make_executable(&binary) {
189                    let _cleanup = remove_file(path);
190                    Some(binary)
191                } else {
192                    error!("=> {} {} not executable", Label::fail(), self.command());
193                    None
194                }
195            }
196            | Err(error) => {
197                error!(error, url, "=> {} {} download", Label::fail(), self.command());
198                None
199            }
200        };
201        let builder = Vale::init().version(release).maybe_binary(binary);
202        match config {
203            | Some(value) => builder.config(value).build(),
204            | None => {
205                let config = ValeConfig::default();
206                builder.config(config).build()
207            }
208        }
209    }
210    fn download_checksums(self) -> Result<HashMap<String, String>, String> {
211        let release = match self.version {
212            | Some(value) => value,
213            | None => SemanticVersion::from_string(VALE_VERSION),
214        };
215        let url = format!(
216            "{}/download/v{}/{}_{}_checksums.txt",
217            VALE_RELEASES_URL,
218            release,
219            self.clone().command(),
220            release
221        );
222        let client = reqwest::blocking::Client::new();
223        let response = client.get(url).send().unwrap();
224        let content = response.text().unwrap();
225        let checksums = content.lines().clone().fold(HashMap::new(), |mut acc: HashMap<String, String>, line| {
226            let mut values = line.split("  ").collect::<Vec<&str>>();
227            let key = values.pop().unwrap()["vale_#.#.#_".len()..].to_string();
228            let value = values.pop().unwrap().to_string();
229            acc.insert(key, value);
230            acc
231        });
232        debug!(
233            "=> {} {} checksums {:#?}",
234            Label::using(),
235            self.command().titlecase(),
236            checksums.dimmed().cyan()
237        );
238        Ok(checksums)
239    }
240    fn extract(self, path: PathBuf, destination: Option<PathBuf>) -> PathBuf {
241        match extension(&path).as_str() {
242            | "zip" => unimplemented!(),
243            | _ => {
244                let tar_gz = File::open(path).unwrap();
245                let tar = GzDecoder::new(tar_gz);
246                let mut archive = Archive::new(tar);
247                let parent = match destination {
248                    | Some(value) => path_to_string(value),
249                    | None => "./.vale/".to_string(),
250                };
251                let message = format!("Unable to extract {} binary", self.clone().command());
252                archive.unpack(parent.clone()).expect(&message);
253                debug!(parent, "=> {} Extracted {} binary", Label::using(), self.command());
254                PathBuf::from(format!("{parent}/vale"))
255            }
256        }
257    }
258    fn sync(self) -> Result<(), std::io::Error> {
259        let path = match self.binary {
260            | Some(value) => value,
261            | None => {
262                error!("=> {} {} binary", Label::not_found(), self.command());
263                std::process::exit(exitcode::UNAVAILABLE);
264            }
265        };
266        let config_path = self.config.unwrap().path;
267        let result = cmd!(path.clone(), "--config", config_path.clone(), "sync").run();
268        match result {
269            | Ok(_) => {
270                let parent = format!("{}/styles/config/vocabularies/{}", config_path.parent().unwrap().display(), APPLICATION);
271                debug!(parent, "=> {} Vocabularies", Label::using());
272                match create_dir_all(parent.clone()) {
273                    | Ok(_) => {}
274                    | Err(why) => error!(directory = parent, "=> {} Create - {}", Label::fail(), why),
275                }
276                match File::create(format!("{parent}/accept.txt")) {
277                    | Ok(mut file) => {
278                        // TODO: Concatenate organization alternative names to accept file
279                        let acronyms = Constant::last_values("acronyms");
280                        let partners = Constant::last_values("partners");
281                        let sponsors = Constant::last_values("sponsors");
282                        let words = Constant::read_lines("accept.txt");
283                        let content = acronyms.chain(partners).chain(sponsors).chain(words).collect::<Vec<String>>().join("\n");
284                        file.write_all(content.as_bytes()).expect("Unable to write to accept.txt");
285                    }
286                    | Err(why) => panic!("=> {} Create accept.txt - {}", Label::fail(), why),
287                }
288                match File::create(format!("{parent}/reject.txt")) {
289                    | Ok(mut file) => {
290                        let content = Constant::read_lines("reject.txt").join("\n");
291                        file.write_all(content.as_bytes()).expect("Unable to write to reject.txt");
292                    }
293                    | Err(why) => panic!("=> {} Create reject.txt - {}", Label::fail(), why),
294                }
295                Ok(())
296            }
297            | Err(why) => {
298                error!(config = path_to_string(config_path), "=> {} Vale sync - {}", Label::fail(), why);
299                std::process::exit(exitcode::SOFTWARE);
300            }
301        }
302    }
303    fn with_binary(mut self, path: PathBuf) -> Self {
304        self.binary = Some(path);
305        self
306    }
307    fn with_config(mut self, value: ValeConfig) -> Self {
308        self.config = Some(value);
309        self
310    }
311    fn with_system_command(mut self) -> Self {
312        let name = self.clone().command();
313        if command_exists(name.clone()) {
314            let path = which(name.clone()).unwrap().to_path_buf();
315            self.binary = Some(path.clone());
316            let offset = "vale version ".len();
317            let version = cmd!(name.clone(), "--version").read().unwrap()[offset..].to_string();
318            self.version = Some(SemanticVersion::from_string(&version));
319            debug!(
320                path = path_to_string(path),
321                "=> {} System {} (v{}) command",
322                Label::using(),
323                name.green().bold(),
324                version
325            );
326        }
327        self
328    }
329    fn with_version(mut self, value: String) -> Self {
330        self.version = Some(SemanticVersion::from_string(&value));
331        self
332    }
333}
334impl StaticAnalyzerConfig for ValeConfig {
335    fn default() -> Self {
336        let config = ValeConfig::init()
337            .packages(to_string(vec!["Google", "proselint", "write-good", "Joblint"]))
338            .vocabularies(to_string(vec![APPLICATION]))
339            .disabled(to_string(vec![
340                "Vale.Terms",
341                "Google.EmDash",
342                "Google.Contractions",
343                "Google.GenderBias",
344                "Google.Headings",
345                "Google.Parens",
346                "Google.Quotes",
347                "Google.We",
348                "Joblint.Competitive",
349                "proselint.GenderBias",
350                "write-good.E-Prime",
351                "write-good.Passive",
352                "write-good.TooWordy",
353                "write-good.Weasel",
354            ]))
355            .build();
356        trace!("=> {} Default - {:#?}", Label::using(), config.dimmed().cyan());
357        config
358    }
359    fn ini(self) -> Ini {
360        let ValeConfig {
361            packages,
362            vocabularies,
363            disabled,
364            ..
365        } = self;
366        let mut conf = Ini::new();
367        // CAUTION: Order of attributes in INI file matter. "StylesPath" must come before "Vocab"
368        conf.with_section::<String>(None)
369            .set("StylesPath", "styles")
370            .set("Vocab", vocabularies.join(", "))
371            .set("Packages", packages.join(", "));
372        conf.with_section(Some("*"))
373            .set("BasedOnStyles", format!("Vale, {}", packages.join(", ")));
374        disabled.iter().for_each(|rule| {
375            conf.with_section(Some("*")).set(rule, "NO");
376        });
377        conf
378    }
379    fn save(self) -> ValeConfig {
380        let path = self.clone().path;
381        let parent = path.parent().unwrap().to_path_buf();
382        match create_dir_all(parent.clone()) {
383            | Ok(_) => {}
384            | Err(why) => error!(directory = path_to_string(parent), "=> {} Create - {}", Label::fail(), why),
385        }
386        match self.clone().ini().write_to_file(path.clone()) {
387            | Ok(_) => {
388                debug!(path = path_to_string(path), "=> {} Saved configuration", Label::using());
389            }
390            | Err(why) => {
391                error!("=> {} Save configuration - {}", Label::fail(), why);
392                std::process::exit(exitcode::SOFTWARE);
393            }
394        }
395        self
396    }
397}
398
399#[cfg(test)]
400mod tests;