use crate::diagnostic::Diagnostics;
use crate::{
Code, Diagnostic, FieldValue, Plane,
files::Tree,
index::{Holder, Recorder, Role, Site, Target, message},
read::{Reader, is_identifier},
uid::Claims,
};
use std::{
cell::{Cell, RefCell, RefMut},
collections::{BTreeMap, BTreeSet},
rc::Rc,
sync::Arc,
};
use tilewright_jsonc::{MAX_SAFE_INTEGER, Member, Span, Value};
pub const DEFAULT_MAXIMUM_STRING: usize = 65_536;
pub const MAXIMUM_FIELD_DEPTH: usize = 8;
pub const MAXIMUM_LIST_ITEMS: usize = 250_000;
#[derive(Clone, Debug, PartialEq)]
pub struct Schema {
pub path: Arc<str>,
fields: Vec<(String, FieldSpec)>,
index: BTreeMap<String, usize>,
required: Vec<usize>,
}
impl Schema {
pub(crate) const fn new(path: Arc<str>) -> Self {
Self {
path,
fields: Vec::new(),
index: BTreeMap::new(),
required: Vec::new(),
}
}
fn push(&mut self, name: String, spec: FieldSpec) {
let position = self.fields.len();
if spec.required {
self.required.push(position);
}
self.index.insert(name.clone(), position);
self.fields.push((name, spec));
}
fn lookup(&self, name: &str) -> Option<(usize, &FieldSpec)> {
let position = *self.index.get(name)?;
self.fields.get(position).map(|(_, spec)| (position, spec))
}
#[must_use]
pub fn fields(&self) -> &[(String, FieldSpec)] {
&self.fields
}
#[must_use]
pub fn field(&self, name: &str) -> Option<&FieldSpec> {
self.lookup(name).map(|(_, spec)| spec)
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct FieldSpec {
pub field_type: FieldType,
pub required: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub enum FieldType {
Boolean,
Integer {
minimum: i64,
maximum: i64,
},
Number {
minimum: f64,
maximum: f64,
},
String {
maximum_length: usize,
values: BTreeSet<String>,
},
Reference {
target: String,
},
Json,
List {
items: Box<FieldType>,
minimum_items: usize,
maximum_items: usize,
},
Object {
fields: Schema,
},
}
impl FieldType {
pub(crate) fn targets<'s>(&'s self, path: &str, visit: &mut impl FnMut(&str, &'s str)) {
match self {
Self::Reference { target } => visit(path, target),
Self::List { items, .. } => items.targets(&format!("{path}[]"), visit),
Self::Object { fields } => {
for (name, spec) in fields.fields() {
spec.field_type.targets(&format!("{path}.{name}"), visit);
}
}
_ => {}
}
}
}
fn is_field_name(name: &str) -> bool {
let bytes = name.as_bytes();
!bytes.is_empty()
&& bytes.len() <= 64
&& bytes[0].is_ascii_lowercase()
&& bytes
.iter()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'_')
}
pub(crate) fn read_schema(
tree: &mut Tree<'_>,
path: &str,
diagnostics: &mut Diagnostics,
) -> Option<(Schema, Vec<Span>)> {
let source = tree.document(path)?;
let reader = Reader::new(&source, diagnostics);
let root = reader.object(reader.root(), &["format", "fields"])?;
let format_ok = reader.format(root, "tilewright.schema/1");
let mut targets = Vec::new();
let schema = read_field_specs(
&reader,
reader.required(root, "fields")?,
&path.into(),
0,
&mut targets,
);
schema.filter(|_| format_ok).map(|schema| (schema, targets))
}
fn read_field_specs<'a>(
reader: &Reader<'a>,
value: &'a Value,
path: &Arc<str>,
depth: usize,
targets: &mut Vec<Span>,
) -> Option<Schema> {
let members = reader.members(value)?;
let mut schema = Schema::new(Arc::clone(path));
let mut ok = true;
for member in members {
let named = is_field_name(&member.key);
if !named {
reader.report(
member.key_span,
Code::Identifier,
format!("\"{}\" is not a valid field name", member.key),
);
ok = false;
}
match read_spec(reader, &member.value, path, depth, true, targets).filter(|_| named) {
Some((field_type, required)) => schema.push(
member.key.clone(),
FieldSpec {
field_type,
required,
},
),
None => ok = false,
}
}
ok.then_some(schema)
}
#[allow(clippy::too_many_lines)]
fn read_spec<'a>(
reader: &Reader<'a>,
value: &'a Value,
path: &Arc<str>,
depth: usize,
takes_required: bool,
targets: &mut Vec<Span>,
) -> Option<(FieldType, bool)> {
if value.as_object().is_none() {
reader.report(
value.span(),
Code::Type,
format!("expected an object, found {}", value.kind()),
);
return None;
}
let spec = value;
let kind = reader.required(spec, "type");
let options: &[&str] = match kind.and_then(Value::as_str) {
Some("integer" | "number") => &["min", "max"],
Some("string") => &["max_length", "values"],
Some("reference") => &["target"],
Some("list") => &["items", "min_items", "max_items"],
Some("object") => &["fields"],
Some("boolean" | "json") => &[],
_ => &[
"min",
"max",
"max_length",
"values",
"target",
"items",
"min_items",
"max_items",
"fields",
],
};
let mut allowed = vec!["type"];
if takes_required {
allowed.push("required");
}
allowed.extend(options);
reader.object(spec, &allowed)?;
let required = match spec.get("required").filter(|_| takes_required) {
Some(flag) => reader.boolean(flag),
None => Some(false),
};
let kind = kind?;
let too_deep = matches!(kind.as_str(), Some("list" | "object")) && depth >= MAXIMUM_FIELD_DEPTH;
if too_deep {
reader.report(
kind.span(),
Code::Range,
format!("list and object types nest at most {MAXIMUM_FIELD_DEPTH} deep"),
);
}
let field_type = match kind.as_str() {
Some("boolean") => Some(FieldType::Boolean),
Some("json") => Some(FieldType::Json),
Some("integer") => {
let bound = |key, default| match spec.get(key) {
Some(value) => reader.integer(value, -MAX_SAFE_INTEGER, MAX_SAFE_INTEGER),
None => Some(default),
};
let (minimum, maximum) = (
bound("min", -MAX_SAFE_INTEGER),
bound("max", MAX_SAFE_INTEGER),
);
ordered(
reader,
spec,
minimum.zip(maximum),
"min is greater than max",
)
.map(|(minimum, maximum)| FieldType::Integer { minimum, maximum })
}
Some("number") => {
let bound = |key, default: f64| match spec.get(key) {
Some(value) => {
let number = value.as_f64();
if number.is_none() {
reader.report(value.span(), Code::Type, "expected a number");
}
number
}
None => Some(default),
};
let (minimum, maximum) = (bound("min", f64::MIN), bound("max", f64::MAX));
ordered(
reader,
spec,
minimum.zip(maximum),
"min is greater than max",
)
.map(|(minimum, maximum)| FieldType::Number { minimum, maximum })
}
Some("string") => {
let maximum_length = match spec.get("max_length") {
Some(value) => reader
.integer(value, 1, 1_048_576)
.and_then(|length| usize::try_from(length).ok()),
None => Some(DEFAULT_MAXIMUM_STRING),
};
let values = match spec.get("values") {
Some(value) => read_values(reader, value),
None => Some(BTreeSet::new()),
};
maximum_length
.zip(values)
.map(|(maximum_length, values)| FieldType::String {
maximum_length,
values,
})
}
Some("reference") => reader.required(spec, "target").and_then(|value| {
let target = reader.identifier(value)?;
targets.push(value.span());
Some(FieldType::Reference { target })
}),
Some("list") => {
let count = |key, default| match spec.get(key) {
Some(value) => {
let limit = i64::try_from(MAXIMUM_LIST_ITEMS).unwrap_or(i64::MAX);
reader
.integer(value, 0, limit)
.and_then(|count| usize::try_from(count).ok())
}
None => Some(default),
};
let (minimum, maximum) = (
count("min_items", 0),
count("max_items", MAXIMUM_LIST_ITEMS),
);
let items = reader
.required(spec, "items")
.and_then(|items| read_spec(reader, items, path, depth + 1, false, targets));
let bounds = ordered(
reader,
spec,
minimum.zip(maximum),
"min_items is greater than max_items",
);
bounds.zip(items).map(
|((minimum_items, maximum_items), (items, _))| FieldType::List {
items: Box::new(items),
minimum_items,
maximum_items,
},
)
}
Some("object") => reader
.required(spec, "fields")
.and_then(|fields| read_field_specs(reader, fields, path, depth + 1, targets))
.map(|fields| FieldType::Object { fields }),
_ => {
reader.report(
kind.span(),
Code::Range,
"type must be boolean, integer, number, string, reference, json, list or object",
);
None
}
};
field_type.zip(required).filter(|_| !too_deep)
}
fn ordered<T: PartialOrd>(
reader: &Reader<'_>,
spec: &Value,
bounds: Option<(T, T)>,
message: &str,
) -> Option<(T, T)> {
let (lower, upper) = bounds?;
if lower > upper {
reader.report(spec.span(), Code::Range, message);
return None;
}
Some((lower, upper))
}
fn read_values<'a>(reader: &Reader<'a>, value: &'a Value) -> Option<BTreeSet<String>> {
let mut values = BTreeSet::new();
let mut ok = true;
for item in reader.array(value)? {
match reader.string(item) {
Some(text) if !values.insert(text.to_owned()) => {
reader.report(item.span(), Code::Duplicate, "value listed twice");
ok = false;
}
Some(_) => {}
None => ok = false,
}
}
ok.then_some(values)
}
pub(crate) struct References {
render_layers: BTreeSet<String>,
ground_layers: BTreeSet<String>,
definition_types: BTreeSet<String>,
definitions: BTreeMap<String, BTreeSet<String>>,
planes: BTreeMap<String, BTreeSet<String>>,
zones: BTreeMap<String, BTreeSet<String>>,
terrains: BTreeSet<String>,
uids: Claims,
recorder: RefCell<Option<Recorder>>,
problems: Rc<Cell<usize>>,
}
impl References {
pub fn new(record: bool, problems: Rc<Cell<usize>>) -> Self {
Self {
render_layers: BTreeSet::new(),
ground_layers: BTreeSet::new(),
definition_types: BTreeSet::new(),
definitions: BTreeMap::new(),
planes: BTreeMap::new(),
zones: BTreeMap::new(),
terrains: BTreeSet::new(),
uids: Claims::default(),
recorder: RefCell::new(record.then(Recorder::default)),
problems,
}
}
fn recorder(&self) -> Option<RefMut<'_, Recorder>> {
let mut slot = self.recorder.borrow_mut();
if self.problems.get() > 0 {
*slot = None;
}
RefMut::filter_map(slot, Option::as_mut).ok()
}
pub fn into_recorder(self) -> Option<Recorder> {
let recorder = self.recorder.into_inner();
recorder.filter(|_| self.problems.get() == 0)
}
pub fn render_layers_declared<'n>(&mut self, layers: impl IntoIterator<Item = &'n String>) {
self.render_layers.extend(layers.into_iter().cloned());
}
pub fn profile_declared<'n>(
&mut self,
ground_layers: impl IntoIterator<Item = &'n String>,
definition_types: impl IntoIterator<Item = &'n String>,
) {
self.ground_layers
.extend(ground_layers.into_iter().cloned());
self.definition_types
.extend(definition_types.into_iter().cloned());
}
pub fn claim_uid(&mut self, uid: &str, path: &Arc<str>) -> Option<Arc<str>> {
self.uids.claim(uid, path)
}
pub fn definitions_listed(&mut self, keys: impl IntoIterator<Item = (String, String)>) {
for (kind, id) in keys {
self.definitions.entry(kind).or_default().insert(id);
}
}
pub fn zones_read<'z>(&mut self, plane: &str, zones: impl IntoIterator<Item = &'z String>) {
self.zones
.entry(plane.to_owned())
.or_default()
.extend(zones.into_iter().cloned());
}
pub fn terrains_declared<'t>(&mut self, terrains: impl IntoIterator<Item = &'t String>) {
self.terrains.extend(terrains.into_iter().cloned());
}
pub fn plane_read(&mut self, plane: &Plane) {
self.planes
.insert(plane.id.clone(), plane.anchors.keys().cloned().collect());
}
pub fn resolves(&self, target: &Target<'_>) -> bool {
match *target {
Target::RenderLayer(layer) => self.render_layers.contains(layer),
Target::GroundLayer(layer) => self.ground_layers.contains(layer),
Target::DefinitionType(kind) => self.definition_types.contains(kind),
Target::Definition { kind, id } => self
.definitions
.get(kind)
.is_some_and(|ids| ids.contains(id)),
Target::Plane(plane) => self.planes.contains_key(plane),
Target::Anchor { plane, id } => self
.planes
.get(plane)
.is_some_and(|anchors| anchors.contains(id)),
Target::Zone { plane, id } => self
.zones
.get(plane)
.is_some_and(|zones| zones.contains(id)),
Target::Terrain(terrain) => self.terrains.contains(terrain),
Target::File(_) => true,
}
}
pub fn check(&self, site: &Site<'_>) -> bool {
self.follow(site);
self.resolves(&site.target)
}
pub fn follow(&self, site: &Site<'_>) {
if let Some(mut recorder) = self.recorder() {
recorder.record(site);
}
}
fn check_field(
&self,
site: &Site<'_>,
types: (&Arc<Schema>, &FieldType),
path: &FieldPath<'_>,
) -> bool {
if let Some(mut recorder) = self.recorder() {
recorder.record_field(site, types, path);
}
self.resolves(&site.target)
}
pub fn check_file(&self, site: &Site<'_>, diagnostics: &mut Diagnostics) -> bool {
let resolves = self.check(site);
if !resolves {
diagnostics.push(Diagnostic::file(site.file, Code::Reference, message(site)));
}
resolves
}
}
#[derive(Clone, Copy)]
pub(crate) enum FieldPath<'p> {
Root,
Field(&'p FieldPath<'p>, &'p str),
Items(&'p FieldPath<'p>),
}
impl FieldPath<'_> {
pub(crate) fn render(&self) -> String {
match self {
Self::Root => String::new(),
Self::Field(parent, name) => match parent.render() {
parent if parent.is_empty() => (*name).to_owned(),
parent => format!("{parent}.{name}"),
},
Self::Items(parent) => format!("{}[]", parent.render()),
}
}
}
pub(crate) fn read_fields<'a>(
reader: &Reader<'a>,
(owner, holder): (&'a Value, Holder<'_>),
fields: Option<&'a Value>,
schema: &Arc<Schema>,
references: &mut References,
) -> Option<Vec<(String, FieldValue)>> {
let members = match fields {
Some(fields) => reader.members(fields)?,
None => &[],
};
let mut output = Vec::with_capacity(members.len());
let at = Values {
holder,
schema,
path: &FieldPath::Root,
};
let ok = check_members(
reader,
members,
fields.unwrap_or(owner).span(),
schema,
(references, at),
&mut |member, spec| {
output.push((
member.key.clone(),
typed_value(reader, &member.value, &spec.field_type),
));
},
);
ok.then_some(output)
}
fn check_members<'a>(
reader: &Reader<'a>,
members: &'a [Member],
owner: Span,
schema: &Schema,
(references, at): (&mut References, Values<'_>),
accept: &mut dyn FnMut(&'a Member, &FieldSpec),
) -> bool {
let mut ok = true;
let mut present = BTreeSet::new();
for member in members {
let Some((position, spec)) = schema.lookup(&member.key) else {
reader.report(
member.key_span,
Code::UnknownKey,
format!("field \"{}\" is not in {}", member.key, schema.path),
);
ok = false;
continue;
};
if spec.required {
present.insert(position);
}
let path = FieldPath::Field(at.path, &member.key);
let at = Values { path: &path, ..at };
if check_value(reader, &member.value, &spec.field_type, (references, at)) {
accept(member, spec);
} else {
ok = false;
}
}
let missing = schema.required.len() - present.len();
if missing > 0 {
let named: Vec<&str> = schema
.required
.iter()
.inspect(|_| visited())
.filter(|position| !present.contains(position))
.take(NAMED)
.filter_map(|position| schema.fields.get(*position))
.map(|(name, _)| name.as_str())
.collect();
reader.report(owner, Code::MissingKey, missing_message(&named, missing));
ok = false;
}
ok
}
const NAMED: usize = 3;
#[cfg(test)]
thread_local! {
pub(crate) static REQUIRED_VISITS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
fn visited() {
REQUIRED_VISITS.with(|visits| visits.set(visits.get() + 1));
}
#[cfg(not(test))]
const fn visited() {}
fn missing_message(named: &[&str], count: usize) -> String {
let list = named
.iter()
.map(|name| format!("\"{name}\""))
.collect::<Vec<_>>()
.join(", ");
match count {
1 => format!("required field {list} is missing"),
count if count <= named.len() => format!("required fields {list} are missing"),
count => format!(
"required fields {list} and {} more are missing",
count - named.len()
),
}
}
fn typed_value(reader: &Reader<'_>, value: &Value, field_type: &FieldType) -> FieldValue {
match (field_type, value.as_f64()) {
(FieldType::Number { .. }, Some(number)) => FieldValue::Number(number),
_ => reader.field_value(value),
}
}
#[derive(Clone, Copy)]
struct Values<'p> {
holder: Holder<'p>,
schema: &'p Arc<Schema>,
path: &'p FieldPath<'p>,
}
fn check_value<'a>(
reader: &Reader<'a>,
value: &'a Value,
field_type: &FieldType,
(references, at): (&mut References, Values<'_>),
) -> bool {
match field_type {
FieldType::Boolean => reader.boolean(value).is_some(),
FieldType::Integer { minimum, maximum } => {
reader.integer(value, *minimum, *maximum).is_some()
}
FieldType::Number { minimum, maximum } => {
let Some(number) = value.as_f64() else {
reader.report(
value.span(),
Code::Type,
format!("expected a number, found {}", value.kind()),
);
return false;
};
if number < *minimum || number > *maximum {
reader.report(
value.span(),
Code::Range,
format!("{number} is outside {minimum}..={maximum}"),
);
return false;
}
true
}
FieldType::String {
maximum_length,
values,
} => {
let Some(text) = reader.text(value, *maximum_length) else {
return false;
};
if !values.is_empty() && !values.contains(&text) {
reader.report(
value.span(),
Code::Range,
format!("\"{text}\" is not one of the allowed values"),
);
return false;
}
true
}
FieldType::Reference { target } => {
let Some(id) = reader.string(value) else {
return false;
};
if !is_identifier(id) {
reader.report(
value.span(),
Code::Identifier,
format!("\"{id}\" is not a valid identifier"),
);
return false;
}
let site = reader.site(
Target::Definition { kind: target, id },
(Role::Field { field: "" }, at.holder),
value.span(),
);
if !references.check_field(&site, (at.schema, field_type), at.path) {
reader.report(value.span(), Code::Reference, message(&site));
}
true
}
FieldType::Json => true,
FieldType::List {
items,
minimum_items,
maximum_items,
} => check_list(
reader,
value,
items,
*minimum_items..=*maximum_items,
(references, at),
),
FieldType::Object { fields } => {
let Some(members) = reader.members(value) else {
return false;
};
check_members(
reader,
members,
value.span(),
fields,
(references, at),
&mut |_, _| {},
)
}
}
}
fn check_list<'a>(
reader: &Reader<'a>,
value: &'a Value,
items: &FieldType,
count: std::ops::RangeInclusive<usize>,
(references, at): (&mut References, Values<'_>),
) -> bool {
let Some(elements) = reader.array(value) else {
return false;
};
let mut ok = count.contains(&elements.len());
if !ok {
reader.report(
value.span(),
Code::Range,
format!(
"{} items, outside {}..={}",
elements.len(),
count.start(),
count.end()
),
);
}
let path = FieldPath::Items(at.path);
let at = Values { path: &path, ..at };
for element in elements {
ok &= check_value(reader, element, items, (references, at));
}
ok
}
#[cfg(test)]
mod tests {
use super::{References, Target};
use std::path::Path;
#[test]
fn references_resolve_against_what_is_known_before_they_are_read() {
let world =
crate::load(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/basic"))
.expect("the fixture is valid");
let mut references = References::new(false, std::rc::Rc::default());
references.render_layers_declared(&world.profile().render_layers);
let proto = |id| Target::Definition { kind: "proto", id };
let anchor = Target::Anchor {
plane: "overworld",
id: "village.well",
};
assert!(references.resolves(&Target::RenderLayer("wall")));
assert!(!references.resolves(&Target::RenderLayer("roof")));
assert!(!references.resolves(&proto("tree")));
assert!(!references.resolves(&anchor));
references.definitions_listed(world.definitions().keys().cloned());
references.plane_read(&world.planes()["overworld"]);
assert!(references.resolves(&proto("tree")));
assert!(!references.resolves(&proto("missing")));
assert!(references.resolves(&anchor));
assert!(references.resolves(&Target::Plane("overworld")));
assert!(!references.resolves(&Target::Plane("vault")));
assert!(!references.resolves(&Target::Zone {
plane: "overworld",
id: "village"
}));
references.zones_read("overworld", world.planes()["overworld"].zones.keys());
assert!(references.resolves(&Target::Zone {
plane: "overworld",
id: "village"
}));
assert!(!references.resolves(&Target::Zone {
plane: "vault",
id: "village"
}));
}
}