init
This commit is contained in:
commit
2844e68a88
6 changed files with 1805 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
1496
Cargo.lock
generated
Normal file
1496
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
|
@ -0,0 +1,19 @@
|
|||
[package]
|
||||
name = "mdlint"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.4.7", features = ["cargo"] }
|
||||
console = "0.15.7"
|
||||
jsonschema = "0.17.1"
|
||||
regex = "1.10.2"
|
||||
serde = "1.0.190"
|
||||
serde_json = "1.0.107"
|
||||
serde_yaml = "0.9.27"
|
8
README.md
Normal file
8
README.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
# mdlint
|
||||
|
||||
Validate markdown frontmatter using [JSON Schemas](https://json-schema.org)
|
||||
|
||||
## Usage
|
||||
```shell
|
||||
$ mdlint [OPTION] <SchemaFile> <MarkdownFile>
|
||||
```
|
10
src/args.rs
Normal file
10
src/args.rs
Normal file
|
@ -0,0 +1,10 @@
|
|||
use clap::{arg, command, ArgMatches};
|
||||
|
||||
pub fn get_args() -> ArgMatches {
|
||||
command!()
|
||||
.about("Lint markdown frontmatter using JSON Schema")
|
||||
.arg(arg!([schema] "Schema file used for validation").required(true))
|
||||
.arg(arg!([file] "File to validate").required(true))
|
||||
.arg(arg!(--greedy "Require all properties to be present even if they are not strictly required in schema").required(false))
|
||||
.get_matches()
|
||||
}
|
271
src/main.rs
Normal file
271
src/main.rs
Normal file
|
@ -0,0 +1,271 @@
|
|||
use console::Style;
|
||||
use jsonschema::{error::ValidationErrorKind, JSONSchema};
|
||||
|
||||
mod args;
|
||||
|
||||
trait ErrorExit {
|
||||
type T;
|
||||
|
||||
fn quit_on_error(self, msg: &str) -> Self::T;
|
||||
}
|
||||
|
||||
impl<T, E> ErrorExit for Result<T, E> {
|
||||
type T = T;
|
||||
|
||||
fn quit_on_error(self, msg: &str) -> T {
|
||||
self.map_or_else(
|
||||
|_| {
|
||||
eprintln!("{} {msg}", Style::new().red().apply_to("Error:"));
|
||||
std::process::exit(1);
|
||||
},
|
||||
|res| res,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// get frontmatter from markdown document
|
||||
#[must_use]
|
||||
pub fn get_frontmatter(markdown: &str) -> Option<String> {
|
||||
let frontmatter_regex = regex::Regex::new(r"(?s)^---\s*\n(.*?)\n---").unwrap();
|
||||
|
||||
frontmatter_regex.captures(markdown).and_then(|captures| {
|
||||
let frontmatter = captures.get(1).map(|m| m.as_str().to_string());
|
||||
|
||||
frontmatter
|
||||
})
|
||||
}
|
||||
|
||||
fn require_everything(schema: &mut serde_json::Value) {
|
||||
if let Some(schema_obj) = schema.as_object_mut() {
|
||||
if schema_obj.get("type").unwrap().as_str().unwrap() == "object" {
|
||||
let keys: Vec<_> = schema_obj
|
||||
.get("properties")
|
||||
.unwrap()
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.cloned()
|
||||
.collect();
|
||||
schema_obj.insert("required".to_owned(), keys.into());
|
||||
|
||||
for property in schema_obj
|
||||
.get_mut("properties")
|
||||
.unwrap()
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
{
|
||||
require_everything(property.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args = args::get_args();
|
||||
|
||||
let schema = args.get_one::<String>("schema").unwrap();
|
||||
let file = args.get_one::<String>("file").unwrap();
|
||||
|
||||
let mut schema: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(schema).quit_on_error("Could not read schema file"),
|
||||
)
|
||||
.quit_on_error("Could not deserialize schema");
|
||||
|
||||
if args.get_flag("greedy") {
|
||||
require_everything(&mut schema);
|
||||
}
|
||||
|
||||
let comp_schema = JSONSchema::options()
|
||||
.should_ignore_unknown_formats(false)
|
||||
.compile(&schema)
|
||||
.quit_on_error("Schema is not valid");
|
||||
|
||||
let frontmatter = serde_yaml::from_str(
|
||||
&get_frontmatter(
|
||||
&std::fs::read_to_string(file).quit_on_error("Could not read markdown file"),
|
||||
)
|
||||
.ok_or(0)
|
||||
.quit_on_error("Could not parse frontmatter"),
|
||||
)
|
||||
.quit_on_error("Frontmatter is no valid yaml");
|
||||
|
||||
let result = comp_schema.validate(&frontmatter);
|
||||
|
||||
if let Err(errors) = result {
|
||||
for error in errors {
|
||||
let instance = error.instance.clone();
|
||||
let path = error.instance_path.to_string();
|
||||
let path = if path.is_empty() { "/" } else { &path };
|
||||
|
||||
//println!("{file}: {:#?}", error);
|
||||
|
||||
let file = Style::new().blue().apply_to(file);
|
||||
let path = Style::new().bright().red().apply_to(path);
|
||||
|
||||
match error.kind {
|
||||
ValidationErrorKind::AdditionalItems { limit: _ } => todo!(),
|
||||
ValidationErrorKind::AdditionalProperties { unexpected } => {
|
||||
let unexpected = Style::new().yellow().apply_to(format!("{unexpected:?}"));
|
||||
println!("{file}: Found additional properties {unexpected} at {path}");
|
||||
}
|
||||
ValidationErrorKind::AnyOf => todo!(),
|
||||
ValidationErrorKind::BacktrackLimitExceeded { error: _ } => todo!(),
|
||||
ValidationErrorKind::Constant { expected_value } => {
|
||||
let instance = Style::new().yellow().apply_to(instance);
|
||||
let expected_value = Style::new().green().apply_to(expected_value);
|
||||
println!(
|
||||
"{file}: Value at {path} should be {expected_value} but is {instance}"
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::Contains => {
|
||||
println!(
|
||||
"{file}: Array at {path} does not contain at least one expected element"
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::ContentEncoding { content_encoding } => {
|
||||
let content_encoding = Style::new().green().apply_to(content_encoding);
|
||||
println!(
|
||||
"{file}: Value at {path} should have {content_encoding} content-encoding"
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::ContentMediaType { content_media_type } => {
|
||||
let content_media_type = Style::new().green().apply_to(content_media_type);
|
||||
println!("{file}: Value at {path} should have {content_media_type} content-media-type");
|
||||
}
|
||||
ValidationErrorKind::Enum { options } => {
|
||||
let options = Style::new().green().apply_to(options);
|
||||
let instance = Style::new().yellow().apply_to(instance);
|
||||
println!(
|
||||
"{file}: Value at {path} should be one of {options} but is {instance}"
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::ExclusiveMaximum { limit } => {
|
||||
println!(
|
||||
"{file}: Value at {path} should be below {} but is {}",
|
||||
Style::new().green().apply_to(limit),
|
||||
Style::new().yellow().apply_to(instance)
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::ExclusiveMinimum { limit } => {
|
||||
println!(
|
||||
"{file}: Value at {path} should be above {} but is {}",
|
||||
Style::new().green().apply_to(limit),
|
||||
Style::new().yellow().apply_to(instance)
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::FalseSchema => todo!(),
|
||||
ValidationErrorKind::FileNotFound { error: _ } => todo!(),
|
||||
ValidationErrorKind::Format { format } => {
|
||||
let format = Style::new().green().apply_to(format);
|
||||
let instance = Style::new().yellow().apply_to(instance);
|
||||
println!("{file}: Value at {path} does not comply with format '{format}' (Is {instance})");
|
||||
}
|
||||
ValidationErrorKind::FromUtf8 { error: _ } => todo!(),
|
||||
ValidationErrorKind::Utf8 { error: _ } => todo!(),
|
||||
ValidationErrorKind::JSONParse { error: _ } => todo!(),
|
||||
ValidationErrorKind::InvalidReference { reference: _ } => todo!(),
|
||||
ValidationErrorKind::InvalidURL { error: _ } => todo!(),
|
||||
ValidationErrorKind::MaxItems { limit } => {
|
||||
let limit = Style::new().green().apply_to(limit);
|
||||
println!("{file}: Array at {path} should have a maximum of {limit} elements but has {}", Style::new().yellow().apply_to(instance.as_array().unwrap().len()));
|
||||
}
|
||||
ValidationErrorKind::Maximum { limit } => {
|
||||
println!(
|
||||
"{file}: Value at {path} should be a maximum of {} but is {}",
|
||||
Style::new().green().apply_to(limit),
|
||||
Style::new().yellow().apply_to(instance)
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::MaxLength { limit } => {
|
||||
let limit = Style::new().green().apply_to(limit);
|
||||
println!(
|
||||
"{file}: Value at {path} should have maximum length of {limit} but has {}",
|
||||
Style::new()
|
||||
.yellow()
|
||||
.apply_to(instance.as_str().unwrap().len())
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::MaxProperties { limit } => {
|
||||
let limit = Style::new().green().apply_to(limit);
|
||||
println!("{file}: Value at {path} should have a maximum of {limit} properties but has {}", Style::new().yellow().apply_to(instance.as_object().unwrap().len()));
|
||||
}
|
||||
ValidationErrorKind::MinItems { limit } => {
|
||||
let limit = Style::new().green().apply_to(limit);
|
||||
println!("{file}: Array at {path} should have a minimum of {limit} elements but has {}", Style::new().yellow().apply_to(instance.as_array().unwrap().len()));
|
||||
}
|
||||
ValidationErrorKind::Minimum { limit } => {
|
||||
println!(
|
||||
"{file}: Value at {path} should be a minimum of {} but is {}",
|
||||
Style::new().green().apply_to(limit),
|
||||
Style::new().yellow().apply_to(instance)
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::MinLength { limit } => {
|
||||
let limit = Style::new().green().apply_to(limit);
|
||||
println!(
|
||||
"{file}: Value at {path} should have minimum length of {limit} but has {}",
|
||||
Style::new()
|
||||
.yellow()
|
||||
.apply_to(instance.as_str().unwrap().len())
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::MinProperties { limit } => {
|
||||
let limit = Style::new().green().apply_to(limit);
|
||||
println!("{file}: Value at {path} should have a minimum of {limit} properties but has {}", Style::new().yellow().apply_to(instance.as_object().unwrap().len()));
|
||||
}
|
||||
ValidationErrorKind::MultipleOf { multiple_of } => {
|
||||
let multiple_of = Style::new().green().apply_to(multiple_of);
|
||||
let instance = Style::new().yellow().apply_to(instance);
|
||||
println!("{file}: Value at {path} should be a multiple of {multiple_of} but is {instance}");
|
||||
}
|
||||
ValidationErrorKind::Not { schema: _ } => todo!(),
|
||||
ValidationErrorKind::OneOfMultipleValid => todo!(),
|
||||
ValidationErrorKind::OneOfNotValid => todo!(),
|
||||
ValidationErrorKind::Pattern { pattern } => {
|
||||
let pattern = Style::new().green().apply_to(format!("'{pattern}'"));
|
||||
let instance = Style::new().yellow().apply_to(instance);
|
||||
println!("{file}: Value at {path} does not match {pattern} (Is {instance})'");
|
||||
}
|
||||
ValidationErrorKind::PropertyNames { error } => {
|
||||
if let ValidationErrorKind::Pattern { ref pattern } = error.kind {
|
||||
let instance = Style::new().yellow().apply_to(error.instance.clone());
|
||||
let pattern = Style::new().green().apply_to(format!("'{pattern}'"));
|
||||
println!("{file}: Object at {path} property name {instance} does not match {pattern}");
|
||||
} else {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
ValidationErrorKind::Required { property } => {
|
||||
println!(
|
||||
"{file}: Required key {} missing at {}",
|
||||
Style::new().yellow().apply_to(property),
|
||||
path
|
||||
);
|
||||
}
|
||||
ValidationErrorKind::Schema => todo!(),
|
||||
ValidationErrorKind::Type { kind } => {
|
||||
let expected = Style::new().green().apply_to(match kind {
|
||||
jsonschema::error::TypeKind::Single(t) => format!("{t:?}").to_lowercase(),
|
||||
jsonschema::error::TypeKind::Multiple(_) => todo!(),
|
||||
});
|
||||
let actual = Style::new().yellow().apply_to(match *instance {
|
||||
serde_json::Value::Null => "null",
|
||||
serde_json::Value::Bool(_) => "bool",
|
||||
serde_json::Value::Number(_) => "number",
|
||||
serde_json::Value::String(_) => "string",
|
||||
serde_json::Value::Array(_) => "array",
|
||||
serde_json::Value::Object(_) => "object",
|
||||
});
|
||||
println!("{file}: Value at {path} should be {expected} but is {actual}");
|
||||
}
|
||||
ValidationErrorKind::UnevaluatedProperties { unexpected: _ } => todo!(),
|
||||
ValidationErrorKind::UniqueItems => {
|
||||
println!("{file}: Array at {path} has values which are not unique");
|
||||
}
|
||||
ValidationErrorKind::UnknownReferenceScheme { scheme: _ } => todo!(),
|
||||
ValidationErrorKind::Resolver { url: _, error: _ } => todo!(),
|
||||
}
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue