1use crate::{ALLOWLIST_FORMAT, Kind};
8use std::{collections::BTreeMap, fmt, io::Read as _, path::Path};
9use tilewright_jsonc::{Document, ErrorCode, Limits, Span, Value};
10
11#[derive(Clone, Debug, Default, Eq, PartialEq)]
13pub struct Allowlist {
14 entries: BTreeMap<Kind, BTreeMap<String, Option<String>>>,
16}
17
18#[derive(Clone, Debug, Eq, PartialEq)]
20pub enum Allowance {
21 Remove { kind: Kind, id: String },
23 Rename {
25 kind: Kind,
26 from: String,
27 to: String,
28 },
29}
30
31impl Allowance {
32 #[must_use]
33 pub const fn kind(&self) -> Kind {
34 match self {
35 Self::Remove { kind, .. } | Self::Rename { kind, .. } => *kind,
36 }
37 }
38
39 #[must_use]
41 pub fn id(&self) -> &str {
42 match self {
43 Self::Remove { id, .. } => id,
44 Self::Rename { from, .. } => from,
45 }
46 }
47}
48
49#[derive(Clone, Copy, Debug, Eq, PartialEq)]
51pub(crate) enum Intent<'a> {
52 Remove,
53 Rename(&'a str),
55}
56
57#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct AllowlistError {
60 pub line: usize,
63 pub column: usize,
64 pub code: &'static str,
67 pub message: String,
68}
69
70impl AllowlistError {
71 fn file(code: &'static str, message: impl Into<String>) -> Self {
72 Self {
73 line: 0,
74 column: 0,
75 code,
76 message: message.into(),
77 }
78 }
79
80 fn at(text: &str, offset: usize, code: &'static str, message: impl Into<String>) -> Self {
82 let mut boundary = offset.min(text.len());
83 while !text.is_char_boundary(boundary) {
84 boundary -= 1;
85 }
86 let before = &text[..boundary];
87 let line_start = before.rfind('\n').map_or(0, |index| index + 1);
88 Self {
89 line: before.bytes().filter(|byte| *byte == b'\n').count() + 1,
90 column: before[line_start..].chars().count() + 1,
91 code,
92 message: message.into(),
93 }
94 }
95}
96
97impl fmt::Display for AllowlistError {
98 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
99 if self.line == 0 {
100 write!(formatter, "{}: {}", self.code, self.message)
101 } else {
102 write!(
103 formatter,
104 "{}:{}: {}: {}",
105 self.line, self.column, self.code, self.message
106 )
107 }
108 }
109}
110
111impl std::error::Error for AllowlistError {}
112
113impl Allowlist {
114 pub fn read(path: &Path) -> Result<Self, AllowlistError> {
120 let limit = Limits::default().max_input_bytes;
121 let io = |error: std::io::Error| AllowlistError::file("source.io", error.to_string());
122 let mut bytes = Vec::new();
123 std::fs::File::open(path)
124 .map_err(io)?
125 .take(u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1))
126 .read_to_end(&mut bytes)
127 .map_err(io)?;
128 if bytes.len() > limit {
129 return Err(AllowlistError::file(
130 ErrorCode::InputTooLarge.as_str(),
131 format!("an allowlist is at most {limit} bytes"),
132 ));
133 }
134 let text = String::from_utf8(bytes)
135 .map_err(|_| AllowlistError::file("source.encoding", "an allowlist is UTF-8"))?;
136 Self::parse(&text)
137 }
138
139 pub fn parse(text: &str) -> Result<Self, AllowlistError> {
146 let document =
147 Document::parse(text, &Limits::default()).map_err(|error| AllowlistError {
148 line: error.line,
149 column: error.column,
150 code: error.code.as_str(),
151 message: "invalid JSONC".into(),
152 })?;
153 let fail = |span: Span, code: &'static str, message: String| {
154 AllowlistError::at(text, span.start, code, message)
155 };
156 let root = document.root();
157 closed(root, &["format", "remove", "rename"], &fail)?;
158 match root.get("format") {
159 Some(value) if value.as_str() == Some(ALLOWLIST_FORMAT) => {}
160 Some(value) => {
161 return Err(fail(
162 value.span(),
163 "shape.format",
164 format!("format must be \"{ALLOWLIST_FORMAT}\""),
165 ));
166 }
167 None => {
168 return Err(fail(
169 root.span(),
170 "shape.missing_key",
171 "missing \"format\"".into(),
172 ));
173 }
174 }
175 let mut allowlist = Self::default();
176 for (key, fields) in [
177 ("remove", &["kind", "id"][..]),
178 ("rename", &["kind", "from", "to"][..]),
179 ] {
180 let Some(list) = root.get(key) else {
181 continue;
182 };
183 let items = list
184 .as_array()
185 .ok_or_else(|| fail(list.span(), "shape.type", format!("{key} is an array")))?;
186 for item in items {
187 closed(item, fields, &fail)?;
188 let mut values = Vec::with_capacity(fields.len());
189 for field in fields {
190 let value = item.get(field).ok_or_else(|| {
191 fail(
192 item.span(),
193 "shape.missing_key",
194 format!("missing \"{field}\""),
195 )
196 })?;
197 let text = value.as_str().ok_or_else(|| {
198 fail(value.span(), "shape.type", format!("{field} is a string"))
199 })?;
200 values.push((text, value.span()));
201 }
202 let (kind_text, kind_span) = values[0];
203 let kind = Kind::parse(kind_text)
204 .ok_or_else(|| fail(kind_span, "shape.range", kinds_message()))?;
205 for (id, span) in &values[1..] {
206 if !kind.is_id(id) {
207 return Err(fail(*span, "shape.identifier", id_form(kind)));
208 }
209 }
210 let (from, from_span) = values[1];
211 let to = values.get(2).map(|(to, _)| (*to).to_owned());
212 if to.as_deref() == Some(from) {
213 return Err(fail(
214 item.span(),
215 "shape.range",
216 "a rename's from and to differ".into(),
217 ));
218 }
219 let ids = allowlist.entries.entry(kind).or_default();
220 if ids.contains_key(from) {
221 return Err(fail(
222 from_span,
223 "ref.duplicate",
224 format!("{kind} {from} has two entries"),
225 ));
226 }
227 ids.insert(from.to_owned(), to);
228 }
229 }
230 Ok(allowlist)
231 }
232
233 #[must_use]
235 pub fn is_empty(&self) -> bool {
236 self.entries.values().all(BTreeMap::is_empty)
237 }
238
239 pub fn allowances(&self) -> impl Iterator<Item = Allowance> + '_ {
241 self.entries.iter().flat_map(|(kind, ids)| {
242 ids.iter().map(|(from, to)| match to {
243 None => Allowance::Remove {
244 kind: *kind,
245 id: from.clone(),
246 },
247 Some(to) => Allowance::Rename {
248 kind: *kind,
249 from: from.clone(),
250 to: to.clone(),
251 },
252 })
253 })
254 }
255
256 pub(crate) fn get(&self, kind: Kind, id: &str) -> Option<Intent<'_>> {
258 self.entries
259 .get(&kind)?
260 .get(id)
261 .map(|to| to.as_deref().map_or(Intent::Remove, Intent::Rename))
262 }
263}
264
265fn closed(
267 value: &Value,
268 keys: &[&str],
269 fail: &impl Fn(Span, &'static str, String) -> AllowlistError,
270) -> Result<(), AllowlistError> {
271 let members = value
272 .as_object()
273 .ok_or_else(|| fail(value.span(), "shape.type", "expected an object".into()))?;
274 match members
275 .iter()
276 .find(|member| !keys.contains(&member.key.as_str()))
277 {
278 Some(member) => Err(fail(
279 member.key_span,
280 "shape.unknown_key",
281 format!("unknown key; expected {}", keys.join(", ")),
282 )),
283 None => Ok(()),
284 }
285}
286
287fn kinds_message() -> String {
289 let names: Vec<&str> = Kind::ALL.iter().map(|kind| kind.as_str()).collect();
290 match names.split_last() {
291 Some((last, rest)) => format!("kind must be {} or {last}", rest.join(", ")),
292 None => "kind must be a known kind".to_owned(),
293 }
294}
295
296fn id_form(kind: Kind) -> String {
298 let form = match kind {
299 Kind::Definition => "<type>/<id>",
300 Kind::Object | Kind::Plane | Kind::RenderLayer => "an identifier",
301 Kind::Anchor | Kind::Stairwell | Kind::Zone => "<plane>/<id>",
302 };
303 let article = if kind.as_str().starts_with(['a', 'e', 'i', 'o', 'u']) {
304 "an"
305 } else {
306 "a"
307 };
308 format!(
309 "{article} {kind} ID is {form}, with identifiers matching ^[a-z0-9][a-z0-9_.-]{{0,127}}$"
310 )
311}