Skip to main content

acorn/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//!
5//!
6use crate::analyzer::error::DocumentTarget;
7use crate::analyzer::vale::{ValeOutput, ValeOutputItem};
8#[cfg(feature = "std")]
9use crate::io::http::get;
10use crate::io::InputOutput;
11use crate::io::{command_exists, download_binary, extract_zip, file_checksum, make_executable, standard_project_folder, ApiResult};
12use crate::prelude::{self, create_dir_all, remove_file, write, Arc, Command, CommandOutput, File, HashMap, Path, PathBuf, Stdio};
13use crate::schema::pid::raid;
14use crate::schema::pid::{PersistentIdentifier, PersistentIdentifierParse, DOI};
15use crate::schema::research_activity::ResearchActivity;
16use crate::schema::standard::cff::{Cff, Identifier, IdentifierType, Reference};
17use crate::schema::standard::text::{Docx, Text};
18use crate::schema::standard::{datacite, dcat, huwise, invenio};
19use crate::schema::{Organization, ProgrammingLanguage, Website};
20use crate::util::constants::app::{APPLICATION, ONLINE_SYNC_ATTEMPTS};
21use crate::util::constants::vale::{CUSTOM_VALE_PACKAGE_NAME, DEFAULT_VALE_PACKAGE_URL, DEFAULT_VALE_ROOT, VALE_RELEASES_URL, VALE_VERSION};
22use crate::util::{is_uri_or_path, Constant, Label, SemanticVersion, StringConversion};
23use crate::{check, check_err, check_ok};
24use crate::{cmd, skip};
25use crate::{Location, Repository};
26use async_trait::async_trait;
27#[cfg(feature = "analysis")]
28use bat::PrettyPrinter;
29use color_eyre::eyre::eyre;
30use color_eyre::owo_colors::OwoColorize;
31use convert_case::{Case, Casing};
32use flate2::read::GzDecoder;
33use futures::future::{BoxFuture, FutureExt};
34use ini::Ini;
35use lychee_lib::{CacheStatus, Response, Status};
36#[cfg(feature = "analysis")]
37use polars::datatypes::PlSmallStr;
38#[cfg(feature = "analysis")]
39use polars::prelude::{DataFrame, PolarsResult};
40use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
41use tar::Archive;
42#[cfg(feature = "std")]
43use tokio::time::sleep;
44use tracing::{debug, error, info, warn};
45use validator::Validate;
46use validator::ValidationErrorsKind;
47use which::which;
48
49pub mod check;
50#[cfg(feature = "analysis")]
51pub mod discovery;
52pub mod error;
53pub mod readability;
54#[cfg(feature = "analysis")]
55mod service;
56pub mod vale;
57
58pub use check::{Check, CheckCategory, CheckOptions, CheckSeverity, Checks, IntoChecks, OutputFormat, Render, Standard};
59
60#[cfg(feature = "analysis")]
61pub use check::{checks_to_csv, checks_to_dataframe, summary, Analysis, IntoRow};
62#[cfg(feature = "analysis")]
63pub use service::{analyze_paths, classify_paths, AnalysisBatch, AnalysisReport, CategoryChecks, StandardPaths};
64
65#[cfg(feature = "analysis")]
66use check::{check_prose_for, check_readability_for};
67use error::{process, ErrorKind};
68use vale::{Vale, ValeConfig};
69
70/// Trait for static analyzers (e.g. Vale)
71#[async_trait]
72pub trait StaticAnalyzer<Config: StaticAnalyzerConfig> {
73    /// Get command name (e.g. "vale")
74    fn command(&self) -> String;
75    /// Download binary
76    async fn download(self, config: Option<Config>, skip_verify_checksum: bool) -> Self;
77    /// Download checksum values
78    async fn download_checksums(self) -> ApiResult<HashMap<String, String>>;
79    /// Extract binary
80    fn extract(self, path: PathBuf, destination: Option<PathBuf>) -> PathBuf;
81    /// Resolve analyzer
82    async fn resolve(_config: Config, _is_offline: bool, _skip_verify_checksum: bool) -> Self;
83    /// Run analyzer on content
84    async fn run(&self, path: PathBuf, content: String, output: Option<String>) -> Vec<Check>;
85    /// Perform synchronization when supported by the analyzer
86    async fn sync(self, is_offline: bool, quiet: bool) -> ApiResult<()>;
87    /// Retry synchronization when supported by the analyzer.
88    #[cfg(feature = "std")]
89    async fn retry_sync(self, options: &CheckOptions) -> ApiResult<()>
90    where
91        Self: Sized,
92    {
93        self.sync(options.offline, options.quiet).await
94    }
95    /// Set binary
96    fn with_binary<P>(self, path: P) -> Self
97    where
98        P: Into<PathBuf>;
99    /// Set config
100    fn with_config(self, value: Config) -> Self;
101    /// Set system command
102    fn with_system_command(self) -> Self;
103    /// Set version
104    fn with_version(self, value: String) -> Self;
105}
106/// Trait for static analyzer configuration (e.g. .vale.ini)
107#[async_trait]
108pub trait StaticAnalyzerConfig {
109    /// Convert to INI
110    async fn ini(self) -> Ini;
111    /// Save configuration
112    async fn save(self) -> Self;
113    /// Set parent path of configuration
114    fn with_path(self, path: PathBuf) -> Self;
115    /// Resolve package source: convert bare package name to download URL or pass through existing URL/path
116    fn resolve_package(value: impl AsRef<str>) -> String;
117}
118#[cfg(feature = "analysis")]
119#[async_trait]
120impl Analysis for Cff {
121    fn standard() -> Standard {
122        Standard::CitationFileFormat
123    }
124    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
125        check_prose_for::<Self>(paths, options).await
126    }
127    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
128        paths
129            .par_iter()
130            .map(|path| match Self::read(path) {
131                | Ok(_) => check_ok!(CheckCategory::Quality),
132                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
133            })
134            .collect()
135    }
136    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
137        check_readability_for::<Self>(paths, options)
138    }
139    async fn check_schema(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
140        paths
141            .par_iter()
142            .flat_map(|path| match Self::read(path) {
143                | Ok(data) => {
144                    let uri = Some(path.file_name_with_parent());
145                    let data = Arc::new(data);
146                    collect_validation_checks(data.as_ref())
147                        .into_iter()
148                        .map(|issue| issue.with_uri(uri.clone()))
149                        .collect::<Vec<_>>()
150                }
151                | Err(why) => why
152                    .to_string()
153                    .lines()
154                    .map(|line| check_err!(CheckCategory::Schema, context: line.to_string()).with_uri(Some(path.file_name_with_parent())))
155                    .collect(),
156            })
157            .collect()
158    }
159    #[cfg(feature = "std")]
160    async fn check_websites(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
161        let futures = paths.iter().map(|path| {
162            let path = path.clone();
163            async move {
164                match Self::read(&path) {
165                    | Ok(data) => {
166                        let _data = Arc::new(data);
167                        let Cff {
168                            identifiers,
169                            license_url,
170                            references,
171                            repository,
172                            repository_artifact,
173                            repository_code,
174                            url,
175                            ..
176                        } = _data.as_ref();
177                        let root_futures = vec![
178                            url.as_deref().map(|u| link_check(Some(u), Some("url".into()))),
179                            license_url.as_deref().map(|u| link_check(Some(u), Some("license_url".into()))),
180                            repository.as_deref().map(|u| link_check(Some(u), Some("repository".into()))),
181                            repository_artifact
182                                .as_deref()
183                                .map(|u| link_check(Some(u), Some("repository_artifact".into()))),
184                            repository_code.as_deref().map(|u| link_check(Some(u), Some("repository_code".into()))),
185                        ]
186                        .into_iter()
187                        .flatten()
188                        .collect::<Vec<_>>();
189                        let identifier_futures = identifiers
190                            .as_ref()
191                            .into_iter()
192                            .flatten()
193                            .enumerate()
194                            .filter_map(|(i, Identifier { kind, value, .. })| match kind {
195                                | IdentifierType::Doi => {
196                                    let maybe_doi = DOI::from_string(value).url();
197                                    let url = if maybe_doi.is_empty() { value.to_owned() } else { maybe_doi };
198                                    Some(link_check(Some(url), Some(format!("identifiers[{i}].value"))))
199                                }
200                                | IdentifierType::Url => Some(link_check(Some(value.as_str()), Some(format!("identifiers[{i}].value")))),
201                                | _ => None,
202                            })
203                            .collect::<Vec<_>>();
204                        let reference_futures = references
205                            .as_ref()
206                            .into_iter()
207                            .flatten()
208                            .enumerate()
209                            .flat_map(
210                                |(
211                                    i,
212                                    Reference {
213                                        doi,
214                                        collection_doi,
215                                        license_url,
216                                        repository,
217                                        repository_artifact,
218                                        repository_code,
219                                        url,
220                                        ..
221                                    },
222                                )| {
223                                    vec![
224                                        doi.as_deref().map(|d| {
225                                            let url = DOI::from_string(d).url();
226                                            link_check(Some(url), Some(format!("references[{i}].doi")))
227                                        }),
228                                        collection_doi.as_deref().map(|d| {
229                                            let url = DOI::from_string(d).url();
230                                            link_check(Some(url), Some(format!("references[{i}].collection_doi")))
231                                        }),
232                                        license_url
233                                            .as_deref()
234                                            .map(|u| link_check(Some(u), Some(format!("references[{i}].license_url")))),
235                                        repository
236                                            .as_deref()
237                                            .map(|u| link_check(Some(u), Some(format!("references[{i}].repository")))),
238                                        repository_artifact
239                                            .as_deref()
240                                            .map(|u| link_check(Some(u), Some(format!("references[{i}].repository_artifact")))),
241                                        repository_code
242                                            .as_deref()
243                                            .map(|u| link_check(Some(u), Some(format!("references[{i}].repository_code")))),
244                                        url.as_deref().map(|u| link_check(Some(u), Some(format!("references[{i}].url")))),
245                                    ]
246                                    .into_iter()
247                                    .flatten()
248                                },
249                            )
250                            .collect::<Vec<_>>();
251                        let futures: Vec<_> = root_futures.into_iter().chain(identifier_futures).chain(reference_futures).collect();
252                        Self::collect_checks(futures, &path).await
253                    }
254                    | Err(why) => vec![check_err!(CheckCategory::Link, context: why.to_string()).with_uri(Some(path.file_name_with_parent()))],
255                }
256            }
257        });
258        Self::flatten_checks(futures).await
259    }
260    fn output_path(path: &Path, _data: &Self) -> PathBuf {
261        standard_project_folder("check", None).join(path.to_path_buf().file_name_with_parent())
262    }
263}
264#[cfg(feature = "analysis")]
265#[async_trait]
266impl Analysis for datacite::Record {
267    fn standard() -> Standard {
268        Standard::Datacite
269    }
270    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
271        check_prose_for::<Self>(paths, options).await
272    }
273    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
274        paths
275            .par_iter()
276            .map(|path| match Self::read(path) {
277                | Ok(_) => check_ok!(CheckCategory::Quality),
278                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
279            })
280            .collect()
281    }
282    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
283        check_readability_for::<Self>(paths, options)
284    }
285    async fn check_schema(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
286        paths
287            .par_iter()
288            .flat_map(|path| match Self::read(path) {
289                | Ok(data) => {
290                    let uri = Some(path.file_name_with_parent());
291                    let data = Arc::new(data);
292                    collect_validation_checks(data.as_ref())
293                        .into_iter()
294                        .map(|issue| issue.with_uri(uri.clone()))
295                        .collect::<Vec<_>>()
296                }
297                | Err(why) => why
298                    .to_string()
299                    .lines()
300                    .map(|line| check_err!(CheckCategory::Schema, context: line.to_string()).with_uri(Some(path.file_name_with_parent())))
301                    .collect(),
302            })
303            .collect()
304    }
305    #[cfg(feature = "std")]
306    async fn check_websites(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
307        let futures = paths.iter().map(|path| {
308            let path = path.clone();
309            async move {
310                match Self::read(&path) {
311                    | Ok(data) => {
312                        let data = Arc::new(data);
313                        let attributes = &data.attributes;
314                        let root_futures = attributes
315                            .url
316                            .as_deref()
317                            .map(|value| link_check(Some(value), Some("attributes.url".into())))
318                            .into_iter();
319                        let rights_futures = attributes.rights_list.iter().flatten().enumerate().filter_map(|(index, value)| {
320                            value
321                                .rights_uri
322                                .as_deref()
323                                .map(|uri| link_check(Some(uri), Some(format!("attributes.rights_list[{index}].rights_uri"))))
324                        });
325                        let related_futures = attributes.related_identifiers.iter().flatten().enumerate().filter_map(|(index, value)| {
326                            match value.related_identifier_type {
327                                | Some(datacite::RelatedIdentifierType::Doi) => {
328                                    let url = DOI::from_string(value.related_identifier.as_str()).url();
329                                    let target = if url.is_empty() { value.related_identifier.clone() } else { url };
330                                    Some(link_check(
331                                        Some(target),
332                                        Some(format!("attributes.related_identifiers[{index}].related_identifier")),
333                                    ))
334                                }
335                                | Some(datacite::RelatedIdentifierType::Url) => Some(link_check(
336                                    Some(value.related_identifier.as_str()),
337                                    Some(format!("attributes.related_identifiers[{index}].related_identifier")),
338                                )),
339                                | _ => None,
340                            }
341                        });
342                        Self::collect_checks(root_futures.chain(rights_futures).chain(related_futures).collect::<Vec<_>>(), &path).await
343                    }
344                    | Err(why) => vec![check_err!(CheckCategory::Link, context: why.to_string()).with_uri(Some(path.file_name_with_parent()))],
345                }
346            }
347        });
348        Self::flatten_checks(futures).await
349    }
350    fn output_path(path: &Path, _data: &Self) -> PathBuf {
351        standard_project_folder("check", None).join(path.to_path_buf().file_name_with_parent())
352    }
353}
354#[cfg(feature = "analysis")]
355#[async_trait]
356impl Analysis for dcat::Dataset {
357    fn standard() -> Standard {
358        Standard::Dcat
359    }
360    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
361        check_prose_for::<Self>(paths, options).await
362    }
363    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
364        paths
365            .par_iter()
366            .map(|path| match Self::read(path) {
367                | Ok(_) => check_ok!(CheckCategory::Quality),
368                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
369            })
370            .collect()
371    }
372    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
373        check_readability_for::<Self>(paths, options)
374    }
375    async fn check_schema(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
376        paths
377            .par_iter()
378            .flat_map(|path| match Self::read(path) {
379                | Ok(data) => {
380                    let uri = Some(path.file_name_with_parent());
381                    let data = Arc::new(data);
382                    collect_validation_checks(data.as_ref())
383                        .into_iter()
384                        .map(|issue| issue.with_uri(uri.clone()))
385                        .collect::<Vec<_>>()
386                }
387                | Err(why) => why
388                    .to_string()
389                    .lines()
390                    .map(|line| check_err!(CheckCategory::Schema, context: line.to_string()).with_uri(Some(path.file_name_with_parent())))
391                    .collect(),
392            })
393            .collect()
394    }
395    #[cfg(feature = "std")]
396    async fn check_websites(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
397        let futures = paths.iter().map(|path| {
398            let path = path.clone();
399            async move {
400                match Self::read(&path) {
401                    | Ok(data) => {
402                        let data = Arc::new(data);
403                        let root_futures = data
404                            .landing_page
405                            .iter()
406                            .flatten()
407                            .enumerate()
408                            .map(|(index, value)| link_check(value.url(), Some(format!("landing_page[{index}]"))));
409                        let distribution_futures = data
410                            .distribution
411                            .iter()
412                            .flatten()
413                            .enumerate()
414                            .flat_map(|(distribution_index, distribution)| {
415                                distribution
416                                    .access_url
417                                    .iter()
418                                    .enumerate()
419                                    .map(move |(index, value)| {
420                                        link_check(
421                                            Some(value.as_str()),
422                                            Some(format!("distribution[{distribution_index}].access_url[{index}]")),
423                                        )
424                                    })
425                                    .chain(distribution.download_url.iter().flatten().enumerate().map(move |(index, value)| {
426                                        link_check(
427                                            Some(value.as_str()),
428                                            Some(format!("distribution[{distribution_index}].download_url[{index}]")),
429                                        )
430                                    }))
431                            });
432                        Self::collect_checks(root_futures.chain(distribution_futures).collect::<Vec<_>>(), &path).await
433                    }
434                    | Err(why) => vec![check_err!(CheckCategory::Link, context: why.to_string()).with_uri(Some(path.file_name_with_parent()))],
435                }
436            }
437        });
438        Self::flatten_checks(futures).await
439    }
440    fn output_path(path: &Path, _data: &Self) -> PathBuf {
441        standard_project_folder("check", None).join(path.to_path_buf().file_name_with_parent())
442    }
443}
444#[cfg(feature = "analysis")]
445#[async_trait]
446impl Analysis for invenio::Record {
447    fn standard() -> Standard {
448        Standard::Invenio
449    }
450    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
451        check_prose_for::<Self>(paths, options).await
452    }
453    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
454        paths
455            .par_iter()
456            .map(|path| match Self::read(path) {
457                | Ok(_) => check_ok!(CheckCategory::Quality),
458                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
459            })
460            .collect()
461    }
462    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
463        check_readability_for::<Self>(paths, options)
464    }
465    async fn check_schema(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
466        paths
467            .par_iter()
468            .flat_map(|path| match Self::read(path) {
469                | Ok(data) => {
470                    let uri = Some(path.file_name_with_parent());
471                    let data = Arc::new(data);
472                    collect_validation_checks(data.as_ref())
473                        .into_iter()
474                        .map(|issue| issue.with_uri(uri.clone()))
475                        .collect::<Vec<_>>()
476                }
477                | Err(why) => why
478                    .to_string()
479                    .lines()
480                    .map(|line| check_err!(CheckCategory::Schema, context: line.to_string()).with_uri(Some(path.file_name_with_parent())))
481                    .collect(),
482            })
483            .collect()
484    }
485    #[cfg(feature = "std")]
486    async fn check_websites(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
487        let futures = paths.iter().map(|path| {
488            let path = path.clone();
489            async move {
490                match Self::read(&path) {
491                    | Ok(data) => {
492                        let data = Arc::new(data);
493                        let pid_futures = data.pids.iter().flat_map(|pids| {
494                            [
495                                pids.doi.as_ref().map(|value| {
496                                    link_check(
497                                        Some(DOI::from_string(value.identifier.as_str()).url()),
498                                        Some("pids.doi.identifier".into()),
499                                    )
500                                }),
501                                pids.concept_doi.as_ref().map(|value| {
502                                    link_check(
503                                        Some(DOI::from_string(value.identifier.as_str()).url()),
504                                        Some("pids.concept_doi.identifier".into()),
505                                    )
506                                }),
507                            ]
508                            .into_iter()
509                            .flatten()
510                        });
511                        let related_futures = data.metadata.iter().flat_map(|metadata| {
512                            metadata.related_identifiers.iter().flatten().enumerate().filter_map(|(index, value)| {
513                                match value.scheme.to_lowercase().as_str() {
514                                    | "doi" => {
515                                        let url = DOI::from_string(value.identifier.as_str()).url();
516                                        let target = if url.is_empty() { value.identifier.clone() } else { url };
517                                        Some(link_check(
518                                            Some(target),
519                                            Some(format!("metadata.related_identifiers[{index}].identifier")),
520                                        ))
521                                    }
522                                    | "url" => Some(link_check(
523                                        Some(value.identifier.as_str()),
524                                        Some(format!("metadata.related_identifiers[{index}].identifier")),
525                                    )),
526                                    | _ => None,
527                                }
528                            })
529                        });
530                        let rights_futures = data.metadata.iter().flat_map(|metadata| {
531                            metadata.rights.iter().flatten().enumerate().filter_map(|(index, value)| {
532                                value
533                                    .link
534                                    .as_deref()
535                                    .map(|uri| link_check(Some(uri), Some(format!("metadata.rights[{index}].link"))))
536                            })
537                        });
538                        Self::collect_checks(pid_futures.chain(related_futures).chain(rights_futures).collect::<Vec<_>>(), &path).await
539                    }
540                    | Err(why) => vec![check_err!(CheckCategory::Link, context: why.to_string()).with_uri(Some(path.file_name_with_parent()))],
541                }
542            }
543        });
544        Self::flatten_checks(futures).await
545    }
546    fn output_path(path: &Path, _data: &Self) -> PathBuf {
547        standard_project_folder("check", None).join(path.to_path_buf().file_name_with_parent())
548    }
549}
550#[cfg(feature = "analysis")]
551#[async_trait]
552impl Analysis for huwise::Dataset {
553    fn standard() -> Standard {
554        Standard::Huwise
555    }
556    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
557        check_prose_for::<Self>(paths, options).await
558    }
559    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
560        paths
561            .par_iter()
562            .map(|path| match Self::read(path) {
563                | Ok(_) => check_ok!(CheckCategory::Quality),
564                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
565            })
566            .collect()
567    }
568    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
569        check_readability_for::<Self>(paths, options)
570    }
571    async fn check_schema(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
572        paths
573            .par_iter()
574            .flat_map(|path| match Self::read(path) {
575                | Ok(data) => {
576                    let uri = Some(path.file_name_with_parent());
577                    let data = Arc::new(data);
578                    collect_validation_checks(data.as_ref())
579                        .into_iter()
580                        .map(|issue| issue.with_uri(uri.clone()))
581                        .collect::<Vec<_>>()
582                }
583                | Err(why) => why
584                    .to_string()
585                    .lines()
586                    .map(|line| check_err!(CheckCategory::Schema, context: line.to_string()).with_uri(Some(path.file_name_with_parent())))
587                    .collect(),
588            })
589            .collect()
590    }
591    fn output_path(path: &Path, _data: &Self) -> PathBuf {
592        standard_project_folder("check", None).join(path.to_path_buf().file_name_with_parent())
593    }
594}
595#[cfg(feature = "analysis")]
596#[async_trait]
597impl Analysis for Docx {
598    fn standard() -> Standard {
599        Standard::Docx
600    }
601    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
602        check_prose_for::<Self>(paths, options).await
603    }
604    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
605        paths
606            .par_iter()
607            .map(|path| match Self::read(path) {
608                | Ok(_) => check_ok!(CheckCategory::Quality),
609                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
610            })
611            .collect()
612    }
613    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
614        check_readability_for::<Self>(paths, options)
615    }
616    fn output_path(path: &Path, _data: &Self) -> PathBuf {
617        standard_project_folder("check", None).join(path.to_path_buf().file_name_with_parent())
618    }
619}
620#[cfg(feature = "analysis")]
621#[async_trait]
622impl Analysis for ResearchActivity {
623    fn standard() -> Standard {
624        Standard::ResearchActivityData
625    }
626    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
627        check_prose_for::<Self>(paths, options).await
628    }
629    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
630        paths
631            .par_iter()
632            .map(|path| match Self::read(path) {
633                | Ok(_) => check_ok!(CheckCategory::Quality),
634                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
635            })
636            .collect()
637    }
638    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
639        check_readability_for::<Self>(paths, options)
640    }
641    async fn check_schema(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
642        paths
643            .par_iter()
644            .flat_map(|path| match Self::read(path) {
645                | Ok(data) => {
646                    let uri = Some(path.file_name_with_parent());
647                    let data = Arc::new(data);
648                    collect_validation_checks(data.as_ref())
649                        .into_iter()
650                        .map(|issue| issue.with_uri(uri.clone()))
651                        .collect::<Vec<_>>()
652                }
653                | Err(why) => why
654                    .to_string()
655                    .lines()
656                    .map(|line| check_err!(CheckCategory::Schema, context: line.to_string()).with_uri(Some(path.file_name_with_parent())))
657                    .collect(),
658            })
659            .collect()
660    }
661    #[cfg(feature = "std")]
662    async fn check_websites(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
663        let futures = paths.iter().map(|path| {
664            let path = path.clone();
665            async move {
666                match Self::read(&path) {
667                    | Ok(data) => {
668                        let data = Arc::new(data);
669                        let ResearchActivity { meta, contact, .. } = data.as_ref();
670                        let dois = match &meta.doi {
671                            | Some(values) => values
672                                .iter()
673                                .enumerate()
674                                .map(|(i, doi)| {
675                                    let url = format!("https://doi.org/{doi}");
676                                    link_check(Some(url), Some(format!("meta.doi[{i}]")))
677                                })
678                                .collect::<Vec<_>>(),
679                            | None => vec![],
680                        };
681                        let websites = match &meta.websites {
682                            | Some(values) => values
683                                .iter()
684                                .enumerate()
685                                .map(|(i, Website { url, .. })| link_check(Some(url.as_str()), Some(format!("meta.websites[{i}].url"))))
686                                .collect::<Vec<_>>(),
687                            | None => vec![],
688                        };
689                        let contact = [link_check(Some(contact.url.as_str()), Some("contact.url".into()))];
690                        let links: Vec<_> = [].into_iter().chain(dois).chain(websites).chain(contact).collect();
691                        Self::collect_checks(links, &path).await
692                    }
693                    | Err(why) => vec![check_err!(CheckCategory::Link, context: why.to_string()).with_uri(Some(path.file_name_with_parent()))],
694                }
695            }
696        });
697        Self::flatten_checks(futures).await
698    }
699    fn output_path(path: &Path, data: &Self) -> PathBuf {
700        let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("index.json");
701        standard_project_folder("check", None)
702            .join(data.meta.identifier.to_lowercase())
703            .join(filename)
704    }
705}
706#[cfg(feature = "analysis")]
707#[async_trait]
708impl Analysis for Text {
709    fn standard() -> Standard {
710        Standard::Text
711    }
712    async fn check_prose(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
713        check_prose_for::<Self>(paths, options).await
714    }
715    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
716        paths
717            .par_iter()
718            .map(|path| match Self::read(path) {
719                | Ok(_) => check_ok!(CheckCategory::Quality),
720                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
721            })
722            .collect()
723    }
724    async fn check_readability(paths: &[PathBuf], options: Option<&CheckOptions>) -> Vec<Check> {
725        check_readability_for::<Self>(paths, options)
726    }
727    fn output_path(path: &Path, _data: &Self) -> PathBuf {
728        standard_project_folder("check", None).join(path.to_path_buf().file_name_with_parent())
729    }
730}
731#[cfg(feature = "analysis")]
732#[async_trait]
733impl Analysis for raid::Metadata {
734    fn standard() -> Standard {
735        Standard::Raid
736    }
737    async fn check_quality(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
738        paths
739            .par_iter()
740            .map(|path| match Self::read(path.to_path_buf()) {
741                | Ok(_) => check_ok!(CheckCategory::Quality),
742                | Err(why) => check_err!(CheckCategory::Quality, context: why.to_string()),
743            })
744            .collect()
745    }
746    async fn check_schema(paths: &[PathBuf], _options: Option<&CheckOptions>) -> Vec<Check> {
747        paths
748            .par_iter()
749            .flat_map(|path| match Self::read(path.to_path_buf()) {
750                | Ok(data) => {
751                    let uri = Some(path.file_name_with_parent());
752                    collect_validation_checks(&data)
753                        .into_iter()
754                        .map(|issue| issue.with_uri(uri.clone()))
755                        .collect::<Vec<_>>()
756                }
757                | Err(why) => why
758                    .to_string()
759                    .lines()
760                    .map(|line| check_err!(CheckCategory::Schema, context: line.to_string()).with_uri(Some(path.file_name_with_parent())))
761                    .collect(),
762            })
763            .collect()
764    }
765    fn output_path(path: &Path, _data: &Self) -> PathBuf {
766        standard_project_folder("check", None).join(path.to_path_buf().file_name_with_parent())
767    }
768}
769#[async_trait]
770impl StaticAnalyzer<ValeConfig> for Vale {
771    fn command(&self) -> String {
772        "vale".to_string()
773    }
774    /// Resolve Vale
775    /// ### Notes
776    /// - Will use system `vale` command if available
777    /// - Will use local `vale` binary if available (will expect local binary if offline)
778    /// - Will download `vale` binary if not available by other means
779    async fn resolve(config: ValeConfig, is_offline: bool, skip_verify_checksum: bool) -> Vale {
780        fn any_exist<S>(paths: Vec<S>) -> bool
781        where
782            S: Into<PathBuf>,
783        {
784            paths.into_iter().any(|s| s.into().exists())
785        }
786        let root = DEFAULT_VALE_ROOT;
787        let name = "vale";
788        let init = Vale::init().build();
789        let vale = if command_exists(name) {
790            let init_with_config = init.with_config(config);
791            init_with_config.with_system_command()
792        } else if is_offline || any_exist(vec![format!("{root}{name}"), format!("{root}{name}.exe")]) {
793            info!("=> {} Local {} binary", Label::using(), name.green().bold());
794            #[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
795            {
796                init.with_config(config).with_binary(format!("{root}{name}"))
797            }
798            #[cfg(windows)]
799            {
800                init.with_config(config).with_binary(format!("{root}{name}.exe"))
801            }
802        } else {
803            init.download(Some(config), skip_verify_checksum).await
804        };
805        vale
806    }
807    async fn run(&self, path: PathBuf, content: String, output_format: Option<String>) -> Vec<Check> {
808        let uri = path.file_name_with_parent();
809        let root = path
810            .parent()
811            .map(|value| value.to_path_buf())
812            .unwrap_or_else(|| standard_project_folder("check", None));
813        match create_dir_all(root.clone()) {
814            | Ok(_) => {}
815            | Err(why) => error!(path = root.clone().to_absolute_path(), "=> {} Create — {why}", Label::fail()),
816        }
817        match write(&path, content.as_bytes()) {
818            | Ok(_) => {}
819            | Err(why) => {
820                error!(path = path.to_absolute_path(), "=> {} Write file — {why}", Label::fail());
821                return vec![check_err!(
822                    CheckCategory::Prose,
823                    uri: uri,
824                    message: format!("Cannot write analyzer input at {}", path.display())
825                )];
826            }
827        }
828        let binary = match &self.binary {
829            | Some(value) => value,
830            | None => {
831                error!("=> {} {} binary", Label::not_found(), self.command());
832                &PathBuf::from("./.vale/vale")
833            }
834        };
835        match &self.config {
836            | Some(config) => {
837                let binary_path = binary.to_absolute_path();
838                let config_path = config.clone().path.to_absolute_path();
839                let path_string = path.clone().to_absolute_path();
840                let args = [
841                    "--no-exit".to_string(),
842                    "--no-wrap".to_string(),
843                    "--ext".to_string(),
844                    ".md".to_string(),
845                    "--config".to_string(),
846                    config_path,
847                    path_string,
848                ];
849                let result = match output_format {
850                    | Some(value) => {
851                        let args = args.into_iter().chain(["--output".to_string(), value]).collect::<Vec<String>>();
852                        cmd!(binary_path, args)
853                    }
854                    | None => cmd!(binary_path, args),
855                };
856                match result {
857                    | Ok(output) if output.status.success() => {
858                        let parsed = ValeOutput::parse(&output.stdout(), path.clone());
859                        if parsed.is_empty() {
860                            info!("=> {} {} has {}", Label::pass(), uri.underline(), "no prose issues".green());
861                            vec![check_ok!(CheckCategory::Prose, uri: uri)]
862                        } else {
863                            parsed
864                                .into_iter()
865                                .map(|item| {
866                                    let ValeOutputItem { message, severity, .. } = item.clone();
867                                    check!(
868                                        CheckCategory::Prose,
869                                        false,
870                                        severity: severity.into(),
871                                        uri: uri.clone(),
872                                        locator: item.locator(),
873                                        message: message,
874                                        errors: ErrorKind::Vale(vec![item.clone()]),
875                                        data: content.clone(),
876                                    )
877                                })
878                                .collect()
879                        }
880                    }
881                    | Ok(output) => {
882                        let why = output.stderr();
883                        let message = if why.is_empty() {
884                            format!("process exited with status {}", output.status)
885                        } else {
886                            why
887                        };
888                        error!("=> {} Analyze with OK output — {message}", Label::fail());
889                        vec![check_err!(CheckCategory::Prose, message: message)]
890                    }
891                    | Err(why) => {
892                        error!("=> {} Analyze — {why}", Label::fail());
893                        vec![check_err!(CheckCategory::Prose, uri: uri)]
894                    }
895                }
896            }
897            | None => {
898                let title = self.command().to_case(Case::Title);
899                error!("=> {} {} configuration", Label::not_found(), title);
900                vec![check_err!(CheckCategory::Prose, message: uri)]
901            }
902        }
903    }
904    async fn download(self, config: Option<ValeConfig>, skip_verify_checksum: bool) -> Vale {
905        let platform = prelude::vale_release_filename();
906        let release = match self.version {
907            | Some(value) => value,
908            | None => SemanticVersion::from(VALE_VERSION),
909        };
910        let url = format!("{VALE_RELEASES_URL}/download/v{release}/{}_{release}_{platform}", self.command());
911        info!(url, "=> {} Vale release v{release}", Label::using());
912        let binary = match download_binary(&url, ".").await {
913            | Ok(path) => {
914                if !skip_verify_checksum {
915                    let dowloaded_checksum = match self.clone().download_checksums().await {
916                        | Ok(value) => value.get(&platform).unwrap_or(&String::new()).to_string(),
917                        | Err(_) => "".to_string(),
918                    };
919                    if let Some(calculated) = file_checksum(path.clone(), None) {
920                        if !dowloaded_checksum.eq(&calculated.checksum_value) {
921                            error!(dowloaded_checksum, calculated = calculated.checksum_value, "=> {}", Label::invalid());
922                            let _cleanup = remove_file(path.clone());
923                        } else {
924                            info!(checksum = dowloaded_checksum, "=> {} Checksum verification", Label::pass());
925                        }
926                    };
927                } else {
928                    skip!("Checksum verification");
929                }
930                let destination = match config.clone() {
931                    | Some(value) => value.path.parent().map(Path::to_path_buf).unwrap_or(PathBuf::from("./.vale/")),
932                    | None => PathBuf::from("./.vale/"),
933                };
934                let binary = self.clone().extract(path.clone(), Some(destination));
935                if make_executable(&binary) {
936                    let _cleanup = remove_file(path);
937                    Some(binary)
938                } else {
939                    error!("=> {} {} not executable", Label::fail(), self.command());
940                    None
941                }
942            }
943            | Err(why) => {
944                error!(url, "=> {} {} download — {why}", Label::fail(), self.command());
945                None
946            }
947        };
948        let builder = Vale::init().version(release).maybe_binary(binary);
949        builder.config(config.unwrap_or_default()).build()
950    }
951    async fn download_checksums(self) -> ApiResult<HashMap<String, String>> {
952        let release = match self.version {
953            | Some(value) => value,
954            | None => SemanticVersion::from(VALE_VERSION),
955        };
956        let url = format!("{VALE_RELEASES_URL}/download/v{release}/{}_{release}_checksums.txt", self.command());
957        let checksums = match get(url).send().await {
958            | Ok(response) => match response.text().await {
959                | Ok(content) => Ok(content.lines().clone().fold(HashMap::new(), |mut acc: HashMap<String, String>, line| {
960                    let mut values: Vec<&str> = line.split("  ").collect();
961                    if let (Some(raw_key), Some(raw_value)) = (values.pop(), values.pop()) {
962                        let key = raw_key["vale_#.#.#_".len()..].to_string();
963                        acc.insert(key, raw_value.to_string());
964                    }
965                    acc
966                })),
967                | Err(why) => Err(eyre!("Failed to read checksums response — {why}")),
968            },
969            | Err(why) => Err(eyre!("Failed to download checksums — {why}")),
970        };
971        match checksums {
972            | Ok(checksums) => {
973                debug!(
974                    "=> {} {} checksums {:#?}",
975                    Label::using(),
976                    self.command().to_case(Case::Title),
977                    checksums.dimmed().cyan()
978                );
979                Ok(checksums)
980            }
981            | Err(why) => Err(why),
982        }
983    }
984    fn extract(self, path: PathBuf, destination: Option<PathBuf>) -> PathBuf {
985        let command = self.command();
986        let parent = match destination {
987            | Some(value) => value.to_absolute_path(),
988            | None => format!("./.{command}/"),
989        };
990        let extension = path.extension().unwrap_or_default().to_str().unwrap_or_default().to_string();
991        match extension.as_str() {
992            | "zip" => match extract_zip(path, Some(parent.into())) {
993                | Ok(value) => {
994                    let path = value.join(command);
995                    if cfg!(windows) {
996                        path.with_extension("exe")
997                    } else {
998                        path
999                    }
1000                }
1001                | Err(why) => {
1002                    error!("=> {} {command} extract — {why}", Label::fail());
1003                    let path = PathBuf::from(DEFAULT_VALE_ROOT).join(command);
1004                    if cfg!(windows) {
1005                        path.with_extension("exe")
1006                    } else {
1007                        path
1008                    }
1009                }
1010            },
1011            | "gz" => match File::open(path) {
1012                | Ok(tar_gz) => {
1013                    let tar = GzDecoder::new(tar_gz);
1014                    let mut archive = Archive::new(tar);
1015                    match archive.unpack(parent.clone()) {
1016                        | Ok(_) => {
1017                            debug!(parent, "=> {} Extracted {command} binary", Label::using());
1018                            PathBuf::from(format!("{parent}/{command}"))
1019                        }
1020                        | Err(why) => {
1021                            error!("=> {} {command} extract — {why}", Label::fail());
1022                            let path = PathBuf::from(DEFAULT_VALE_ROOT).join(command);
1023                            if cfg!(windows) {
1024                                path.with_extension("exe")
1025                            } else {
1026                                path
1027                            }
1028                        }
1029                    }
1030                }
1031                | Err(why) => {
1032                    error!("=> {} {command} extract — {why}", Label::fail());
1033                    let path = PathBuf::from(DEFAULT_VALE_ROOT).join(command);
1034                    if cfg!(windows) {
1035                        path.with_extension("exe")
1036                    } else {
1037                        path
1038                    }
1039                }
1040            },
1041            | _ => {
1042                error!("=> {} {command} extract — Unsupported format", Label::fail());
1043                PathBuf::from(DEFAULT_VALE_ROOT).join(command)
1044            }
1045        }
1046    }
1047    async fn sync(self, is_offline: bool, quiet: bool) -> ApiResult<()> {
1048        let command = self.command();
1049        let binary_path = match self.binary {
1050            | Some(value) => value,
1051            | None => {
1052                error!("=> {} {} binary", Label::not_found(), command);
1053                PathBuf::from(DEFAULT_VALE_ROOT).join(command)
1054            }
1055        };
1056        let config_path = self.config.unwrap_or_default().path;
1057        let result: ApiResult<()> = if is_offline {
1058            skip!("Vale sync");
1059            Ok(())
1060        } else {
1061            let pipe = || if quiet { Stdio::null() } else { Stdio::inherit() };
1062            let status = Command::new(binary_path.clone())
1063                .arg("--config")
1064                .arg(config_path.clone())
1065                .arg("sync")
1066                .stdout(pipe())
1067                .stderr(pipe())
1068                .status();
1069            match status {
1070                | Ok(value) if value.success() => Ok(()),
1071                | Ok(value) => Err(eyre!("Vale sync failed ({value})")),
1072                | Err(why) => Err(eyre!("Vale sync failed — {why}")),
1073            }
1074        };
1075        match result {
1076            | Ok(_) => {
1077                let parent_dir = config_path.parent().map(|p| p.display().to_string()).unwrap_or_default();
1078                let parent = format!("{parent_dir}/styles/config/vocabularies/{APPLICATION}");
1079                debug!(parent, "=> {} Vocabularies", Label::using());
1080                match create_dir_all(parent.clone()) {
1081                    | Ok(_) => {}
1082                    | Err(why) => error!(directory = parent, "=> {} Create - {why}", Label::fail()),
1083                }
1084                let acronyms = Constant::last_values("acronyms");
1085                let partners = Constant::nth_values("partners", 1);
1086                let sponsors = Constant::nth_values("sponsors", 1);
1087                let abbreviations = Organization::alternative_names();
1088                let words = Constant::read_lines("accept.txt");
1089                let accept_content = acronyms
1090                    .chain(partners)
1091                    .chain(sponsors)
1092                    .chain(abbreviations)
1093                    .chain(words)
1094                    .collect::<Vec<String>>()
1095                    .join("\n");
1096                match write(format!("{parent}/accept.txt"), accept_content.as_bytes()) {
1097                    | Ok(_) => {
1098                        let reject_content = Constant::read_lines("reject.txt").join("\n");
1099                        match write(format!("{parent}/reject.txt"), reject_content.as_bytes()) {
1100                            | Ok(_) => Ok(()),
1101                            | Err(why) => Err(eyre!("Write reject.txt failed — {why}")),
1102                        }
1103                    }
1104                    | Err(why) => Err(eyre!("Write accept.txt failed — {why}")),
1105                }
1106            }
1107            | Err(why) => {
1108                error!(config = config_path.to_absolute_path(), "=> {} Vale sync — {why}", Label::fail());
1109                Err(why)
1110            }
1111        }
1112    }
1113    #[cfg(feature = "std")]
1114    async fn retry_sync(self, options: &CheckOptions) -> ApiResult<()> {
1115        let command = self.command();
1116        let mut attempt = 1;
1117        loop {
1118            match self.clone().sync(options.offline, options.quiet).await {
1119                | Ok(()) => break Ok(()),
1120                | Err(why) if attempt < ONLINE_SYNC_ATTEMPTS => {
1121                    warn!(
1122                        attempt,
1123                        attempts = ONLINE_SYNC_ATTEMPTS,
1124                        interval = %options.sync_retry_interval,
1125                        "{command} sync failed — {why}; retrying..."
1126                    );
1127                    sleep(options.sync_retry_interval.unsigned_abs()).await;
1128                    attempt = attempt.saturating_add(1);
1129                }
1130                | Err(why) if options.ignore_sync_failure => {
1131                    warn!(
1132                        attempts = ONLINE_SYNC_ATTEMPTS,
1133                        "{command} sync failed — {why}; continuing because --ignore-sync-failure is set"
1134                    );
1135                    break Ok(());
1136                }
1137                | Err(why) => break Err(eyre!("{command} sync failed after {ONLINE_SYNC_ATTEMPTS} attempts — {why}")),
1138            }
1139        }
1140    }
1141    fn with_binary<P>(mut self, path: P) -> Self
1142    where
1143        P: Into<PathBuf>,
1144    {
1145        self.binary = Some(path.into());
1146        self
1147    }
1148    fn with_config(mut self, value: ValeConfig) -> Self {
1149        self.config = Some(value);
1150        self
1151    }
1152    fn with_system_command(mut self) -> Self {
1153        let name = self.command();
1154        if command_exists(name.clone()) {
1155            match which(name.clone()) {
1156                | Ok(path) => {
1157                    let path = path.to_path_buf();
1158                    self.binary = Some(path.clone());
1159                    match cmd!(name.clone(), ["--version"]) {
1160                        | Ok(output) => {
1161                            let stdout = output.stdout();
1162                            let version = stdout.strip_prefix("vale version ").unwrap_or(stdout.as_str()).trim().to_string();
1163                            self.version = Some(SemanticVersion::from(version.as_ref()));
1164                            debug!(
1165                                path = path.to_absolute_path(),
1166                                "=> {} System {} (v{version}) command",
1167                                Label::using(),
1168                                name.green().bold(),
1169                            );
1170                        }
1171                        | Err(why) => {
1172                            error!("=> {} Resolve {name} version — {why}", Label::fail());
1173                        }
1174                    }
1175                }
1176                | Err(why) => {
1177                    error!("=> {} Resolve {name} binary — {why}", Label::fail());
1178                }
1179            }
1180        }
1181        self
1182    }
1183    fn with_version(mut self, value: String) -> Self {
1184        self.version = Some(SemanticVersion::from(value.as_ref()));
1185        self
1186    }
1187}
1188#[async_trait]
1189impl StaticAnalyzerConfig for ValeConfig {
1190    async fn ini(self) -> Ini {
1191        let ValeConfig {
1192            packages,
1193            vocabularies,
1194            disabled,
1195            ..
1196        } = self;
1197        let package_names = packages.clone();
1198        let mut conf = Ini::new();
1199        let package_repository = Repository::GitLab {
1200            id: None,
1201            location: Location::Simple("https://code.ornl.gov/research-enablement/vale-package".to_string()),
1202        };
1203        let package_url = match package_repository.latest_release().await {
1204            | Some(release) => {
1205                let tag = release.tag_name;
1206                format!("https://code.ornl.gov/research-enablement/vale-package/-/archive/{tag}/vale-package-{tag}.zip")
1207            }
1208            | None => DEFAULT_VALE_PACKAGE_URL.to_string(),
1209        };
1210        let package_sources = package_names
1211            .iter()
1212            .map(Self::resolve_package)
1213            .chain(core::iter::once(package_url))
1214            .collect::<Vec<String>>();
1215        // CAUTION: Order of attributes in INI file matter. "StylesPath" must come before "Vocab"
1216        conf.with_section::<String>(None)
1217            .set("StylesPath", "styles")
1218            .set("Vocab", vocabularies.join(", "))
1219            .set("Packages", package_sources.join(", "));
1220        conf.with_section(Some("*")).set(
1221            "BasedOnStyles",
1222            format!("Vale, {}, {}", CUSTOM_VALE_PACKAGE_NAME, package_names.join(", ")),
1223        );
1224        disabled.iter().for_each(|rule| {
1225            conf.with_section(Some("*")).set(rule, "NO");
1226        });
1227        conf
1228    }
1229    fn resolve_package(value: impl AsRef<str>) -> String {
1230        let value = value.as_ref().trim();
1231        if is_uri_or_path(value) {
1232            value.to_string()
1233        } else {
1234            format!("https://github.com/errata-ai/{value}/releases/latest/download/{value}.zip")
1235        }
1236    }
1237    async fn save(self) -> ValeConfig {
1238        let path = self.clone().path;
1239        let parent = path.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
1240        match create_dir_all(parent.clone()) {
1241            | Ok(_) => {}
1242            | Err(why) => error!(directory = parent.to_absolute_path(), "=> {} Create — {why}", Label::fail()),
1243        }
1244        match self.clone().ini().await.write_to_file(path.clone()) {
1245            | Ok(_) => {
1246                debug!(path = path.to_absolute_path(), "=> {} Saved configuration", Label::using());
1247            }
1248            | Err(why) => {
1249                error!("=> {} Save configuration — {why}", Label::fail());
1250            }
1251        }
1252        self
1253    }
1254    fn with_path(mut self, path: PathBuf) -> Self {
1255        self.path = path;
1256        self
1257    }
1258}
1259pub(crate) fn collect_validation_checks<T>(value: &T) -> Vec<Check>
1260where
1261    T: Validate,
1262{
1263    value
1264        .validate()
1265        .err()
1266        .into_iter()
1267        .flat_map(|err| {
1268            let kind = ValidationErrorsKind::Struct(Box::new(err));
1269            process("", &kind).into_iter().map(|issue| {
1270                let validator_error = validator::ValidationError {
1271                    code: issue.code.to_string().into(),
1272                    message: Some(issue.message.clone().into()),
1273                    params: issue.params.clone().into_iter().map(|(key, value)| (key.into(), value)).collect(),
1274                };
1275                let errors = ValidationErrorsKind::Field(vec![validator_error]);
1276                let prefix = issue.path.clone().unwrap_or_else(|| issue.locator());
1277                check_err!(
1278                    CheckCategory::Schema,
1279                    locator: issue.locator(),
1280                    context: prefix,
1281                    message: issue.message,
1282                    errors: ErrorKind::Validator(errors)
1283                )
1284            })
1285        })
1286        .collect()
1287}
1288/// Convert Lychee response to [`Check`]
1289pub(crate) fn convert_lychee_response(value: Response) -> Check {
1290    let body = value.body().to_string();
1291    let url = value.source().to_string();
1292    debug!(url, "=> {} Response: {:#?}", Label::using(), body.dimmed().cyan());
1293    let status_code = value.status().code().map(|c| c.to_string()).unwrap_or_default();
1294    match value.status() {
1295        | Status::Ok(_) | Status::Redirected(_, _) => check!(
1296            CheckCategory::Link,
1297            true,
1298            severity: CheckSeverity::Info,
1299            status_code: status_code.clone(),
1300            message: "Has no HTTP errors"
1301        ),
1302        | Status::Cached(status) => match status {
1303            | CacheStatus::Ok(_) => check!(
1304                CheckCategory::Link,
1305                true,
1306                severity: CheckSeverity::Info,
1307                status_code: status_code.clone(),
1308                message: "Has no HTTP errors"
1309            ),
1310            | CacheStatus::Error(Some(_)) => check_err!(
1311                CheckCategory::Link,
1312                status_code: status_code.clone(),
1313                message: "Has cached HTTP errors"
1314            ),
1315            | CacheStatus::Unsupported => check!(
1316                CheckCategory::Link,
1317                false,
1318                severity: CheckSeverity::Warning,
1319                status_code: status_code.clone(),
1320                message: "Unsupported cached response"
1321            ),
1322            | _ => check!(
1323                CheckCategory::Link,
1324                true,
1325                severity: CheckSeverity::Suggestion,
1326                status_code: status_code.clone(),
1327                message: "Ignored or otherwise successful (cached response)"
1328            ),
1329        },
1330        | Status::Error(_) => check_err!(
1331            CheckCategory::Link,
1332            status_code: status_code.clone(),
1333            message: "Has HTTP errors"
1334        ),
1335        | Status::Unsupported(why) => check!(
1336            CheckCategory::Link,
1337            false,
1338            severity: CheckSeverity::Warning,
1339            status_code: status_code.clone(),
1340            message: format!("Unsupported HTTP response — {why}")
1341        ),
1342        | Status::UnknownStatusCode(_) => check!(
1343            CheckCategory::Link,
1344            false,
1345            severity: CheckSeverity::Warning,
1346            status_code: status_code.clone(),
1347            message: "Unknown HTTP response"
1348        ),
1349        | Status::Timeout(_) => check!(
1350            CheckCategory::Link,
1351            false,
1352            severity: CheckSeverity::Warning,
1353            status_code: status_code.clone(),
1354            message: "HTTP timeout"
1355        ),
1356        | _ => check!(
1357            CheckCategory::Link,
1358            true,
1359            severity: CheckSeverity::Suggestion,
1360            status_code: status_code.clone(),
1361            message: "Ignored or otherwise successful"
1362        ),
1363    }
1364}
1365/// Perform link check on given URL using Lychee
1366pub fn link_check<'a, T>(uri: Option<T>, locator: Option<String>) -> BoxFuture<'a, Check>
1367where
1368    T: Into<String> + Send + 'a,
1369{
1370    async move {
1371        match uri {
1372            | Some(value) => {
1373                let context = value.into();
1374                let result = lychee_lib::check(context.as_str()).await;
1375                match result {
1376                    | Ok(response) => convert_lychee_response(response).with_context(context).with_locator(locator),
1377                    | Err(_) => check_err!(CheckCategory::Link, context: context, message: "Unreachable").with_locator(locator),
1378                }
1379            }
1380            | None => check_err!(CheckCategory::Link, message: "Missing URL").with_locator(locator),
1381        }
1382    }
1383    .boxed()
1384}
1385/// Prints `text` to stdout using syntax highlighting for the specified `syntax`.
1386///
1387/// `highlight` is an iterator of line numbers to highlight in the output.
1388#[cfg(feature = "analysis")]
1389pub fn pretty_print<I: IntoIterator<Item = usize>>(text: &str, syntax: ProgrammingLanguage, highlight: I) {
1390    let input = format!("{text}\n");
1391    let language = syntax.to_string();
1392    let mut printer = PrettyPrinter::new();
1393    printer
1394        .input_from_bytes(input.as_bytes())
1395        .theme("zenburn")
1396        .language(&language)
1397        .line_numbers(true);
1398    for line in highlight {
1399        printer.highlight(line);
1400    }
1401    #[allow(clippy::unwrap_used)]
1402    printer.print().unwrap();
1403}
1404/// Convert vector of values of a given type to a Polars [DataFrame]
1405/// ### Example
1406/// ```ignore
1407/// let df = to_dataframe::<i32, _, str>(vec![1, 2, 3], ["a", "b", "c"]);
1408/// ```
1409///
1410/// [DataFrame]: https://docs.rs/polars/latest/polars/prelude/struct.DataFrame.html
1411#[cfg(feature = "analysis")]
1412pub fn to_dataframe<'a, T, I, H>(values: Vec<T>, names: I) -> PolarsResult<DataFrame>
1413where
1414    T: IntoRow<'a>,
1415    H: Into<PlSmallStr>,
1416    I: IntoIterator<Item = H>,
1417{
1418    let rows = values.into_iter().map(|value| value.to_row::<T>()).collect::<Vec<_>>();
1419    match DataFrame::from_rows(&rows) {
1420        | Ok(mut df) => match df.set_column_names(&names.into_iter().map(Into::into).collect::<Vec<PlSmallStr>>()) {
1421            | Ok(_) => Ok(df),
1422            | Err(why) => Err(why),
1423        },
1424        | Err(why) => Err(why),
1425    }
1426}
1427
1428#[cfg(test)]
1429mod tests;