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