1use std::collections::{BTreeMap, BTreeSet};
5use std::fs::File;
6use std::io::{Read, Write};
7use std::ops::Range;
8
9use zincite_lint::{EditPart, SourceSnapshot, TextEdit, prepare_text_edits};
10use zincite_query::{
11 ArrayIndexing, Input, ItemKind, Limits, LiteralKind, LiteralValue, Query, QueryResult,
12};
13use zincite_syntax::{FileMode, TokenKind};
14
15const MAX_INPUT: usize = 1024 * 1024;
16const MAX_OUTPUT: usize = 16 * 1024 * 1024;
17const MAX_TOKENS: usize = 200_000;
18const MAX_FACTOR: usize = 1000;
19const ASSIGNMENTS: [&str; 8] = [
20 "Topics",
21 "Guests",
22 "guests",
23 "same_table",
24 "different_tables",
25 "Tables",
26 "table_capacities",
27 "max_gender_imbalance_per_table",
28];
29
30type Result<T> = std::result::Result<T, String>;
31
32fn main() {
33 if let Err(message) = run() {
34 eprintln!("grow-wedding: {message}");
35 std::process::exit(2);
36 }
37}
38
39fn run() -> Result<()> {
40 let args: Vec<_> = std::env::args_os().skip(1).collect();
41 if args.len() != 2 {
42 return Err(
43 "usage: grow-wedding INPUT.dzn FACTOR (total copies, including the original)".into(),
44 );
45 }
46 let factor = args[1]
47 .to_str()
48 .and_then(|text| text.parse::<usize>().ok())
49 .ok_or("FACTOR must be a positive integer")?;
50 let mut bytes = Vec::new();
51 File::open(&args[0])
52 .map_err(|error| error.to_string())?
53 .take((MAX_INPUT + 1) as u64)
54 .read_to_end(&mut bytes)
55 .map_err(|error| error.to_string())?;
56 let candidate = grow(bytes, factor)?;
57 std::io::stdout()
58 .lock()
59 .write_all(&candidate)
60 .map_err(|error| error.to_string())
61}
62
63fn grow(bytes: Vec<u8>, factor: usize) -> Result<Vec<u8>> {
64 if !(1..=MAX_FACTOR).contains(&factor) {
65 return Err(format!("FACTOR must be in 1..={MAX_FACTOR}"));
66 }
67 if bytes.len() > MAX_INPUT {
68 return Err("input exceeds 1 MiB".into());
69 }
70 let input = Input::parse_named(bytes, FileMode::Data, "wedding.dzn")
71 .map_err(|error| format!("invalid data: {error:?}"))?;
72 if input
73 .syntax()
74 .tokens()
75 .len()
76 .checked_mul(factor)
77 .is_none_or(|n| n > MAX_TOKENS)
78 {
79 return Err("grown input exceeds the 200,000-token allowance".into());
80 }
81 let QueryResult::Selection(items) = Query::parse("items", Limits::default())
83 .map_err(|error| error.message)?
84 .evaluate(&input, Limits::default())
85 .map_err(|error| error.message)?
86 else {
87 unreachable!()
88 };
89 let mut names = BTreeSet::new();
90 for item in items.items() {
91 if item.kind != ItemKind::Assignment
92 || !item
93 .name
94 .is_some_and(|name| ASSIGNMENTS.contains(&name) && names.insert(name))
95 {
96 return Err("expected only the eight distinct wedding data assignments".into());
97 }
98 }
99 if names.len() != ASSIGNMENTS.len() {
100 return Err("all eight wedding data assignments are required".into());
101 }
102 for (index, token) in input.syntax().tokens().iter().enumerate() {
104 if matches!(token.kind, TokenKind::LineComment | TokenKind::BlockComment)
105 && input
106 .token_bytes(index)
107 .windows(b"zincite-fmt:".len())
108 .any(|w| w == b"zincite-fmt:")
109 {
110 return Err("comments containing zincite directives are unsupported".into());
111 }
112 }
113 let topics = literal(&input, "Topics")?;
114 let guests = literal(&input, "Guests")?;
115 let records = literal(&input, "guests")?;
116 let same = literal(&input, "same_table")?;
117 let different = literal(&input, "different_tables")?;
118 let capacities = literal(&input, "table_capacities")?;
119 let imbalance = literal(&input, "max_gender_imbalance_per_table")?;
120 let topic_members = members(&topics)?;
121 let guest_members = members(&guests)?;
122 if topic_members.is_empty() || guest_members.is_empty() {
123 return Err("Topics and Guests must be nonempty literal member sets".into());
124 }
125 let topic_names: BTreeSet<_> = topic_members.iter().map(|(name, _)| *name).collect();
126 let guest_names: BTreeSet<_> = guest_members.iter().map(|(name, _)| *name).collect();
127 if !topic_names.is_disjoint(&guest_names)
128 || topic_names
129 .iter()
130 .chain(guest_names.iter())
131 .any(|name| ASSIGNMENTS.contains(name) || ["Man", "Woman", "NA", "T"].contains(name))
132 {
133 return Err("enum members collide with the wedding model's identifiers".into());
134 }
135 validate_records(&records, &guest_names, topic_members.len())?;
136 let same_members = group_members(&same, &guest_names)?;
137 let different_members = group_members(&different, &guest_names)?;
138 let table_item = items
139 .items()
140 .iter()
141 .find(|item| item.name == Some("Tables"))
142 .unwrap();
143 let (table_count, table_range) = table_constructor(&input, &table_item.range)?;
144 let capacity_values = positional(&capacities)?;
145 if capacity_values.len() != table_count
146 || capacity_values
147 .iter()
148 .any(|value| !matches!(value.kind, LiteralKind::Integer))
149 || !matches!(imbalance.kind, LiteralKind::Integer)
150 {
151 return Err("table_capacities must contain one literal integer per table; imbalance must be a literal integer".into());
152 }
153 let grown_tables = table_count
154 .checked_mul(factor)
155 .filter(|count| i64::try_from(*count).is_ok())
156 .ok_or("table count overflow")?;
157 if factor == 1 {
158 return Ok(input.source_bytes().to_vec());
159 }
160 let mut occupied: BTreeSet<String> = input
161 .syntax()
162 .tokens()
163 .iter()
164 .enumerate()
165 .filter(|(_, token)| {
166 matches!(
167 token.kind,
168 TokenKind::Identifier | TokenKind::QuotedIdentifier
169 )
170 })
171 .map(|(index, _)| {
172 String::from_utf8_lossy(input.token_bytes(index))
173 .trim_matches('\'')
174 .to_owned()
175 })
176 .collect();
177 let mut mappings = Vec::new();
178 for copy in 1..factor {
179 let mut mapping = BTreeMap::new();
180 for (index, (name, _)) in guest_members.iter().enumerate() {
181 let generated = format!("GrownGuest_{copy}_{}", index + 1);
182 if !occupied.insert(generated.clone()) {
183 return Err(format!("generated guest identifier collides: {generated}"));
184 }
185 mapping.insert(*name, generated);
186 }
187 mappings.push(mapping);
188 }
189 let LiteralKind::Array { entries, .. } = &records.kind else {
190 unreachable!()
191 };
192 let record_keys: Vec<_> = entries
193 .iter()
194 .map(|entry| {
195 let key = entry.key.as_ref().unwrap();
196 let LiteralKind::Member(name) = key.kind else {
197 unreachable!()
198 };
199 (name, key.range.clone())
200 })
201 .collect();
202 let mut edits = Vec::new();
203 for (value, replacements) in [
204 (&guests, guest_members),
205 (&records, record_keys),
206 (&same, same_members),
207 (&different, different_members),
208 (&capacities, Vec::new()),
209 ] {
210 edits.push(copy_collection(&input, value, &replacements, &mappings)?);
211 }
212 edits.push(TextEdit {
213 range: table_range,
214 replacement: vec![EditPart::Text(grown_tables.to_string())],
215 });
216 let mut output_len = input.source_bytes().len();
218 for edit in &edits {
219 output_len -= edit.range.len();
220 for part in &edit.replacement {
221 let len = match part {
222 EditPart::Text(text) => text.len(),
223 EditPart::Original(range) => range.len(),
224 };
225 output_len = output_len
226 .checked_add(len)
227 .filter(|n| *n <= MAX_OUTPUT)
228 .ok_or("candidate exceeds 16 MiB")?;
229 }
230 }
231 let snapshot = SourceSnapshot::new("wedding.dzn", input.source_bytes());
232 let candidate = prepare_text_edits(&snapshot, input.source_bytes(), &edits)
233 .map_err(|error| error.to_string())?;
234 Input::parse_named(candidate.clone(), FileMode::Data, "grown-wedding.dzn")
235 .map_err(|error| format!("invalid candidate: {error:?}"))?;
236 Ok(candidate)
237}
238
239fn literal<'a>(input: &'a Input, name: &str) -> Result<LiteralValue<'a>> {
240 let query = Query::parse(
241 &format!("filter(name(\"{name}\")) | values"),
242 Limits::default(),
243 )
244 .map_err(|error| error.message)?;
245 let QueryResult::Literals(values) = query
246 .evaluate(input, Limits::default())
247 .map_err(|error| error.message)?
248 else {
249 unreachable!()
250 };
251 match values.values() {
252 [value] => Ok(value.clone()),
253 _ => Err(format!("expected one {name} assignment")),
254 }
255}
256
257fn members<'a>(value: &LiteralValue<'a>) -> Result<Vec<(&'a str, Range<usize>)>> {
258 let LiteralKind::Set(values) = &value.kind else {
259 return Err("expected a literal enum member set".into());
260 };
261 let mut seen = BTreeSet::new();
262 values
263 .iter()
264 .map(|value| match value.kind {
265 LiteralKind::Member(name) if plain_member(value) && seen.insert(name) => {
266 Ok((name, value.range.clone()))
267 }
268 _ => Err("expected distinct literal enum members".into()),
269 })
270 .collect()
271}
272
273fn plain_member(value: &LiteralValue<'_>) -> bool {
275 let LiteralKind::Member(name) = value.kind else {
276 return false;
277 };
278 value.spelling == name.as_bytes()
279 || value
280 .spelling
281 .strip_prefix(b"'")
282 .and_then(|s| s.strip_suffix(b"'"))
283 == Some(name.as_bytes())
284}
285
286fn positional<'a, 'b>(value: &'b LiteralValue<'a>) -> Result<Vec<&'b LiteralValue<'a>>> {
287 match &value.kind {
288 LiteralKind::Array {
289 indexing: ArrayIndexing::Positional,
290 entries,
291 } => Ok(entries.iter().map(|entry| &entry.value).collect()),
292 _ => Err("expected a positional literal array".into()),
293 }
294}
295
296fn validate_records(
297 value: &LiteralValue<'_>,
298 guests: &BTreeSet<&str>,
299 topics: usize,
300) -> Result<()> {
301 let LiteralKind::Array {
302 indexing: ArrayIndexing::Explicit,
303 entries,
304 } = &value.kind
305 else {
306 return Err("guests must be an explicitly guest-keyed record array".into());
307 };
308 let mut keys = BTreeSet::new();
309 for entry in entries {
310 let Some(LiteralValue {
311 kind: LiteralKind::Member(name),
312 ..
313 }) = &entry.key
314 else {
315 return Err("each guest record needs an enum member key".into());
316 };
317 if !plain_member(entry.key.as_ref().unwrap())
318 || !guests.contains(name)
319 || !keys.insert(*name)
320 {
321 return Err("guest keys must cover Guests exactly once".into());
322 }
323 let LiteralKind::Record(fields) = &entry.value.kind else {
324 return Err("expected a guest record".into());
325 };
326 let mut labels = BTreeSet::new();
327 for field in fields {
328 if !labels.insert(field.label.name) {
329 return Err("duplicate guest record field".into());
330 }
331 let valid = match field.label.name {
332 "name" | "surname" => matches!(field.value.kind, LiteralKind::String),
333 "gender" => matches!(
334 field.value.kind,
335 LiteralKind::Member("Man" | "Woman" | "NA")
336 ),
337 "interest" => positional(&field.value).is_ok_and(|values| {
338 values.len() == topics
339 && values
340 .iter()
341 .all(|v| matches!(v.kind, LiteralKind::Integer))
342 }),
343 _ => false,
344 };
345 if !valid {
346 return Err(format!("unsupported guest field: {}", field.label.name));
347 }
348 }
349 if labels != BTreeSet::from(["name", "surname", "gender", "interest"]) {
350 return Err("guest records require name, surname, gender and interest".into());
351 }
352 }
353 if &keys != guests {
354 return Err("guest keys must cover Guests exactly once".into());
355 }
356 Ok(())
357}
358
359fn group_members<'a>(
360 value: &LiteralValue<'a>,
361 guests: &BTreeSet<&str>,
362) -> Result<Vec<(&'a str, Range<usize>)>> {
363 let mut result = Vec::new();
364 for group in positional(value)? {
365 for (name, range) in members(group)? {
366 if !guests.contains(name) {
367 return Err("group contains an unknown guest".into());
368 }
369 result.push((name, range));
370 }
371 }
372 Ok(result)
373}
374
375fn table_constructor(input: &Input, range: &Range<usize>) -> Result<(usize, Range<usize>)> {
376 let tokens: Vec<_> = input
377 .syntax()
378 .tokens()
379 .iter()
380 .enumerate()
381 .filter(|(index, token)| {
382 range.contains(&input.token_range(*index).start)
383 && !matches!(
384 token.kind,
385 TokenKind::Whitespace | TokenKind::LineComment | TokenKind::BlockComment
386 )
387 })
388 .map(|(index, _)| index)
389 .collect();
390 if tokens.len() != 9
391 || tokens.iter().enumerate().any(|(position, index)| {
392 position != 6
393 && input.token_bytes(*index)
394 != [
395 b"Tables".as_slice(),
396 b"=",
397 b"T",
398 b"(",
399 b"1",
400 b"..",
401 b"",
402 b")",
403 b";",
404 ][position]
405 })
406 {
407 return Err("Tables must have the exact constructor shape T(1..N)".into());
408 }
409 let end = input.token_bytes(tokens[6]);
410 if end.is_empty() || !end.iter().all(u8::is_ascii_digit) {
411 return Err("table count must be a positive decimal integer".into());
412 }
413 let count = std::str::from_utf8(end)
414 .unwrap()
415 .parse::<usize>()
416 .map_err(|_| "table count overflow")?;
417 if count == 0 {
418 return Err("table count must be positive".into());
419 }
420 Ok((count, input.token_range(tokens[6])))
421}
422
423fn copy_collection(
424 input: &Input,
425 value: &LiteralValue<'_>,
426 replacements: &[(&str, Range<usize>)],
427 mappings: &[BTreeMap<&str, String>],
428) -> Result<TextEdit> {
429 let bytes = input.source_bytes();
430 let range = value.range.clone();
431 if !matches!(
432 (bytes[range.start], bytes[range.end - 1]),
433 (b'[', b']') | (b'{', b'}')
434 ) {
435 return Err("collection literals must use direct brackets or braces".into());
436 }
437 let inside = range.start + 1..range.end - 1;
438 let last = input
439 .syntax()
440 .tokens()
441 .iter()
442 .enumerate()
443 .rev()
444 .find(|(index, token)| {
445 inside.contains(&input.token_range(*index).start)
446 && !matches!(
447 token.kind,
448 TokenKind::Whitespace | TokenKind::LineComment | TokenKind::BlockComment
449 )
450 });
451 let mut parts = vec![EditPart::Original(range.start..inside.end)];
452 if let Some((_, last)) = last {
453 for mapping in mappings {
454 if last.kind != TokenKind::Comma {
455 parts.push(EditPart::Text(",".into()));
456 }
457 let mut cursor = inside.start;
458 for (name, replaced) in replacements {
459 parts.push(EditPart::Original(cursor..replaced.start));
460 parts.push(EditPart::Text(mapping[*name].clone()));
461 cursor = replaced.end;
462 }
463 parts.push(EditPart::Original(cursor..inside.end));
464 }
465 }
466 parts.push(EditPart::Original(inside.end..range.end));
467 Ok(TextEdit {
468 range,
469 replacement: parts,
470 })
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476 const DATA: &[u8] = include_bytes!("fixtures/wedding.dzn");
477
478 #[test]
479 fn copies_guest_keys_and_group_members_without_rewriting_records_or_comments() {
480 let candidate = grow(DATA.to_vec(), 2).unwrap();
481 let text = std::str::from_utf8(&candidate).unwrap();
482 assert!(text.contains("Tables = T(1..4);"));
483 assert!(text.contains("GrownGuest_1_1: (name: \"A\""));
484 assert!(text.contains("{GrownGuest_1_1, GrownGuest_1_2}"));
485 assert_eq!(text.matches("% inside the record").count(), 2);
486 assert_eq!(text.matches("% between records").count(), 2);
487 assert!(candidate.starts_with(b"% unchanged heading\nTopics = {Music, Food};\n"));
488 assert!(candidate.ends_with(b"max_gender_imbalance_per_table = 1; % unchanged setting\n"));
489 let input = Input::parse(candidate, FileMode::Data).unwrap();
490 assert_eq!(
491 members(&literal(&input, "Guests").unwrap()).unwrap().len(),
492 4
493 );
494 assert_eq!(
495 positional(&literal(&input, "table_capacities").unwrap())
496 .unwrap()
497 .len(),
498 4
499 );
500 assert_eq!(grow(DATA.to_vec(), 1).unwrap(), DATA);
501 }
502
503 #[test]
504 fn refuses_computed_groups_collisions_and_excessive_growth() {
505 let source = std::str::from_utf8(DATA).unwrap();
506 let computed = source.replace("same_table = [{A, B}]", "same_table = [{A} union {B}]");
507 assert!(grow(computed.into_bytes(), 2).is_err());
508 let collision = source.replace("Topics = {Music, Food}", "Topics = {GrownGuest_1_1, Food}");
509 assert!(
510 grow(collision.into_bytes(), 2)
511 .unwrap_err()
512 .contains("collides")
513 );
514 assert!(grow(DATA.to_vec(), 0).is_err());
515 assert!(grow(DATA.to_vec(), MAX_FACTOR + 1).is_err());
516 assert!(grow(vec![b' '; MAX_INPUT + 1], 2).is_err());
517 }
518}