Skip to main content

grow_wedding/
grow-wedding.rs

1//! Grow the documented seating data shape without changing the input file.
2//!
3//! Run from a checkout: cargo run -p zincite-query --example grow-wedding -- INPUT.dzn FACTOR
4use 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    // This example understands these eight assignments, not arbitrary dependencies.
82    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    // Refuse formatter directives: copying their text could change directive scopes.
103    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    // Charge the exact final byte count before the public edit API allocates it.
217    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
273// Remapping replaces one identifier token, never parentheses or attached comments.
274fn 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}