acorn_lib/analyzer/
mod.rs

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