1use crate::analyzer::vale::{ValeOutput, ValeOutputItem};
6use crate::constants::{
7 APPLICATION, CUSTOM_VALE_PACKAGE_NAME, DEFAULT_VALE_PACKAGE_URL, DEFAULT_VALE_ROOT, DISABLED_VALE_RULES, ENABLED_VALE_PACKAGES, ORGANIZATION,
8 VALE_RELEASES_URL, VALE_VERSION,
9};
10use crate::schema::ProgrammingLanguage;
11use crate::util::*;
12use crate::{Location, Repository};
13use bat::PrettyPrinter;
14use bon::Builder;
15use color_eyre::owo_colors::OwoColorize;
16use convert_case::{Case, Casing};
17use derive_more::Display;
18use duct::cmd;
19use flate2::read::GzDecoder;
20use ini::Ini;
21use lychee_lib::{CacheStatus, Response, Status};
22use polars::datatypes::PlSmallStr;
23use polars::frame::row::Row;
24use polars::prelude::{AnyValue, DataFrame, PolarsResult};
25use std::collections::HashMap;
26use std::fs::File;
27use std::fs::{create_dir_all, remove_file};
28use std::io::prelude::*;
29use std::path::PathBuf;
30use tar::Archive;
31use tracing::{debug, error, info, trace, warn};
32use validator::ValidationErrorsKind;
33use which::which;
34
35pub mod readability;
36pub mod vale;
37
38use readability::ReadabilityType;
39use vale::{Vale, ValeConfig};
40
41pub trait IntoRow<'a> {
45 fn to_row<T>(self) -> Row<'a>;
47}
48pub trait StaticAnalyzer<Config: StaticAnalyzerConfig> {
50 fn command(self) -> String;
52 fn download(self, config: Option<Config>, skip_verify_checksum: bool) -> Self;
54 fn download_checksums(self) -> Result<HashMap<String, String>, String>;
56 fn extract(self, path: PathBuf, destination: Option<PathBuf>) -> PathBuf;
58 fn resolve(_config: Config, _is_offline: bool, _skip_verify_checksum: bool) -> Self;
60 fn run(&self, id: String, content: String, output: Option<String>) -> Check;
62 fn sync(self, is_offline: bool) -> Result<(), std::io::Error>;
64 fn with_binary<P>(self, path: P) -> Self
66 where
67 P: Into<PathBuf>;
68 fn with_config(self, value: Config) -> Self;
70 fn with_system_command(self) -> Self;
72 fn with_version(self, value: String) -> Self;
74}
75pub trait StaticAnalyzerConfig {
77 fn default() -> Self;
79 fn ini(self) -> Ini;
81 fn save(self) -> Self;
83 fn with_path(self, path: PathBuf) -> Self;
85}
86#[derive(Clone, Debug, Display, PartialEq)]
88pub enum CheckCategory {
89 #[display("link")]
91 Link,
92 #[display("prose")]
94 Prose,
95 #[display("readability")]
97 Readability,
98 #[display("schema")]
100 Schema,
101}
102#[derive(Clone, Debug)]
104pub enum ErrorKind {
105 Readability((f64, ReadabilityType)),
107 Vale(Vec<ValeOutputItem>),
109 Validator(ValidationErrorsKind),
113}
114#[derive(Builder, Clone, Debug, Display)]
116#[builder(start_fn = init)]
117#[display("{message}")]
118pub struct Check {
119 pub category: CheckCategory,
121 pub context: Option<String>,
123 #[builder(default = false)]
125 pub success: bool,
126 pub status_code: Option<String>,
128 pub errors: Option<ErrorKind>,
130 pub uri: Option<String>,
132 #[builder(default = "".to_string())]
134 pub message: String,
135}
136impl Check {
137 pub fn issue_count(&self) -> usize {
139 match self.category {
140 | CheckCategory::Link => 1,
141 | CheckCategory::Prose => {
142 if let Some(kind) = &self.errors {
143 match kind {
144 | ErrorKind::Vale(values) => values.len(),
145 | _ => 0,
146 }
147 } else {
148 0
149 }
150 }
151 | CheckCategory::Readability => 1,
152 | CheckCategory::Schema => {
153 if let Some(kind) = &self.errors {
154 match kind {
155 | ErrorKind::Validator(values) => match values {
156 | ValidationErrorsKind::Field(_) => 1,
157 | ValidationErrorsKind::Struct(values) => values.clone().into_errors().len(),
158 | ValidationErrorsKind::List(_) => 0,
159 },
160 | _ => 0,
161 }
162 } else {
163 0
164 }
165 }
166 }
167 }
168 pub fn print(self) {
170 match self.category {
171 | CheckCategory::Link => {
172 let code = match self.status_code {
173 | Some(code) => format!(" ({code})").dimmed().to_string(),
174 | None => "".to_string(),
175 };
176 let url = match self.uri {
177 | Some(value) => value.underline().italic().to_string(),
178 | None => "Missing".italic().to_string(),
179 };
180 if self.success {
181 let message = &self.message.to_case(Case::Title).green().bold().to_string();
182 info!("=> {} \"{url}\" {message}{code}", Label::valid());
183 } else {
184 let message = &self.message.to_case(Case::Title).red().bold().to_string();
185 error!("=> {} \"{url}\" {message}{code}", Label::invalid());
186 }
187 }
188 | CheckCategory::Prose => {
189 let Check {
190 context, errors, message, ..
191 } = self;
192 match &errors {
193 | Some(ErrorKind::Vale(values)) => {
194 error!("=> {} {} issues found in {}", Label::fail(), values.len(), message.underline());
195 for item in values {
196 let ValeOutputItem {
197 check,
198 line,
199 message,
200 severity,
201 span,
202 ..
203 } = item;
204 let location = format!("Line {}, Character {}", line, span[0]);
205 println!(" {:<24} {:<21} {} {}", location, severity.colored(), message, check.dimmed());
206 }
207 let highlight = values.clone().into_iter().map(|item| item.line as usize).collect::<Vec<_>>();
208 if let Some(content) = &context {
209 println!();
210 pretty_print(content, ProgrammingLanguage::Markdown, highlight);
211 println!("\n");
212 }
213 }
214 | None | Some(_) => {
215 let message = format!("=> {} {} has {}", Label::pass(), message.underline(), "no prose issues".green(),);
216 info!("{}", message);
217 }
218 }
219 }
220 | CheckCategory::Readability => {
221 let Check {
222 context, errors, message, ..
223 } = self;
224 match &errors {
225 | Some(ErrorKind::Readability(values)) => {
226 let (index, readability_type) = values;
227 error!(
228 "=> {} {} has {} value of {} (should be less than {})",
229 Label::fail(),
230 message,
231 readability_type.to_string().to_uppercase(),
232 index.red().bold(),
233 context.unwrap().cyan(),
234 );
235 }
236 | None | Some(_) => {
237 if let Some(context) = &context {
238 info!(
239 "=> {} {} has {} {}",
240 Label::pass(),
241 message,
242 "no readability issues".green().bold(),
243 context.dimmed()
244 );
245 }
246 }
247 }
248 }
249 | CheckCategory::Schema => {
250 let path = self.clone().uri.unwrap();
251 if self.success {
252 info!("=> {} {} has {}", Label::pass(), path, "no schema validation issues".green().bold());
253 } else {
254 let count = self.issue_count();
255 error!(
256 "=> {} Found {} schema validation issue{} in {}: \n{:#?}",
257 Label::fail(),
258 count.red(),
259 suffix(count),
260 path.italic().underline(),
261 self.errors.unwrap()
262 );
263 }
264 }
265 }
266 }
267 pub fn with_uri(self, value: String) -> Self {
269 Check::init()
270 .category(self.category)
271 .success(self.success)
272 .uri(value)
273 .message(self.message)
274 .maybe_status_code(self.status_code)
275 .maybe_errors(self.errors)
276 .build()
277 }
278}
279impl<'a> IntoRow<'a> for Check {
280 fn to_row<Check>(self) -> Row<'a> {
281 let Self {
282 success,
283 category,
284 message,
285 uri,
286 status_code,
287 context,
288 ..
289 } = self;
290 let data = [
291 if success { "pass" } else { "fail" },
292 &category.to_string(),
293 &message,
294 &uri.unwrap_or_default(),
295 &status_code.unwrap_or_default(),
296 &context.unwrap_or_default(),
297 ];
298 Row::new(data.into_iter().map(|x| AnyValue::String(x).into_static()).collect::<Vec<_>>())
299 }
300}
301impl StaticAnalyzer<ValeConfig> for Vale {
302 fn command(self) -> String {
303 "vale".to_string()
304 }
305 fn resolve(config: ValeConfig, is_offline: bool, skip_verify_checksum: bool) -> Vale {
311 fn any_exist<S>(paths: Vec<S>) -> bool
312 where
313 S: Into<PathBuf>,
314 {
315 paths.into_iter().any(|s| s.into().exists())
316 }
317 let root = DEFAULT_VALE_ROOT;
318 let name = "vale";
319 let init = Vale::init().build();
320 let vale = if command_exists(name) {
321 init.with_config(config).with_system_command()
322 } else if is_offline || any_exist(vec![format!("{root}{name}"), format!("{root}{name}.exe")]) {
323 info!("=> {} Local {} binary", Label::using(), name.green().bold());
324 #[cfg(any(unix, target_os = "wasi", target_os = "redox"))]
325 {
326 init.with_config(config).with_binary(format!("{root}{name}"))
327 }
328 #[cfg(windows)]
329 {
330 init.with_config(config).with_binary(format!("{root}{name}.exe"))
331 }
332 } else {
333 init.download(Some(config), skip_verify_checksum)
334 };
335 vale
336 }
337 fn run(&self, id: String, content: String, output: Option<String>) -> Check {
338 let root = standard_project_folder("check", None);
339 match create_dir_all(root.clone()) {
340 | Ok(_) => {}
341 | Err(why) => error!(path = to_absolute_string(root.clone()), "=> {} Create - {}", Label::fail(), why),
342 }
343 let path = root.join(&id);
344 let mut file = match File::create(&path) {
345 | Ok(file) => file,
346 | Err(why) => panic!("=> {} Create file {} - {}", Label::fail(), path.display(), why),
347 };
348 file.write_all(content.as_bytes())
349 .expect("Unable to write to cache directory project file");
350 let binary = match &self.binary {
351 | Some(value) => value,
352 | None => {
353 error!("=> {} {} binary", Label::not_found(), self.clone().command());
354 std::process::exit(exitcode::UNAVAILABLE);
355 }
356 };
357 match &self.config {
358 | Some(config) => {
359 let result = match output {
360 | Some(value) => cmd!(
361 binary,
362 "--no-wrap",
363 "--config",
364 config.clone().path,
365 "--output",
366 value,
367 path.clone(),
368 "--ext",
369 ".md",
370 "--no-exit",
371 )
372 .read(),
373 | None => cmd!(
374 binary,
375 "--no-wrap",
376 "--config",
377 config.clone().path,
378 path.clone(),
379 "--ext",
380 ".md",
381 "--no-exit"
382 )
383 .read(),
384 };
385 match result {
386 | Ok(output) => {
387 let parsed = ValeOutput::parse(&output, path);
388 if parsed.is_empty() {
389 Check::init().category(CheckCategory::Prose).success(true).message(id).build()
390 } else {
391 Check::init()
392 .category(CheckCategory::Prose)
393 .success(false)
394 .message(id)
395 .errors(ErrorKind::Vale(parsed))
396 .context(content)
397 .build()
398 }
399 }
400 | Err(output) => {
401 error!("=> {} Analyze - {}", Label::fail(), output);
402 Check::init().category(CheckCategory::Prose).success(false).message(id).build()
403 }
404 }
405 }
406 | None => {
407 let title = self.clone().command().to_case(Case::Title);
408 error!("=> {} {} configuration", Label::not_found(), title);
409 std::process::exit(exitcode::UNAVAILABLE);
410 }
411 }
412 }
413 fn download(self, config: Option<ValeConfig>, skip_verify_checksum: bool) -> Vale {
414 let os = std::env::consts::OS.to_lowercase();
416 let platform = match os.as_str() {
417 | "linux" => "Linux_64-bit.tar.gz",
418 | "macos" | "apple" => "macOS_64-bit.tar.gz",
419 | "windows" => "Windows_64-bit.zip",
420 | _ => {
421 error!(os, "=> {}", Label::not_found());
422 std::process::exit(exitcode::UNAVAILABLE);
423 }
424 };
425 let release = match self.version {
426 | Some(value) => value,
427 | None => SemanticVersion::from_string(VALE_VERSION),
428 };
429 let url = format!("{VALE_RELEASES_URL}/download/v{release}/{}_{release}_{platform}", self.clone().command());
430 info!(url, "=> {} Vale release v{release}", Label::using());
431 let binary = match download_binary(&url, ".") {
432 | Ok(path) => {
433 if !skip_verify_checksum {
434 let dowloaded_checksum = match self.clone().download_checksums() {
435 | Ok(value) => value.get(platform).unwrap().to_string(),
436 | Err(_) => "".to_string(),
437 };
438 if let Some(calculated) = checksum(path.clone()) {
439 if !dowloaded_checksum.eq(&calculated) {
440 error!(dowloaded_checksum, calculated, "=> {}", Label::invalid());
441 let _cleanup = remove_file(path);
442 std::process::exit(exitcode::USAGE);
443 } else {
444 info!(checksum = dowloaded_checksum, "=> {} Checksum verification", Label::pass());
445 }
446 };
447 } else {
448 warn!("=> {} Checksum verification", Label::skip());
449 }
450 let destination = match config.clone() {
451 | Some(value) => value.path.parent().unwrap().to_path_buf(),
452 | None => PathBuf::from("./.vale/"),
453 };
454 let binary = self.clone().extract(path.clone(), Some(destination));
455 if make_executable(&binary) {
456 let _cleanup = remove_file(path);
457 Some(binary)
458 } else {
459 error!("=> {} {} not executable", Label::fail(), self.command());
460 None
461 }
462 }
463 | Err(error) => {
464 error!(error, url, "=> {} {} download", Label::fail(), self.command());
465 None
466 }
467 };
468 let builder = Vale::init().version(release).maybe_binary(binary);
469 match config {
470 | Some(value) => builder.config(value).build(),
471 | None => {
472 let config = ValeConfig::default();
473 builder.config(config).build()
474 }
475 }
476 }
477 fn download_checksums(self) -> Result<HashMap<String, String>, String> {
478 let release = match self.version {
479 | Some(value) => value,
480 | None => SemanticVersion::from_string(VALE_VERSION),
481 };
482 let url = format!(
483 "{VALE_RELEASES_URL}/download/v{release}/{}_{release}_checksums.txt",
484 self.clone().command()
485 );
486 let client = reqwest::blocking::Client::new();
487 let response = client.get(url).send().unwrap();
488 let content = response.text().unwrap();
489 let checksums = content.lines().clone().fold(HashMap::new(), |mut acc: HashMap<String, String>, line| {
490 let mut values = line.split(" ").collect::<Vec<&str>>();
491 let key = values.pop().unwrap()["vale_#.#.#_".len()..].to_string();
492 let value = values.pop().unwrap().to_string();
493 acc.insert(key, value);
494 acc
495 });
496 debug!(
497 "=> {} {} checksums {:#?}",
498 Label::using(),
499 self.command().to_case(Case::Title),
500 checksums.dimmed().cyan()
501 );
502 Ok(checksums)
503 }
504 fn extract(self, path: PathBuf, destination: Option<PathBuf>) -> PathBuf {
505 let command = self.clone().command();
506 let parent = match destination {
507 | Some(value) => to_absolute_string(value),
508 | None => format!("./.{command}/"),
509 };
510 match extension(&path).as_str() {
511 | "zip" => match extract_zip(path, Some(parent.into())) {
512 | Ok(value) => value.join(command),
513 | Err(why) => {
514 error!("=> {} {command} extract - {why}", Label::fail());
515 std::process::exit(exitcode::UNAVAILABLE);
516 }
517 },
518 | "gz" => {
519 let tar_gz = File::open(path).unwrap();
520 let tar = GzDecoder::new(tar_gz);
521 let mut archive = Archive::new(tar);
522 let message = format!("Unable to extract {command} binary");
523 archive.unpack(parent.clone()).expect(&message);
524 debug!(parent, "=> {} Extracted {command} binary", Label::using());
525 PathBuf::from(format!("{parent}/{command}"))
526 }
527 | _ => {
528 error!("=> {} {command} extract - Unsupported format", Label::fail());
529 std::process::exit(exitcode::UNAVAILABLE);
530 }
531 }
532 }
533 fn sync(self, is_offline: bool) -> Result<(), std::io::Error> {
534 let path = match self.binary {
535 | Some(value) => value,
536 | None => {
537 error!("=> {} {} binary", Label::not_found(), self.command());
538 std::process::exit(exitcode::UNAVAILABLE);
539 }
540 };
541 let config_path = self.config.unwrap().path;
542 let result = if is_offline {
543 todo!("Support pointing to local vale package files");
544 } else {
545 cmd!(path.clone(), "--config", config_path.clone(), "sync").run()
546 };
547 match result {
548 | Ok(_) => {
549 let parent = format!("{}/styles/config/vocabularies/{}", config_path.parent().unwrap().display(), APPLICATION);
550 debug!(parent, "=> {} Vocabularies", Label::using());
551 match create_dir_all(parent.clone()) {
552 | Ok(_) => {}
553 | Err(why) => error!(directory = parent, "=> {} Create - {why}", Label::fail()),
554 }
555 match File::create(format!("{parent}/accept.txt")) {
556 | Ok(mut file) => {
557 let acronyms = Constant::last_values("acronyms");
559 let partners = Constant::last_values("partners");
560 let sponsors = Constant::last_values("sponsors");
561 let words = Constant::read_lines("accept.txt");
562 let content = acronyms.chain(partners).chain(sponsors).chain(words).collect::<Vec<String>>().join("\n");
563 file.write_all(content.as_bytes()).expect("Unable to write to accept.txt");
564 }
565 | Err(why) => panic!("=> {} Create accept.txt - {}", Label::fail(), why),
566 }
567 match File::create(format!("{parent}/reject.txt")) {
568 | Ok(mut file) => {
569 let content = Constant::read_lines("reject.txt").join("\n");
570 file.write_all(content.as_bytes()).expect("Unable to write to reject.txt");
571 }
572 | Err(why) => panic!("=> {} Create reject.txt - {}", Label::fail(), why),
573 }
574 Ok(())
575 }
576 | Err(why) => {
577 error!(config = to_absolute_string(config_path), "=> {} Vale sync - {}", Label::fail(), why);
578 std::process::exit(exitcode::SOFTWARE);
579 }
580 }
581 }
582 fn with_binary<P>(mut self, path: P) -> Self
583 where
584 P: Into<PathBuf>,
585 {
586 self.binary = Some(path.into());
587 self
588 }
589 fn with_config(mut self, value: ValeConfig) -> Self {
590 self.config = Some(value);
591 self
592 }
593 fn with_system_command(mut self) -> Self {
594 let name = self.clone().command();
595 if command_exists(name.clone()) {
596 let path = which(name.clone()).unwrap().to_path_buf();
597 self.binary = Some(path.clone());
598 let offset = "vale version ".len();
599 let version = cmd!(name.clone(), "--version").read().unwrap()[offset..].to_string();
600 self.version = Some(SemanticVersion::from_string(&version));
601 debug!(
602 path = to_absolute_string(path),
603 "=> {} System {} (v{}) command",
604 Label::using(),
605 name.green().bold(),
606 version
607 );
608 }
609 self
610 }
611 fn with_version(mut self, value: String) -> Self {
612 self.version = Some(SemanticVersion::from_string(&value));
613 self
614 }
615}
616impl StaticAnalyzerConfig for ValeConfig {
617 fn default() -> Self {
618 let config = ValeConfig::init()
619 .packages(to_string(ENABLED_VALE_PACKAGES.to_vec()))
620 .vocabularies(to_string(vec![&ORGANIZATION.to_uppercase(), APPLICATION]))
621 .disabled(to_string(DISABLED_VALE_RULES.to_vec()))
622 .build();
623 trace!("=> {} Default - {:#?}", Label::using(), config.dimmed().cyan());
624 config
625 }
626 fn ini(self) -> Ini {
627 let ValeConfig {
628 packages,
629 vocabularies,
630 disabled,
631 ..
632 } = self;
633 let mut conf = Ini::new();
634 let package_repository = Repository::GitLab {
635 id: None,
636 location: Location::Simple("https://code.ornl.gov/research-enablement/vale-package".to_string()),
637 };
638 let package_url = match package_repository.latest_release() {
639 | Some(release) => {
640 let tag = release.tag_name;
641 format!("https://code.ornl.gov/research-enablement/vale-package/-/archive/{tag}/vale-package-{tag}.zip")
642 }
643 | None => DEFAULT_VALE_PACKAGE_URL.to_string(),
644 };
645 conf.with_section::<String>(None)
647 .set("StylesPath", "styles")
648 .set("Vocab", vocabularies.join(", "))
649 .set("Packages", format!("{}, {}", packages.join(", "), package_url));
650 conf.with_section(Some("*"))
651 .set("BasedOnStyles", format!("Vale, {}, {}", CUSTOM_VALE_PACKAGE_NAME, packages.join(", ")));
652 disabled.iter().for_each(|rule| {
653 conf.with_section(Some("*")).set(rule, "NO");
654 });
655 conf
656 }
657 fn save(self) -> ValeConfig {
658 let path = self.clone().path;
659 let parent = path.parent().unwrap().to_path_buf();
660 match create_dir_all(parent.clone()) {
661 | Ok(_) => {}
662 | Err(why) => error!(directory = parent.to_absolute_string(), "=> {} Create - {why}", Label::fail()),
663 }
664 match self.clone().ini().write_to_file(path.clone()) {
665 | Ok(_) => {
666 debug!(path = to_absolute_string(path), "=> {} Saved configuration", Label::using());
667 }
668 | Err(why) => {
669 error!("=> {} Save configuration - {why}", Label::fail());
670 std::process::exit(exitcode::SOFTWARE);
671 }
672 }
673 self
674 }
675 fn with_path(mut self, path: PathBuf) -> Self {
676 self.path = path;
677 self
678 }
679}
680pub fn convert_lychee_response(value: Response) -> Check {
682 match value.status() {
683 | Status::Ok(code) | Status::Redirected(code) => Check::init()
684 .category(CheckCategory::Link)
685 .success(true)
686 .status_code(code.to_string())
687 .message("has no HTTP errors".to_string())
688 .build(),
689 | Status::Cached(status) => match status {
690 | CacheStatus::Ok(code) => Check::init()
691 .category(CheckCategory::Link)
692 .success(true)
693 .status_code(code.to_string())
694 .message("has no HTTP errors".to_string())
695 .build(),
696 | CacheStatus::Error(Some(code)) => Check::init()
697 .category(CheckCategory::Link)
698 .success(false)
699 .status_code(code.to_string())
700 .message("has cached HTTP errors".to_string())
701 .build(),
702 | CacheStatus::Unsupported => Check::init()
703 .category(CheckCategory::Link)
704 .success(false)
705 .message("unsupported cached response".to_string())
706 .build(),
707 | _ => Check::init()
708 .category(CheckCategory::Link)
709 .success(true)
710 .message("ignored or otherwise successful (cached response)".to_string())
711 .build(),
712 },
713 | Status::Error(code) => Check::init()
714 .category(CheckCategory::Link)
715 .success(false)
716 .status_code(code.to_string())
717 .message("has HTTP errors".to_string())
718 .build(),
719 | Status::Unsupported(why) => Check::init()
720 .category(CheckCategory::Link)
721 .success(false)
722 .message(format!("unsupported HTTP response - {why}"))
723 .build(),
724 | Status::UnknownStatusCode(code) => Check::init()
725 .category(CheckCategory::Link)
726 .success(false)
727 .status_code(code.to_string())
728 .message("unknown HTTP response".to_string())
729 .build(),
730 | Status::Timeout(_) => Check::init()
731 .category(CheckCategory::Link)
732 .success(false)
733 .message("HTTP timeout".to_string())
734 .build(),
735 | _ => Check::init()
736 .category(CheckCategory::Link)
737 .success(true)
738 .message("ignored or otherwise successful".to_string())
739 .build(),
740 }
741}
742pub async fn link_check(uri: Option<String>) -> Check {
744 match uri {
745 | Some(value) => {
746 let result = lychee_lib::check(value.as_str()).await;
747 match result {
748 | Ok(response) => convert_lychee_response(response).with_uri(value),
749 | Err(_) => Check::init()
750 .category(CheckCategory::Link)
751 .success(false)
752 .uri(value)
753 .message("unreachable".to_string())
754 .build(),
755 }
756 }
757 | None => Check::init()
758 .category(CheckCategory::Link)
759 .success(false)
760 .message("missing URL".to_string())
761 .build(),
762 }
763}
764pub fn checks_to_dataframe(values: Vec<Check>) -> PolarsResult<DataFrame> {
766 let names = ["success", "category", "message", "uri", "status_code", "context"];
767 to_dataframe::<Check, _, &str>(values, names)
768}
769pub fn pretty_print<I: IntoIterator<Item = usize>>(text: &str, syntax: ProgrammingLanguage, highlight: I) {
773 let input = format!("{text}\n");
774 let language = syntax.to_string();
775 let mut printer = PrettyPrinter::new();
776 printer
777 .input_from_bytes(input.as_bytes())
778 .theme("zenburn")
779 .language(&language)
780 .line_numbers(true);
781 for line in highlight {
782 printer.highlight(line);
783 }
784 printer.print().unwrap();
785}
786pub fn summary(issues: Vec<Check>) -> Vec<Vec<String>> {
788 [
789 CheckCategory::Schema,
790 CheckCategory::Link,
791 CheckCategory::Prose,
792 CheckCategory::Readability,
793 ]
794 .iter()
795 .map(|category| {
796 let count = issues
797 .iter()
798 .filter(|issue| issue.category == *category)
799 .map(|issue| issue.issue_count())
800 .sum::<usize>()
801 .to_string();
802 to_string(vec![&category.to_string(), &count])
803 })
804 .collect::<Vec<_>>()
805}
806pub fn to_dataframe<'a, T, I, H>(values: Vec<T>, names: I) -> PolarsResult<DataFrame>
814where
815 T: IntoRow<'a>,
816 H: Into<PlSmallStr>,
817 I: IntoIterator<Item = H>,
818{
819 let rows = values.into_iter().map(|value| value.to_row::<T>()).collect::<Vec<_>>();
820 match DataFrame::from_rows(&rows) {
821 | Ok(mut df) => match df.set_column_names(names) {
822 | Ok(_) => Ok(df),
823 | Err(why) => Err(why),
824 },
825 | Err(why) => Err(why),
826 }
827}
828
829#[cfg(test)]
830mod tests;