use clap::{Args, Parser, Subcommand, ValueEnum};
use std::collections::{BTreeSet, HashMap};
use std::error::Error;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::{Duration, Instant};
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Parser)]
#[command(name = "shacl", about = "Formalism-first SHACL/SHACL-AF engine")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Version,
Inspect(InspectArgs),
Validate(ValidateArgs),
Infer(InferArgs),
Repair(RepairArgs),
}
#[derive(Args)]
struct RepairArgs {
#[arg(long, value_name = "SHAPES", required = true, action = clap::ArgAction::Append)]
shapes: Vec<String>,
#[arg(long, value_name = "DATA", action = clap::ArgAction::Append)]
data: Vec<String>,
#[arg(long)]
base: Option<String>,
#[arg(long, value_enum, default_value_t = RepairStage::Tree)]
stage: RepairStage,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long)]
no_infer: bool,
#[arg(long)]
apply: bool,
}
#[derive(Clone, Copy, ValueEnum)]
enum RepairStage {
Witness,
Tree,
Solve,
}
#[derive(Args)]
struct InferArgs {
#[arg(long, value_name = "SHAPES", required = true, action = clap::ArgAction::Append)]
shapes: Vec<String>,
#[arg(long, value_name = "DATA", action = clap::ArgAction::Append)]
data: Vec<String>,
#[arg(long)]
base: Option<String>,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long)]
profile: bool,
}
#[derive(Args)]
struct ValidateArgs {
#[arg(long, value_name = "SHAPES", required = true, action = clap::ArgAction::Append)]
shapes: Vec<String>,
#[arg(long, value_name = "DATA", action = clap::ArgAction::Append)]
data: Vec<String>,
#[arg(long)]
base: Option<String>,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long)]
report: bool,
#[arg(long)]
no_infer: bool,
#[arg(long, visible_alias = "graph-scope", value_enum, default_value_t = GraphMode::Union)]
graph_mode: GraphMode,
#[arg(long = "shape-name", visible_alias = "entry-shape", value_name = "IRI", action = clap::ArgAction::Append)]
entry_shape_names: Vec<String>,
#[arg(long, value_enum, default_value_t = SeverityLevel::Info)]
minimum_severity: SeverityLevel,
#[arg(long, value_name = "PATH")]
dump_data: Option<String>,
#[arg(long, value_name = "PATH")]
dump_shapes: Option<String>,
#[arg(long)]
profile: bool,
}
#[derive(Args)]
struct InspectArgs {
file: PathBuf,
#[arg(long, value_enum, default_value_t = Stage::Algebra)]
stage: Stage,
#[arg(long, value_enum, default_value_t = Format::Text)]
format: Format,
#[arg(long)]
base: Option<String>,
}
#[derive(Clone, Copy, ValueEnum)]
enum Stage {
Rdf,
Algebra,
Normalized,
Strata,
Plan,
Capability,
Access,
}
#[derive(Clone, Copy, ValueEnum)]
enum Format {
Text,
Json,
Dot,
}
#[derive(Clone, Copy, ValueEnum)]
enum GraphMode {
Data,
Union,
UnionAll,
}
#[derive(Clone, Copy, ValueEnum)]
enum SeverityLevel {
Info,
Warning,
Violation,
}
impl From<SeverityLevel> for shifty_algebra::Severity {
fn from(value: SeverityLevel) -> Self {
match value {
SeverityLevel::Info => Self::Info,
SeverityLevel::Warning => Self::Warning,
SeverityLevel::Violation => Self::Violation,
}
}
}
impl From<GraphMode> for shifty_engine::ValidationGraphMode {
fn from(mode: GraphMode) -> Self {
match mode {
GraphMode::Data => Self::Data,
GraphMode::Union => Self::Union,
GraphMode::UnionAll => Self::UnionAll,
}
}
}
fn main() -> ExitCode {
env_logger::init();
match run(Cli::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e}");
ExitCode::FAILURE
}
}
}
fn run(cli: Cli) -> Result<(), Box<dyn Error>> {
match cli.command {
Command::Version => {
println!("{VERSION}");
Ok(())
}
Command::Inspect(args) => inspect(args),
Command::Validate(args) => validate(args),
Command::Infer(args) => infer(args),
Command::Repair(args) => repair(args),
}
}
struct SourceBytes {
bytes: Vec<u8>,
content_type: Option<String>,
}
fn fetch_bytes(src: &str) -> Result<SourceBytes, Box<dyn Error>> {
if src.starts_with("http://") || src.starts_with("https://") {
let response = ureq::get(src).call()?;
let content_type = response
.headers()
.get("content-type")
.and_then(|value| value.to_str().ok())
.map(ToOwned::to_owned);
let mut bytes = Vec::new();
std::io::Read::read_to_end(&mut response.into_body().into_reader(), &mut bytes)?;
Ok(SourceBytes {
bytes,
content_type,
})
} else {
Ok(SourceBytes {
bytes: std::fs::read(src)?,
content_type: None,
})
}
}
struct SourceStat {
name: String,
triples: usize,
format: shifty_parse::RdfFormat,
}
fn load_sources(
sources: &[String],
base: Option<&str>,
) -> Result<shifty_parse::Loaded, Box<dyn Error>> {
load_sources_profiled(sources, base).map(|(loaded, _)| loaded)
}
fn load_sources_profiled(
sources: &[String],
base: Option<&str>,
) -> Result<(shifty_parse::Loaded, Vec<SourceStat>), Box<dyn Error>> {
let mut merged: Option<shifty_parse::Loaded> = None;
let mut stats = Vec::with_capacity(sources.len());
for src in sources {
let fetched = fetch_bytes(src)?;
let parsed_base = base.or_else(|| {
(src.starts_with("http://") || src.starts_with("https://")).then_some(src.as_str())
});
let (loaded, format) = shifty_parse::load_rdf_auto_with_format(
&fetched.bytes,
fetched.content_type.as_deref(),
Some(src),
parsed_base,
)?;
stats.push(SourceStat {
name: src.clone(),
triples: loaded.graph.len(),
format,
});
match merged.as_mut() {
None => merged = Some(loaded),
Some(m) => m.merge_from(&loaded),
}
}
let merged = merged.ok_or_else(|| Box::<dyn Error>::from("no sources provided"))?;
Ok((merged, stats))
}
fn input_profile_lines(kind: &str, stats: &[SourceStat], merged: usize) -> Vec<String> {
let mut lines = Vec::new();
match stats {
[] => return lines,
[one] => {
lines.push(format!(
"profile: {kind}: {} from {} [{}]",
plural(one.triples, "triple"),
one.name,
one.format
));
}
many => {
let sum: usize = many.iter().map(|s| s.triples).sum();
let overlap = if sum > merged {
format!(" ({} dropped as duplicate)", plural(sum - merged, "triple"))
} else {
String::new()
};
lines.push(format!(
"profile: {kind}: {} from {}{overlap}",
plural(merged, "triple"),
plural(many.len(), "source"),
));
for stat in many {
lines.push(format!(
" {}: {} [{}]",
stat.name,
plural(stat.triples, "triple"),
stat.format
));
}
}
}
lines
}
fn output_prefixes<'a>(
shapes: &'a shifty_parse::Loaded,
data: Option<&'a shifty_parse::Loaded>,
) -> Vec<(&'a str, &'a str)> {
let mut prefixes: Vec<(&str, &str)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for (name, iri) in shapes
.prefixes
.iter()
.chain(data.map(|d| d.prefixes.iter()).into_iter().flatten())
{
if seen.insert(name.as_str()) {
prefixes.push((name.as_str(), iri.as_str()));
}
}
for (name, iri) in [
("sh", "http://www.w3.org/ns/shacl#"),
("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"),
("xsd", "http://www.w3.org/2001/XMLSchema#"),
] {
if seen.insert(name) {
prefixes.push((name, iri));
}
}
prefixes
}
fn turtle_bytes(
graph: &oxrdf::Graph,
prefixes: &[(&str, &str)],
) -> Result<Vec<u8>, Box<dyn Error>> {
let mut ser = oxttl::TurtleSerializer::new();
for (name, iri) in prefixes {
ser = ser.with_prefix(*name, *iri)?;
}
Ok(graph
.iter()
.try_fold(ser.for_writer(Vec::new()), |mut s, triple| {
s.serialize_triple(triple).map(|()| s)
})?
.finish()?)
}
fn dump_graph(
dest: &str,
graph: &oxrdf::Graph,
prefixes: &[(&str, &str)],
) -> Result<(), Box<dyn Error>> {
let bytes = turtle_bytes(graph, prefixes)?;
if dest == "-" {
use std::io::Write;
std::io::stdout().write_all(&bytes)?;
} else {
std::fs::write(dest, &bytes).map_err(|e| format!("failed to write {dest}: {e}"))?;
}
Ok(())
}
fn print_profile(input_lines: &[String]) {
for line in input_lines {
println!("{line}");
}
if let Some(col) = shifty_engine::profile::take() {
col.print_summary();
}
}
fn profile_stage(lines: &mut Vec<String>, enabled: bool, stage: &str, elapsed: Duration) {
if enabled {
lines.push(format!(
"profile: stage: {stage}: {:.3} ms",
elapsed.as_secs_f64() * 1_000.0
));
}
}
fn infer(args: InferArgs) -> Result<(), Box<dyn Error>> {
if args.profile {
shifty_engine::profile::enable();
}
let base = args.base.as_deref();
let stage_start = Instant::now();
let (shapes, shape_stats) = load_sources_profiled(&args.shapes, base)?;
let shapes_load_time = stage_start.elapsed();
let stage_start = Instant::now();
let compiled = shifty_engine::CompiledShapes::compile(shapes)?;
let compile_time = stage_start.elapsed();
for d in compiled.diagnostics() {
eprintln!("{d}");
}
let mut input_lines =
input_profile_lines("shapes", &shape_stats, compiled.source().graph.len());
profile_stage(
&mut input_lines,
args.profile,
"shapes load",
shapes_load_time,
);
profile_stage(&mut input_lines, args.profile, "compile", compile_time);
let stage_start = Instant::now();
let data = if args.data.is_empty() {
input_lines
.push("profile: data: none given; the shapes graph is also the data graph".to_string());
None
} else {
let (data, data_stats) = load_sources_profiled(&args.data, base)?;
input_lines.extend(input_profile_lines("data", &data_stats, data.graph.len()));
Some(data)
};
profile_stage(
&mut input_lines,
args.profile,
"data load",
stage_start.elapsed(),
);
let session_data = data.map_or(shifty_engine::SessionData::Embedded, |data| {
shifty_engine::SessionData::Separate(data.graph)
});
let stage_start = Instant::now();
let session = match compiled.session(
session_data,
shifty_engine::SessionOptions {
inference: true,
..Default::default()
},
) {
Ok(session) => session,
Err(e) => return Err(format!("{e}; cannot infer (see `inspect --stage strata`)").into()),
};
profile_stage(
&mut input_lines,
args.profile,
"session and inference",
stage_start.elapsed(),
);
for d in session.diagnostics() {
eprintln!("warning: {}", d.message);
}
let stage_start = Instant::now();
match args.format {
Format::Dot => return Err("--format dot is not supported for infer".into()),
Format::Json => {
let triples: Vec<_> = session
.inferred()
.iter()
.map(|t| {
serde_json::json!({
"subject": t.subject.to_string(),
"predicate": t.predicate.to_string(),
"object": t.object.to_string(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&triples)?);
}
Format::Text => {
println!("inferred {} triple(s):", session.inferred().len());
let mut lines: Vec<String> = session.inferred().iter().map(|t| t.to_string()).collect();
lines.sort();
for line in lines {
println!(" {line}");
}
}
}
profile_stage(
&mut input_lines,
args.profile,
"export",
stage_start.elapsed(),
);
if args.profile {
print_profile(&input_lines);
}
Ok(())
}
fn json_report(
outcome: &shifty_engine::ValidationOutcome,
schema: &shifty_algebra::Schema,
plan: &shifty_opt::PhysicalPlan,
px: &shifty_algebra::Prefixes,
) -> Result<serde_json::Value, Box<dyn Error>> {
use serde_json::{Map, Value, json};
use shifty_algebra::render::{PRETTY_WIDTH, describe_shape_in, describe_shape_pretty};
let mut doc = serde_json::to_value(outcome)?;
let mut referenced: std::collections::BTreeSet<u32> = Default::default();
if let Some(violations) = doc.get_mut("violations").and_then(Value::as_array_mut) {
for (value, v) in violations.iter_mut().zip(&outcome.violations) {
let Some(object) = value.as_object_mut() else {
continue;
};
if let Some(statement) = schema.statements.get(v.statement) {
object.insert(
"target".into(),
json!(shifty_algebra::render::selector_to_string_in_px(
&statement.selector,
&schema.arena,
&schema.prefixes
)),
);
if let Some(name) = schema.name_of(statement.shape) {
object.insert("shape_name".into(), json!(name));
}
}
let reasons = object
.get_mut("reasons")
.and_then(Value::as_array_mut)
.map(|r| r.iter_mut().zip(&v.reasons));
for (value, r) in reasons.into_iter().flatten() {
let Some(object) = value.as_object_mut() else {
continue;
};
object.insert(
"definition".into(),
json!(describe_shape_in(&plan.arena, r.constraint_id, px)),
);
object.insert(
"definition_pretty".into(),
json!(describe_shape_pretty(
&plan.arena,
r.constraint_id,
px,
PRETTY_WIDTH
)),
);
collect_shapes(&plan.arena, r.constraint_id, &mut referenced);
}
}
}
let shapes: Map<String, Value> = referenced
.iter()
.map(|id| {
Ok((
id.to_string(),
serde_json::to_value(plan.arena.get(shifty_algebra::ShapeId(*id)))?,
))
})
.collect::<Result<_, serde_json::Error>>()?;
if let Some(object) = doc.as_object_mut() {
object.insert("shapes".into(), Value::Object(shapes));
}
Ok(doc)
}
fn collect_shapes(
arena: &shifty_algebra::ShapeArena,
id: shifty_algebra::ShapeId,
out: &mut std::collections::BTreeSet<u32>,
) {
if !out.insert(id.0) {
return;
}
for child in arena.get(id).child_shapes() {
collect_shapes(arena, child, out);
}
}
const NOTATION: &[(&str, &str, &str)] = &[
(
"โ",
"โ p . X",
"every value along p satisfies X (holds when there are none)",
),
(
"โ[",
"โ[m..n] p . X",
"between m and n values along p satisfy X",
),
("โ", "โ p", "no values along p at all"),
("^", "^p", "p followed backwards, from object to subject"),
("*", "p*", "p repeated zero or more times"),
];
fn notation_key(lines: &[String]) -> Vec<String> {
let used: Vec<(&str, &str)> = NOTATION
.iter()
.filter(|(symbol, ..)| {
lines
.iter()
.any(|line| line.replace("^^", "").contains(symbol))
})
.map(|(_, form, gloss)| (*form, *gloss))
.collect();
if used.is_empty() {
return Vec::new();
}
let width = used
.iter()
.map(|(form, _)| form.chars().count())
.max()
.unwrap_or(0);
let mut out = vec![String::new(), "notation".to_string()];
out.extend(used.into_iter().map(|(form, gloss)| {
let padding = " ".repeat(width - form.chars().count());
format!(" {form}{padding} {gloss}")
}));
out
}
fn plural(n: usize, word: &str) -> String {
if n == 1 {
format!("{n} {word}")
} else {
format!("{n} {word}s")
}
}
struct Finding {
statement: usize,
target: String,
severity: String,
shape: Option<String>,
body: Vec<String>,
members: Vec<(String, Option<String>)>,
}
fn related_findings(index: usize, findings: &[Finding]) -> String {
let current = &findings[index];
let mine: BTreeSet<&str> = current.members.iter().map(|(f, _)| f.as_str()).collect();
let mut parts = Vec::new();
for (other_index, other) in findings.iter().enumerate() {
if other_index == index || other.statement != current.statement {
continue;
}
let shared = other
.members
.iter()
.filter(|(focus, _)| mine.contains(focus.as_str()))
.count();
if shared == 0 {
continue;
}
let label = format!("Finding {}", other_index + 1);
parts.push(if shared == mine.len() {
label
} else {
format!("{label} ({shared} of {} nodes)", mine.len())
});
}
parts.join(", ")
}
fn render_affected(members: &[(String, Option<String>)]) -> Vec<String> {
if let [(focus, value)] = members {
let mut out = field(2, "affects", focus);
out.extend(field(
2,
"value node",
value.as_deref().unwrap_or("(the focus node itself)"),
));
return out;
}
let counted = plural(members.len(), "focus node");
let any_values = members.iter().any(|(_, value)| value.is_some());
let mut out = field(
2,
"affects",
&if any_values {
format!("{counted}, each with the value node that failed")
} else {
format!("{counted}; the constraint applies to each node itself")
},
);
let column = members
.iter()
.filter(|(_, value)| value.is_some())
.map(|(focus, _)| focus.chars().count())
.filter(|width| *width <= 56)
.max()
.unwrap_or(0);
out.extend(members.iter().map(|(focus, value)| match value {
Some(value) => {
let padding = " ".repeat(column.saturating_sub(focus.chars().count()));
format!(" {focus}{padding} {value}")
}
None => format!(" {focus}"),
}));
out
}
const LABEL_WIDTH: usize = 13;
fn field(indent: usize, label: &str, value: &str) -> Vec<String> {
let pad = " ".repeat(indent);
let hang = " ".repeat(indent + LABEL_WIDTH);
let width = shifty_algebra::render::PRETTY_WIDTH;
let mut out = Vec::new();
for (i, line) in wrap(value, width.saturating_sub(indent + LABEL_WIDTH))
.into_iter()
.enumerate()
{
if i == 0 {
out.push(format!("{pad}{label:<LABEL_WIDTH$}{line}"));
} else {
out.push(format!("{hang}{line}"));
}
}
if out.is_empty() {
out.push(format!("{pad}{label}"));
}
out
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for word in text.split_whitespace() {
match out.last_mut() {
Some(line) if line.chars().count() + 1 + word.chars().count() <= width => {
line.push(' ');
line.push_str(word);
}
_ => out.push(word.to_string()),
}
}
out
}
fn render_reason(
r: &shifty_engine::Reason,
arena: &shifty_algebra::ShapeArena,
px: &shifty_algebra::Prefixes,
focus: &str,
violation_severity: &str,
indent: usize,
hoist_value: Option<&mut Option<String>>,
) -> Vec<String> {
let requirement = describe_requirement(r, arena, px, indent + LABEL_WIDTH);
let mut lines = Vec::new();
if r.severity.to_string() != violation_severity {
lines.extend(field(indent, "severity", &r.severity.to_string()));
}
if let Some(author) = &r.author_message {
lines.extend(field(indent, "message", author));
}
lines.extend(field(indent, "failure", &r.message));
if let Some(path) = &r.path {
lines.extend(field(indent, "path", path));
}
let value = shifty_algebra::render::term_to_string_in(&r.value, px);
match hoist_value {
Some(slot) if value != focus => *slot = Some(value),
Some(_) => {}
None if value != focus => lines.extend(field(indent, "value node", &value)),
None => {}
}
if let Some(found) = found_line(r, arena) {
lines.extend(field(indent, "found", &found));
}
if let Some(requirement) = requirement {
lines.extend(labelled_block(indent, "requirement", &requirement));
}
if let Some(d) = &r.sparql_diagnostic {
lines.extend(render_sparql_diagnostic(d, indent + 2));
}
for (i, sub) in r.sub_reasons.iter().enumerate() {
lines.push(String::new());
lines.push(format!(
"{}or-branch {} of {} (satisfying any one of these fixes it)",
" ".repeat(indent + 2),
i + 1,
r.sub_reasons.len()
));
lines.extend(render_reason(
sub,
arena,
px,
focus,
violation_severity,
indent + 4,
None,
));
}
lines
}
fn labelled_block(indent: usize, label: &str, value: &str) -> Vec<String> {
let mut lines = value.lines();
let pad = " ".repeat(indent);
let hang = " ".repeat(indent + LABEL_WIDTH);
let mut out = match lines.next() {
Some(first) => vec![format!("{pad}{label:<LABEL_WIDTH$}{first}")],
None => return Vec::new(),
};
out.extend(lines.map(|line| format!("{hang}{line}")));
out
}
fn describe_requirement(
r: &shifty_engine::Reason,
arena: &shifty_algebra::ShapeArena,
px: &shifty_algebra::Prefixes,
indent: usize,
) -> Option<String> {
use shifty_algebra::render::{PRETTY_WIDTH, describe_shape_pretty};
let width = PRETTY_WIDTH.saturating_sub(indent);
let text = describe_shape_pretty(arena, r.constraint_id, px, width);
(!text.is_empty()).then_some(text)
}
fn found_line(r: &shifty_engine::Reason, arena: &shifty_algebra::ShapeArena) -> Option<String> {
let found = r.observed_count?;
let shifty_algebra::Shape::Count {
min,
max,
qualifier,
..
} = &r.constraint
else {
return Some(format!("{found} value(s)"));
};
let counted = match arena.get(*qualifier) {
shifty_algebra::Shape::Top | shifty_algebra::Shape::Pending => {
format!("{found} value(s) along the path")
}
_ => format!("{found} value(s) matching the requirement"),
};
let bound = match (min, max) {
(Some(m), _) if found < *m => format!("; at least {m} required"),
(_, Some(x)) if found > *x => format!("; at most {x} allowed"),
_ => String::new(),
};
Some(format!("{counted}{bound}"))
}
fn render_sparql_diagnostic(d: &shifty_engine::SparqlDiagnostic, indent: usize) -> Vec<String> {
let pad = " ".repeat(indent);
let mut lines = vec![format!("{pad}SPARQL:")];
lines.push(format!("{pad} Query:"));
for line in d.query.lines() {
lines.push(format!("{pad} {line}"));
}
if !d.bindings.is_empty() {
lines.push(format!("{pad} Bound:"));
for (k, v) in &d.bindings {
lines.push(format!("{pad} ${k} = {v}"));
}
}
if !d.results.is_empty() {
lines.push(format!("{pad} Results:"));
for (i, row) in d.results.iter().enumerate() {
if row.is_empty() {
lines.push(format!("{pad} [{}] (no projected variables)", i + 1));
continue;
}
let cols = row
.iter()
.map(|(k, v)| format!("?{k} = {v}"))
.collect::<Vec<_>>()
.join(", ");
lines.push(format!("{pad} [{}] {cols}", i + 1));
}
}
if let Some(reason) = &d.fallback_reason {
lines.push(format!("{pad} Did not use the native executor: {reason}"));
}
lines
}
fn validate(args: ValidateArgs) -> Result<(), Box<dyn Error>> {
if args.profile {
shifty_engine::profile::enable();
}
let base = args.base.as_deref();
let stage_start = Instant::now();
let (shapes_loaded, shape_stats) = load_sources_profiled(&args.shapes, base)?;
let shapes_load_time = stage_start.elapsed();
if shapes_loaded.graph.is_empty() {
return Err("explicit shapes graph is empty".into());
}
let stage_start = Instant::now();
let compiled = shifty_engine::CompiledShapes::compile(shapes_loaded)?;
let compile_time = stage_start.elapsed();
let shapes_loaded = compiled.source();
let authored = compiled.authored_schema();
for d in compiled.diagnostics() {
eprintln!("{d}");
}
let graph_mode = args.graph_mode.into();
let threshold: shifty_algebra::Severity = args.minimum_severity.into();
let finding_options = shifty_engine::FindingOptions {
minimum_severity: threshold.clone(),
sort_results: true,
entry_shape_names: args.entry_shape_names.clone(),
};
let mut input_lines = input_profile_lines("shapes", &shape_stats, shapes_loaded.graph.len());
profile_stage(
&mut input_lines,
args.profile,
"shapes load",
shapes_load_time,
);
profile_stage(&mut input_lines, args.profile, "compile", compile_time);
let stage_start = Instant::now();
let data_loaded = if args.data.is_empty() {
input_lines
.push("profile: data: none given; the shapes graph is also the data graph".to_string());
None
} else {
let (data, data_stats) = load_sources_profiled(&args.data, base)?;
input_lines.extend(input_profile_lines("data", &data_stats, data.graph.len()));
Some(data)
};
profile_stage(
&mut input_lines,
args.profile,
"data load",
stage_start.elapsed(),
);
let display_prefixes = shifty_algebra::Prefixes::merged([
data_loaded
.as_ref()
.map(|d| d.prefixes.clone())
.unwrap_or_default(),
shapes_loaded.prefixes.clone(),
]);
let stage_start = Instant::now();
let session_data = data_loaded
.as_ref()
.map_or(shifty_engine::SessionData::Embedded, |data| {
shifty_engine::SessionData::Separate(data.graph.clone())
});
let session = compiled
.session(
session_data,
shifty_engine::SessionOptions {
graph_mode,
inference: !args.no_infer,
engine: Default::default(),
},
)
.map_err(|e| format!("{e}; cannot prepare validation (see `inspect --stage strata`)"))?;
profile_stage(
&mut input_lines,
args.profile,
"session and inference",
stage_start.elapsed(),
);
if args.no_infer {
input_lines.push("profile: inference: skipped (--no-infer)".to_string());
} else {
for d in session.diagnostics() {
eprintln!("warning: {}", d.message);
}
input_lines.push(format!(
"profile: inference: {} added before validation",
plural(session.inferred().len(), "triple")
));
}
let prefixes = output_prefixes(shapes_loaded, data_loaded.as_ref());
if let Some(dest) = &args.dump_shapes {
dump_graph(dest, &shapes_loaded.graph, &prefixes)?;
}
if let Some(dest) = &args.dump_data {
dump_graph(dest, session.data(), &prefixes)?;
}
if args.report {
let stage_start = Instant::now();
let report = session.report(&finding_options);
profile_stage(
&mut input_lines,
args.profile,
"first report",
stage_start.elapsed(),
);
let stage_start = Instant::now();
let graph = shifty_engine::report_to_graph(&report);
let bytes = turtle_bytes(&graph, &prefixes)?;
print!("{}", String::from_utf8_lossy(&bytes));
profile_stage(
&mut input_lines,
args.profile,
"export",
stage_start.elapsed(),
);
if args.profile {
print_profile(&input_lines);
}
return Ok(());
}
let physical = compiled.physical_plan();
let stage_start = Instant::now();
let mut outcome = session.validate(&finding_options);
profile_stage(
&mut input_lines,
args.profile,
"first validation",
stage_start.elapsed(),
);
let stage_start = Instant::now();
outcome.violations.retain(|v| v.severity.meets(&threshold));
for v in &mut outcome.violations {
v.reasons.retain(|r| r.severity.meets(&threshold));
}
match args.format {
Format::Dot => return Err("--format dot is not supported for validate".into()),
Format::Json => {
let doc = json_report(&outcome, authored, physical, &display_prefixes)?;
println!("{}", serde_json::to_string_pretty(&doc)?);
}
Format::Text => {
let mut findings: Vec<Finding> = Vec::new();
let mut index: HashMap<(usize, String, String, String), usize> = HashMap::new();
for v in &outcome.violations {
let st = &authored.statements[v.statement];
let focus = shifty_algebra::render::term_to_string_in(&v.focus, &display_prefixes);
let target = shifty_algebra::render::selector_to_string_in_px(
&st.selector,
&authored.arena,
&authored.prefixes,
);
let shape = authored
.name_of(st.shape)
.map(|name| display_prefixes.compact(name));
for r in &v.reasons {
let severity = r.severity.to_string();
let mut value = None;
let body = render_reason(
r,
&physical.arena,
&display_prefixes,
&focus,
&severity,
2,
Some(&mut value),
);
let key = (
v.statement,
severity.clone(),
target.clone(),
body.join("\n"),
);
match index.get(&key) {
Some(at) => findings[*at].members.push((focus.clone(), value)),
None => {
index.insert(key, findings.len());
findings.push(Finding {
statement: v.statement,
target: target.clone(),
severity,
shape: shape.clone(),
body,
members: vec![(focus.clone(), value)],
});
}
}
}
}
let total = outcome.violations.len();
if outcome.conforms {
println!("conforms: true");
} else if findings.len() == total {
println!("conforms: false โ {}", plural(total, "violation"));
} else {
println!(
"conforms: false โ {} in {}",
plural(total, "violation"),
plural(findings.len(), "finding")
);
}
let mut out: Vec<String> = Vec::new();
for (i, finding) in findings.iter().enumerate() {
out.push(String::new());
out.push(format!("Finding {} of {}", i + 1, findings.len()));
out.extend(field(2, "target", &finding.target));
out.extend(field(2, "severity", &finding.severity));
if let Some(shape) = &finding.shape {
out.extend(field(2, "shape", shape));
}
out.extend(finding.body.iter().cloned());
out.push(String::new());
out.extend(render_affected(&finding.members));
let related = related_findings(i, &findings);
if !related.is_empty() {
out.extend(field(2, "also fails", &related));
}
}
for line in &out {
println!("{line}");
}
for line in notation_key(&out) {
println!("{line}");
}
}
}
profile_stage(
&mut input_lines,
args.profile,
"export",
stage_start.elapsed(),
);
if args.profile {
print_profile(&input_lines);
}
Ok(())
}
fn repair(args: RepairArgs) -> Result<(), Box<dyn Error>> {
let base = args.base.as_deref();
let compiled = shifty_engine::CompiledShapes::compile(load_sources(&args.shapes, base)?)?;
let shapes_loaded = compiled.source();
for d in compiled.diagnostics() {
eprintln!("{d}");
}
let schema = compiled.authored_schema();
let data_loaded = if args.data.is_empty() {
None
} else {
Some(load_sources(&args.data, base)?)
};
let session_data = data_loaded
.as_ref()
.map_or(shifty_engine::SessionData::Embedded, |data| {
shifty_engine::SessionData::Separate(data.graph.clone())
});
let session = compiled
.session(
session_data,
shifty_engine::SessionOptions {
inference: !args.no_infer,
..Default::default()
},
)
.map_err(|e| format!("{e}; cannot prepare repair (see `inspect --stage strata`)"))?;
for diagnostic in session.diagnostics() {
eprintln!("warning: {}", diagnostic.message);
}
let data_graph = session.data().clone();
let context = if data_loaded.is_some() {
shifty_engine::graph_union(&data_graph, &shapes_loaded.graph)
} else {
data_graph.clone()
};
if args.apply {
let result = match shifty_engine::repair_to_fixpoint(
&data_graph,
&context,
schema,
shifty_engine::EnumOptions::default(),
) {
Ok(r) => r,
Err(e) => {
return Err(format!("{e}; cannot repair (see `inspect --stage strata`)").into());
}
};
let mut lines: Vec<String> = result.graph.iter().map(|t| t.to_string()).collect();
lines.sort();
for line in lines {
println!("{line}");
}
eprintln!(
"repaired: applied {} repair(s) over {} iteration(s); {} violation(s) remain",
result.applied.len(),
result.iterations,
result.remaining,
);
return Ok(());
}
let witnesses = match shifty_engine::witness_violations(&data_graph, &context, schema) {
Ok(ws) => ws,
Err(e) => {
return Err(format!("{e}; cannot witness (see `inspect --stage strata`)").into());
}
};
if matches!(args.format, Format::Dot) {
return Err("--format dot is not supported for repair".into());
}
let target = |statement: usize| {
shifty_algebra::render::selector_to_string_in_px(
&schema.statements[statement].selector,
&schema.arena,
&schema.prefixes,
)
};
match args.stage {
RepairStage::Witness => match args.format {
Format::Json => println!("{}", serde_json::to_string_pretty(&witnesses)?),
Format::Text => {
if witnesses.is_empty() {
println!("conforms: no violations to witness");
}
for fw in &witnesses {
println!("{} [target: {}]", fw.focus, target(fw.statement));
for line in render_witness(&fw.failure, &schema.prefixes, 2) {
println!("{line}");
}
}
}
Format::Dot => unreachable!(),
},
RepairStage::Tree => {
let trees: Vec<(&shifty_engine::FocusWitness, shifty_repair::RepairTree)> = witnesses
.iter()
.map(|fw| (fw, shifty_engine::synthesize(&schema.arena, fw)))
.collect();
match args.format {
Format::Json => {
let arr: Vec<_> = trees
.iter()
.map(|(fw, t)| {
serde_json::json!({
"focus": fw.focus.to_string(),
"statement": fw.statement,
"tree": t,
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&arr)?);
}
Format::Text => {
if trees.is_empty() {
println!("conforms: no violations to repair");
}
for (fw, t) in &trees {
println!("{} [target: {}]", fw.focus, target(fw.statement));
for line in render_tree(t, &schema.arena, &schema.prefixes, 2) {
println!("{line}");
}
}
}
Format::Dot => unreachable!(),
}
}
RepairStage::Solve => {
let opts = shifty_engine::EnumOptions::default();
let mut json_items = Vec::new();
if witnesses.is_empty() {
match args.format {
Format::Json => println!("[]"),
_ => println!("conforms: no violations to repair"),
}
}
for fw in &witnesses {
let tree = shifty_engine::synthesize(&schema.arena, fw);
let sol = match shifty_engine::enumerate_repair(
&tree,
&data_graph,
&context,
schema,
opts,
) {
Ok(s) => s,
Err(e) => return Err(format!("{e}; cannot solve").into()),
};
match args.format {
Format::Text => {
println!("{} [target: {}]", fw.focus, target(fw.statement));
match &sol {
Some(s) => {
println!(
" repair (fixes {}, introduces {}):",
s.outcome.fixed.len(),
s.outcome.introduced.len()
);
for t in &s.delta.delete {
println!(" del {t}");
}
for t in &s.delta.add {
println!(" add {t}");
}
}
None => println!(" no repair found within budget"),
}
}
Format::Json => json_items.push(serde_json::json!({
"focus": fw.focus.to_string(),
"statement": fw.statement,
"repair": sol.as_ref().map(|s| serde_json::json!({
"add": s.delta.add.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
"delete": s.delta.delete.iter().map(|t| t.to_string()).collect::<Vec<_>>(),
"fixed": s.outcome.fixed.len(),
"introduced": s.outcome.introduced.len(),
})),
})),
Format::Dot => unreachable!(),
}
}
if matches!(args.format, Format::Json) {
println!("{}", serde_json::to_string_pretty(&json_items)?);
}
}
}
Ok(())
}
fn path_str(p: &shifty_algebra::Path, px: &shifty_algebra::Prefixes) -> String {
shifty_algebra::render::path_to_string_in(p, px)
}
fn render_witness(
w: &shifty_engine::Witness,
px: &shifty_algebra::Prefixes,
indent: usize,
) -> Vec<String> {
use shifty_engine::Witness as W;
let pad = " ".repeat(indent);
let mut out = Vec::new();
match w {
W::Atom {
node,
reached_by,
produced_by,
..
} => out.push(format!(
"{pad}Atom at {node} via {}{}",
path_str(reached_by, px),
if produced_by.is_some() {
" [cuttable]"
} else {
""
}
)),
W::Relational {
kind, offending, ..
} => out.push(format!(
"{pad}Relational {kind:?}: {} offending pair(s)",
offending.len()
)),
W::Closed { offenders, .. } => {
out.push(format!(
"{pad}Closed: {} disallowed triple(s)",
offenders.len()
));
for (p, o) in offenders {
out.push(format!("{pad} - {p} {o}"));
}
}
W::Not { inner, .. } => {
out.push(format!("{pad}Not โ falsify the inner shape:"));
out.extend(render_sat(inner, px, indent + 2));
}
W::All { failed, .. } => {
out.push(format!("{pad}All โ fix every:"));
for f in failed {
out.extend(render_witness(f, px, indent + 2));
}
}
W::Any { branches, .. } => {
out.push(format!("{pad}Any โ fix any one of:"));
for b in branches {
out.extend(render_witness(b, px, indent + 2));
}
}
W::CountLow {
path, have, min, ..
} => out.push(format!(
"{pad}CountLow along {}: have {have}, need {min}",
path_str(path, px)
)),
W::CountHigh {
path,
matched,
max,
per_value,
..
} => {
out.push(format!(
"{pad}CountHigh along {}: {} match(es), max {max}",
path_str(path, px),
matched.len()
));
for (v, sub) in per_value {
out.push(format!("{pad} value {v}:"));
out.extend(render_witness(sub, px, indent + 4));
}
}
W::Opaque { .. } => out.push(format!("{pad}Opaque (SPARQL) โ no algebraic witness")),
}
out
}
fn render_sat(
s: &shifty_engine::SatTrace,
px: &shifty_algebra::Prefixes,
indent: usize,
) -> Vec<String> {
use shifty_engine::SatTrace as S;
let pad = " ".repeat(indent);
let mut out = Vec::new();
match s {
S::Irrefutable { .. } => out.push(format!("{pad}Irrefutable (โค)")),
S::Atom { node, .. } => out.push(format!("{pad}Atom holds at {node} [cut to break]")),
S::AllHeld { children, .. } => {
out.push(format!("{pad}AllHeld โ break any one:"));
for c in children {
out.extend(render_sat(c, px, indent + 2));
}
}
S::AnyHeld { satisfied, .. } => {
out.push(format!("{pad}AnyHeld โ break every:"));
for c in satisfied {
out.extend(render_sat(c, px, indent + 2));
}
}
S::CountHeld { matches, .. } => {
out.push(format!("{pad}CountHeld: {} match(es)", matches.len()))
}
S::ForAllHeld { values, .. } => {
out.push(format!(
"{pad}ForAllHeld: {} checked value(s)",
values.len()
));
for (_, _, trace) in values {
out.extend(render_sat(trace, px, indent + 2));
}
}
S::NotHeld { inner_fails, .. } => {
out.push(format!("{pad}NotHeld โ make the inner shape hold:"));
out.extend(render_witness(inner_fails, px, indent + 2));
}
S::Blocked { reason, .. } => out.push(format!("{pad}Blocked: {reason:?}")),
S::Coinductive { .. } => out.push(format!("{pad}Coinductive (gfp back-edge)")),
}
out
}
fn render_tree(
t: &shifty_repair::RepairTree,
arena: &shifty_algebra::ShapeArena,
px: &shifty_algebra::Prefixes,
indent: usize,
) -> Vec<String> {
use shifty_repair::RepairTree as T;
let pad = " ".repeat(indent);
let mut out = Vec::new();
match t {
T::Noop(_) => out.push(format!("{pad}Noop")),
T::Blocked(_, r) => out.push(format!("{pad}Blocked: {r:?}")),
T::Edits { edits, holes, .. } => {
out.push(format!("{pad}Edits:"));
for e in edits {
out.push(format!("{pad} {}", edit_str(e)));
}
for (h, c) in holes {
out.push(format!(
"{pad} ?{} : {}",
h.0,
constraint_str(c, arena, px)
));
}
}
T::All { children, .. } => {
out.push(format!("{pad}All โ do all:"));
for c in children {
out.extend(render_tree(c, arena, px, indent + 2));
}
}
T::Any { children, .. } => {
out.push(format!("{pad}Any โ choose one:"));
for c in children {
out.extend(render_tree(c, arena, px, indent + 2));
}
}
T::Repeat { body, min, max, .. } => {
let hi = max.map_or_else(|| "โ".to_string(), |m| m.to_string());
out.push(format!("{pad}Repeat [{min}..{hi}]:"));
out.extend(render_tree(body, arena, px, indent + 2));
}
}
out
}
fn edit_str(e: &shifty_repair::Edit) -> String {
use shifty_repair::EditOp;
let (sign, p) = match &e.op {
EditOp::Add(p) => ("add", p),
EditOp::Delete(p) => ("del", p),
};
format!(
"{sign} {} {} {}",
slot_str(&p.s),
slot_str(&p.p),
slot_str(&p.o)
)
}
fn slot_str(s: &shifty_repair::Slot) -> String {
match s {
shifty_repair::Slot::Bound(t) => t.to_string(),
shifty_repair::Slot::Open(h) => format!("?{}", h.0),
}
}
fn constraint_str(
c: &shifty_repair::HoleConstraint,
arena: &shifty_algebra::ShapeArena,
px: &shifty_algebra::Prefixes,
) -> String {
use shifty_repair::HoleConstraint as H;
match c {
H::AnyNode => "any node".to_string(),
H::Fresh => "fresh node".to_string(),
H::Const(t) => format!("= {t}"),
H::Typed(_) => "typed value".to_string(),
H::Kind(_) => "nodeKind".to_string(),
H::OneOf(v) => format!("one of {} value(s)", v.len()),
H::ConformsTo(s) => shifty_algebra::render::describe_shape_in(arena, *s, px),
H::ConformsToAll(ss) => shifty_algebra::render::describe_shapes_in(arena, ss, px),
}
}
fn inspect(args: InspectArgs) -> Result<(), Box<dyn Error>> {
let bytes = std::fs::read(&args.file)?;
let base = args.base.as_deref();
let source = args.file.to_string_lossy();
let loaded = shifty_parse::load_rdf_auto(&bytes, None, Some(source.as_ref()), base)?;
let out = shifty_parse::parse_loaded(&loaded);
match args.stage {
Stage::Rdf => match args.format {
Format::Text => {
let mut lines: Vec<String> = loaded.graph.iter().map(|t| t.to_string()).collect();
lines.sort();
for line in lines {
println!("{line}");
}
}
Format::Json => {
let triples: Vec<_> = loaded
.graph
.iter()
.map(|t| {
serde_json::json!({
"subject": t.subject.to_string(),
"predicate": t.predicate.to_string(),
"object": t.object.to_string(),
})
})
.collect();
println!("{}", serde_json::to_string_pretty(&triples)?);
}
Format::Dot => {
return Err(
"--format dot is only supported for --stage algebra or --stage normalized"
.into(),
);
}
},
Stage::Algebra => {
match args.format {
Format::Text => print!("{}", shifty_algebra::render::schema_to_text(&out.schema)),
Format::Json => println!("{}", serde_json::to_string_pretty(&out.schema)?),
Format::Dot => print!("{}", shifty_algebra::render::schema_to_dot(&out.schema)),
}
for d in &out.diagnostics {
eprintln!("{d}");
}
}
Stage::Normalized => {
let schema = shifty_opt::normalize(&out.schema);
match args.format {
Format::Text => print!("{}", shifty_algebra::render::schema_to_text(&schema)),
Format::Json => println!("{}", serde_json::to_string_pretty(&schema)?),
Format::Dot => print!("{}", shifty_algebra::render::schema_to_dot(&schema)),
}
for d in &out.diagnostics {
eprintln!("{d}");
}
}
Stage::Strata => {
let strat = shifty_opt::analyze(&out.schema.arena);
match args.format {
Format::Json => println!("{}", serde_json::to_string_pretty(&strat)?),
Format::Text => print_strata(&strat),
Format::Dot => {
return Err(
"--format dot is only supported for --stage algebra or --stage normalized"
.into(),
);
}
}
for d in &out.diagnostics {
eprintln!("{d}");
}
}
Stage::Plan => {
let normalized = shifty_opt::normalize(&out.schema);
let physical = shifty_opt::plan(&normalized);
match args.format {
Format::Text => print!("{}", shifty_opt::plan::plan_to_text(&physical)),
Format::Json => println!("{}", serde_json::to_string_pretty(&physical)?),
Format::Dot => return Err("--format dot is not supported for --stage plan".into()),
}
for d in &out.diagnostics {
eprintln!("{d}");
}
}
Stage::Capability => {
if !matches!(args.format, Format::Text) {
return Err("--stage capability only supports --format text".into());
}
let normalized = shifty_opt::normalize(&out.schema);
print_capability(&normalized);
for d in &out.diagnostics {
eprintln!("{d}");
}
}
Stage::Access => {
if matches!(args.format, Format::Dot) {
return Err("--format dot is not supported for --stage access".into());
}
let functions = shifty_parse::collect_functions(&loaded);
let catalog = shifty_opt::AccessCatalog::compile(&out.schema, &functions);
match args.format {
Format::Text => print_access(&catalog),
Format::Json => println!("{}", serde_json::to_string_pretty(&catalog)?),
Format::Dot => unreachable!(),
}
for d in &out.diagnostics {
eprintln!("{d}");
}
}
}
Ok(())
}
fn print_access(catalog: &shifty_opt::AccessCatalog) {
println!(
"access: {} consumer(s), {} query identity/identities, {} path identity/identities",
catalog.consumers.len(),
catalog.queries.len(),
catalog.paths.len()
);
for consumer in &catalog.consumers {
println!("{:?}", consumer.consumer);
println!(" default: {}", access_requirement(&consumer.default));
println!(" shapes: {}", access_requirement(&consumer.shapes));
if !consumer.queries.is_empty() {
println!(" queries: {:?}", consumer.queries);
}
if !consumer.paths.is_empty() {
println!(" paths: {:?}", consumer.paths);
}
if !consumer.calls.is_empty() {
let mut calls: Vec<_> = consumer.calls.iter().map(|iri| iri.as_str()).collect();
calls.sort_unstable();
println!(" functions: {}", calls.join(", "));
}
if consumer.writes.any_predicate || !consumer.writes.predicates.is_empty() {
let mut writes: Vec<_> = consumer
.writes
.predicates
.iter()
.map(|iri| iri.as_str())
.collect();
writes.sort_unstable();
if consumer.writes.any_predicate {
writes.push("*");
}
println!(" writes: {}", writes.join(", "));
}
}
for (index, query) in catalog.queries.iter().enumerate() {
println!("query[{index}]: {}", query.text.replace(['\r', '\n'], " "));
}
for (index, path) in catalog.paths.iter().enumerate() {
println!("path[{index}]: {:?}", path.path);
}
}
fn access_requirement(requirement: &shifty_opt::AccessRequirement) -> String {
let mut predicates: Vec<_> = requirement
.predicates
.iter()
.map(|predicate| predicate.as_str())
.collect();
predicates.sort_unstable();
let mut probes = Vec::new();
for (enabled, name) in [
(requirement.probes.forward, "forward"),
(requirement.probes.reverse, "reverse"),
(requirement.probes.membership, "membership"),
(requirement.probes.open_scan, "open scan"),
] {
if enabled {
probes.push(name);
}
}
format!(
"predicates [{}{}]; probes [{}]; node domain {}; {}",
predicates.join(", "),
if requirement.any_predicate {
if predicates.is_empty() { "*" } else { ", *" }
} else {
""
},
probes.join(", "),
requirement.reads_node_domain,
if requirement.incomplete {
"conservative/unknown"
} else {
"complete"
}
)
}
fn print_strata(strat: &shifty_opt::Stratification) {
let recursive = strat.recursive().count();
println!(
"strata: stratifiable = {}; {} shape(s) in {} stratum(strata); {} recursive component(s)",
strat.stratifiable,
strat.shape_count(),
strat.strata.len(),
recursive,
);
let fmt = |shapes: &[shifty_algebra::ShapeId]| {
shapes
.iter()
.map(|s| format!("@{}", s.0))
.collect::<Vec<_>>()
.join(" ")
};
if recursive > 0 {
println!("recursive components (in dependency order):");
for (level, s) in strat.strata.iter().enumerate() {
if !s.recursive {
continue;
}
let tag = if s.stratifiable {
"positive recursion, ok"
} else {
"NON-STRATIFIABLE: recursion through negation"
};
println!(" stratum {level}: {} ({tag})", fmt(&s.shapes));
}
}
}
fn print_capability(schema: &shifty_algebra::Schema) {
use shifty_algebra::Shape;
use shifty_opt::lower_query;
use spargebra::SparqlParser;
let mut sparql_queries: Vec<String> = Vec::new();
for i in 0..schema.arena.len() {
let id = shifty_algebra::ShapeId(i as u32);
if let Shape::Sparql(c) = schema.arena.get(id) {
sparql_queries.push(c.query.clone());
}
}
let lowered_count = sparql_queries
.iter()
.filter(|q| {
SparqlParser::new()
.parse_query(q)
.map(|parsed| lower_query(&parsed).is_ok())
.unwrap_or(false)
})
.count();
println!(
"capability: {} SPARQL constraint query/queries ({} native, {} fall back)",
sparql_queries.len(),
lowered_count,
sparql_queries.len() - lowered_count,
);
for (i, q) in sparql_queries.iter().enumerate() {
match SparqlParser::new().parse_query(q) {
Ok(parsed) => {
let tag = match lower_query(&parsed) {
Ok(_) => "NATIVE".to_string(),
Err(reason) => format!("FALLBACK ({reason})"),
};
println!(" [{i}] {tag}:\n{q}");
}
Err(e) => println!(" [{i}] PARSE ERROR: {e}"),
}
}
}