#![doc = include_str!("../README.md")]
extern crate alloc;
use alloc::collections::BTreeMap;
use globset::{Glob, GlobSet, GlobSetBuilder};
use schema_catalog::Catalog;
pub const CATALOG_URL: &str = "https://www.schemastore.org/api/json/catalog.json";
pub fn parse_catalog(value: serde_json::Value) -> Result<Catalog, serde_json::Error> {
schema_catalog::parse_catalog_value(value)
}
struct CompiledGlobSet {
set: GlobSet,
entries: Vec<(String, String)>,
}
impl CompiledGlobSet {
fn build(patterns: &[(String, String)]) -> Self {
let mut builder = GlobSetBuilder::new();
let mut entries = Vec::new();
for (pattern, url) in patterns {
if let Ok(glob) = Glob::new(pattern) {
builder.add(glob);
entries.push((url.clone(), pattern.clone()));
}
}
Self {
set: builder.build().unwrap_or_else(|_| GlobSet::empty()),
entries,
}
}
fn find_match(&self, path: &str, file_name: &str) -> Option<&str> {
let matches = self.set.matches(path);
if let Some(&idx) = matches.first() {
return Some(&self.entries[idx].0);
}
let matches = self.set.matches(file_name);
if let Some(&idx) = matches.first() {
return Some(&self.entries[idx].0);
}
None
}
fn find_match_detailed(&self, path: &str, file_name: &str) -> Option<(&str, &str)> {
let matches = self.set.matches(path);
if let Some(&idx) = matches.first() {
let (url, pat) = &self.entries[idx];
return Some((url, pat));
}
let matches = self.set.matches(file_name);
if let Some(&idx) = matches.first() {
let (url, pat) = &self.entries[idx];
return Some((url, pat));
}
None
}
}
#[derive(Debug, Clone)]
struct CatalogEntryInfo {
name: String,
description: String,
file_match: Vec<String>,
}
#[derive(Debug)]
pub struct SchemaMatch<'a> {
pub url: &'a str,
pub matched_pattern: &'a str,
pub file_match: &'a [String],
pub name: &'a str,
pub description: Option<&'a str>,
}
pub struct CompiledCatalog {
exact_filename: BTreeMap<String, String>,
extension_sets: BTreeMap<String, CompiledGlobSet>,
fallback_set: CompiledGlobSet,
url_to_name: BTreeMap<String, String>,
url_to_entry: BTreeMap<String, CatalogEntryInfo>,
}
fn is_bare_filename(pattern: &str) -> bool {
!pattern.contains('/')
&& !pattern.contains('*')
&& !pattern.contains('?')
&& !pattern.contains('[')
}
fn extract_extension(pattern: &str) -> Option<&str> {
let file_part = pattern.rsplit('/').next().unwrap_or(pattern);
let dot_pos = file_part.rfind('.')?;
let ext = &file_part[dot_pos..];
if ext.contains('*') || ext.contains('?') || ext.contains('[') {
return None;
}
let offset = pattern.len() - file_part.len() + dot_pos;
Some(&pattern[offset..])
}
impl CompiledCatalog {
pub fn compile(catalog: &Catalog) -> Self {
let mut exact_filename: BTreeMap<String, String> = BTreeMap::new();
let mut ext_patterns: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
let mut fallback_patterns: Vec<(String, String)> = Vec::new();
let mut url_to_name: BTreeMap<String, String> = BTreeMap::new();
let mut url_to_entry: BTreeMap<String, CatalogEntryInfo> = BTreeMap::new();
for schema in &catalog.schemas {
url_to_name
.entry(schema.url.clone())
.or_insert_with(|| schema.name.clone());
url_to_entry
.entry(schema.url.clone())
.or_insert_with(|| CatalogEntryInfo {
name: schema.name.clone(),
description: schema.description.clone(),
file_match: schema.file_match.clone(),
});
for pattern in &schema.file_match {
if pattern.starts_with('!') {
continue;
}
if is_bare_filename(pattern) {
exact_filename
.entry(pattern.clone())
.or_insert_with(|| schema.url.clone());
} else if let Some(ext) = extract_extension(pattern) {
ext_patterns
.entry(ext.to_ascii_lowercase())
.or_default()
.push((pattern.clone(), schema.url.clone()));
} else {
fallback_patterns.push((pattern.clone(), schema.url.clone()));
}
}
}
let extension_sets = ext_patterns
.into_iter()
.map(|(ext, patterns)| (ext, CompiledGlobSet::build(&patterns)))
.collect();
Self {
exact_filename,
extension_sets,
fallback_set: CompiledGlobSet::build(&fallback_patterns),
url_to_name,
url_to_entry,
}
}
pub fn find_schema(&self, path: &str, file_name: &str) -> Option<&str> {
if let Some(url) = self.exact_filename.get(file_name) {
return Some(url);
}
if let Some(dot_pos) = file_name.rfind('.') {
let ext = &file_name[dot_pos..];
if let Some(compiled) = self.extension_sets.get(&ext.to_ascii_lowercase())
&& let Some(url) = compiled.find_match(path, file_name)
{
return Some(url);
}
}
self.fallback_set.find_match(path, file_name)
}
pub fn find_schema_detailed<'a>(
&'a self,
path: &str,
file_name: &'a str,
) -> Option<SchemaMatch<'a>> {
if let Some(url) = self.exact_filename.get(file_name)
&& let Some(entry) = self.url_to_entry.get(url.as_str())
{
return Some(SchemaMatch {
url,
matched_pattern: file_name,
file_match: &entry.file_match,
name: &entry.name,
description: if entry.description.is_empty() {
None
} else {
Some(&entry.description)
},
});
}
if let Some(dot_pos) = file_name.rfind('.') {
let ext = &file_name[dot_pos..];
if let Some(compiled) = self.extension_sets.get(&ext.to_ascii_lowercase())
&& let Some((url, pattern)) = compiled.find_match_detailed(path, file_name)
&& let Some(entry) = self.url_to_entry.get(url)
{
return Some(SchemaMatch {
url,
matched_pattern: pattern,
file_match: &entry.file_match,
name: &entry.name,
description: if entry.description.is_empty() {
None
} else {
Some(&entry.description)
},
});
}
}
if let Some((url, pattern)) = self.fallback_set.find_match_detailed(path, file_name)
&& let Some(entry) = self.url_to_entry.get(url)
{
return Some(SchemaMatch {
url,
matched_pattern: pattern,
file_match: &entry.file_match,
name: &entry.name,
description: if entry.description.is_empty() {
None
} else {
Some(&entry.description)
},
});
}
None
}
pub fn schema_name(&self, url: &str) -> Option<&str> {
self.url_to_name.get(url).map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use schema_catalog::SchemaEntry;
use super::*;
fn test_catalog() -> Catalog {
Catalog {
version: 1,
schemas: vec![
SchemaEntry {
name: "tsconfig".into(),
description: String::new(),
url: "https://json.schemastore.org/tsconfig.json".into(),
source_url: None,
file_match: vec!["tsconfig.json".into(), "tsconfig.*.json".into()],
versions: BTreeMap::new(),
},
SchemaEntry {
name: "package.json".into(),
description: String::new(),
url: "https://json.schemastore.org/package.json".into(),
source_url: None,
file_match: vec!["package.json".into()],
versions: BTreeMap::new(),
},
SchemaEntry {
name: "no-match".into(),
description: String::new(),
url: "https://example.com/no-match.json".into(),
source_url: None,
file_match: vec![],
versions: BTreeMap::new(),
},
],
..Catalog::default()
}
}
#[test]
fn compile_and_match_basename() {
let catalog = test_catalog();
let compiled = CompiledCatalog::compile(&catalog);
assert_eq!(
compiled.find_schema("tsconfig.json", "tsconfig.json"),
Some("https://json.schemastore.org/tsconfig.json")
);
}
#[test]
fn compile_and_match_with_path() {
let catalog = test_catalog();
let compiled = CompiledCatalog::compile(&catalog);
assert_eq!(
compiled.find_schema("project/tsconfig.json", "tsconfig.json"),
Some("https://json.schemastore.org/tsconfig.json")
);
}
#[test]
fn compile_and_match_glob_pattern() {
let catalog = test_catalog();
let compiled = CompiledCatalog::compile(&catalog);
assert_eq!(
compiled.find_schema("tsconfig.build.json", "tsconfig.build.json"),
Some("https://json.schemastore.org/tsconfig.json")
);
}
#[test]
fn no_match_returns_none() {
let catalog = test_catalog();
let compiled = CompiledCatalog::compile(&catalog);
assert!(
compiled
.find_schema("unknown.json", "unknown.json")
.is_none()
);
}
#[test]
fn empty_file_match_skipped() {
let catalog = test_catalog();
let compiled = CompiledCatalog::compile(&catalog);
assert!(
compiled
.find_schema("no-match.json", "no-match.json")
.is_none()
);
}
fn github_workflow_catalog() -> Catalog {
Catalog {
version: 1,
schemas: vec![SchemaEntry {
name: "GitHub Workflow".into(),
description: String::new(),
url: "https://www.schemastore.org/github-workflow.json".into(),
source_url: None,
file_match: vec![
"**/.github/workflows/*.yml".into(),
"**/.github/workflows/*.yaml".into(),
],
versions: BTreeMap::new(),
}],
..Catalog::default()
}
}
#[test]
fn github_workflow_matches_relative_path() {
let catalog = github_workflow_catalog();
let compiled = CompiledCatalog::compile(&catalog);
assert_eq!(
compiled.find_schema(".github/workflows/ci.yml", "ci.yml"),
Some("https://www.schemastore.org/github-workflow.json")
);
}
#[test]
fn github_workflow_matches_dot_slash_prefix() {
let catalog = github_workflow_catalog();
let compiled = CompiledCatalog::compile(&catalog);
assert_eq!(
compiled.find_schema("./.github/workflows/ci.yml", "ci.yml"),
Some("https://www.schemastore.org/github-workflow.json")
);
}
#[test]
fn github_workflow_matches_nested() {
let catalog = github_workflow_catalog();
let compiled = CompiledCatalog::compile(&catalog);
assert_eq!(
compiled.find_schema("myproject/.github/workflows/deploy.yaml", "deploy.yaml"),
Some("https://www.schemastore.org/github-workflow.json")
);
}
#[test]
fn parse_catalog_from_json() -> anyhow::Result<()> {
let json = r#"{"version":1,"schemas":[{"name":"test","description":"desc","url":"https://example.com/s.json","fileMatch":["*.json"]}]}"#;
let value: serde_json::Value = serde_json::from_str(json)?;
let catalog = parse_catalog(value)?;
assert_eq!(catalog.schemas.len(), 1);
assert_eq!(catalog.schemas[0].name, "test");
Ok(())
}
}