zincite-query 0.5.0

Source-preserving MiniZinc inspection and data queries
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
use std::ffi::OsString;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

use zincite_lint::{
    ModelOptions, SourceLocation, SourceSnapshot, load_model, replace_source_file, write_fix_diff,
};
use zincite_syntax::{FileMode, byte_line_column};

use crate::{ErrorLocation, Input, Limits, Query, QueryError, QueryResult};

const HELP: &str = "Usage: zincite-query [--stdin-filepath PATH] [--model PATH] [-I DIR] [--stdlib-dir DIR] [--diff|--write] QUERY [FILE|-]

Use --program TEXT or -f PROGRAM [FILE|-] for bindings, reports or data batches.
Use let for inputs and calculations; all reads use the original input.
    --program 'let required=items | filter(name(\"required\")) | values | one; sum(required)' data.dzn
The final expression emits its result, with an optional final semicolon.
Bindings require semicolons; report explicitly requests an intermediate result.
Native report example:
    --program 'report subtree | declarations | unique | project(d, {\"name\": d | names | one, \"uses\": d | uses | count});' model.mzn
Native match: match { from d in (subtree | declarations | unique); from u in (d | uses); }
    keeps declaration/use fields for project(m, {\"text\": m.u | text | one}).
Required where guards use type(d, \"int\") or instantiation(d, \"par\").
Unknown guards refuse at their model location after shared Boolean evaluation.
Projection/match are report-only; one requires exactly one projected value.
Program input is exclusive with a legacy positional QUERY.
Reports support typed pure fn declarations, direct/mutual recursion and explicit native inputs.
Functions share work/results with reports; active call depth defaults to 64.
Reports support length, checked integer sum, forall, exists and literal-message require.
Array reducers are eager; these pure expressions do not execute a model or solver.
Programs construct arrays, records and sets with bounded MiniZinc comprehensions.
Data batches use create/replace, native append or bounded scale_enum and end with emit;
all require --model. Each target appears once; final checks include affected unchanged data.
Source-copy stages take one original assignment through values, without one:
    clone_members(ADDITIONAL,PREFIX) creates fresh member maps for an explicit enum set.
    clone_elements(COPIES) copies collection elements with typed member/key remapping.
    repeat_elements(ADDITIONAL) copies elements unchanged, without reindexing keys.
Bind clone_members output, then use append Target = COPIES; or append a copy pipeline.
Copies retain comments, strings and unrelated enums; copied directives are refused.
scale_enum Target = FACTOR; multiplies the literal positive N in C(1..N), checked for overflow.
FACTOR must be positive; constructor-member references outside that assignment are unsupported.
Copy/scale edits validate final literal types, member domains and array coverage.
Computed/optional values, matrices, starting-key arrays and enum subsets are unsupported
by source-copy edits. Affected constructed create/replace targets are refused.
Inspect MiniZinc source or change data assignments and enum membership.
No input or '-' reads stdin; .dzn paths select assignment-only data syntax.
Syntax queries do not load includes or require a MiniZinc library or solver.
QUERY is one fixed pipeline separated by '|'; an empty query selects all items.

Stages:
    items                   Select all top-level items (initial selection)
    filter(PREDICATE)       Keep matching items; not > and > or, with parentheses
    head(N)                 Keep the first N nodes, literal values or strings
    tail(N)                 Keep the last N rows in their current order
    reverse                 Reverse current row order, retaining duplicates
    check                   Require at least one row; pass the stream through
    check(N)                Require exactly N rows; pass the stream through
    check(MESSAGE)          Require a nonempty stream with a failure message
    check(N,MESSAGE)        Require exactly N rows with a failure message
    count                   Return a count; only json may follow
    emit                    Emit selected source or the complete edit candidate; terminal stage
    expressions             Select expression nodes in each selected subtree
    children                Select immediate CST child nodes
    subtree                 Select each node and its descendants, with duplicates
    range(START,END)         Keep nodes contained in an original-input byte range
    references              Select written references inside selected regions
    declarations            Select owned declarations or proven reference targets
    uses                    Select occurrences referring to selected declarations
    transitive_references(N) Follow written declaration references for N waves
    referenced_declarations Follow direct proven targets in an owning declaration
    types                   Inspect native declaration/expression type facts
    instantiations          Inspect available par/var facts and unknown reasons
    names                   Project direct identifier identities
    call_names              Project selected call heads
    annotation_names        Project selected annotation heads
    text                    Project original node text (requires UTF-8)
    unique                  Keep first occurrences of nodes or strings
    tally                   Count projected strings; only json may follow
    values                  Inspect recursive literal values with original ranges
    fields                  Select record field nodes
    elements                Select collection values; matrix cells in row order
    keys                    Select written array and matrix axis keys
    json                    Emit inspection JSON; terminal stage
    set_value(\"EXPRESSION\") Replace selected data assignment right-hand sides
    filter_elements(CMP)    Filter literal set/array/matrix elements in data mode
    remove                  Remove selected data assignments and attached comments
    reduce_enum(NAME,KEEP)   Reduce an explicit enum and supported dependent data
Source/string emission is implicit; edits implicitly emit the complete candidate.
emit_document remains an alias for edit emission.
Projections inspect selected nodes; use subtree to include descendants.
Range bounds are half-open original byte coordinates, including a BOM.
JSON includes node kind, names, file, range and exact UTF-8 text.
Node navigation preserves per-root written order and overlapping duplicates.
Counts describe written occurrences, not execution frequency.
In a positional QUERY, only emit may follow a transformation stage. Edits require data mode.
Comments inside a replaced expression cause a located placement error.
Element comparisons: eq(VALUE), lt(NUMBER), le(NUMBER), gt(NUMBER), ge(NUMBER).
Equality accepts numbers, Booleans, strings and explicit member(\"A\") identities.
Filtering preserves written keys and retained comments; positional lists compact.
Matrices require a common column mask; starting-key bare tails cannot be changed.
Enum retention: keep(\"A\", \"B\") selects identities in original order;
keep_first(N) keeps an original prefix. Empty retention is allowed.
reduce_enum inspects the entire data document, including after filter/head.
Explicit enum keys establish identity; positional axes require model domains.
Supported sets prune removed members; array[int] groups drop newly empty entries.
Literal group accesses use original-to-retained indices; required empty cells stay.
Remaining scalar/computed dependence makes the candidate incomplete.
values accepts head/tail/reverse/check/count/json/emit; other projections continue as node streams.
Predicates: kind(\"assignment\"), name(\"capacity\"); names compare identifier identity.
Semantic predicates: type(\"int\"), instantiation(\"par\"|\"var\"),
    source_kind(\"user\"|\"standard_library\"). Unknown stays unknown under not.
Type kinds: bool, int, float, string, annotation, enum, set, array, tuple, record,
    bottom. Nested unknown types/type variables cannot match a type predicate.
Semantic streams accept filter/head/tail/reverse/check/count/names/text/unique/json/emit;
    names/text yield strings and may use tally. Unrelated CST/edit stages fail.
Semantic source uses actual retained file owners and original BOM coordinates.
references/declarations/uses preserve root order and duplicates; unique uses IDs.
transitive_references starts at declarations or proven reference targets. N=0
    emits none. Expand N waves, visiting roots then file/range/kind order; cycles
    stop by declaration ID, emitted references deduplicate file/range/kind.
Traversal includes signatures, defaults, initializers and nested written regions.
Semantic queries load an explicit model-mode file as root, or use --model.
Semantic stdin requires --model and exact original root bytes, including BOM.
--stdin-filepath is a label and mode, never a model filesystem identity.
Semantic JSON is {complete, limitations, result}, including counts/empty streams.
Located unknown facts and model limitations survive every projection.
These inspect static written relationships; they do not run lint policy or solve.
Item kinds: assignment, declaration, enum, type_alias, function, predicate, test,
       annotation, constraint, include, output, solve.
Navigated node kinds use CST names in snake_case, such as call_expression.
Untouched items emit the whole document; filter/head/tail/reverse emit fragments with
attached comments. Separated section comments and a BOM stay in document output.
Fragment selections that unbalance paired formatter markers fail.
Tail zero is empty; an oversized tail keeps all rows. Reverse keeps duplicates.
Check counts returned rows, including on incomplete semantic results. It does
not inspect literal array elements. Program counts/messages accept scoped values;
positional queries use nonnegative integer counts and double-quoted messages.

Options:
    --program TEXT         Run an inline program; one native expression needs no ';'
    --param name=value     Supply one required int, string or bool input (repeatable)
    -f, --program-file PATH Read bindings, reports or explicit data-edit batches
    --max-work N           Program work allowance (default 1000000)
    --max-results N        Program aggregate entries (default 100000)
    --max-items N          Program stream ceiling (default 100000)
    --max-nesting N        Program nesting ceiling (default 64)
    --stdin-filepath PATH   Select stdin language mode and diagnostic path
    --model PATH            Retain a root for semantic queries or model-backed data edits
    -I DIR                  Add an include directory in supplied order
    --stdlib-dir DIR        MiniZinc library root; overrides MZN_STDLIB_DIR
    --diff                  Preview an editing pipeline/program as a whole-file diff
    --write                 Write a complete candidate; rejects stdin/symlinks
    --                      End option processing
    -h, --help              Show this help

Limits: 1048576 query bytes, 64 nested parentheses/not operators,
        100000 result/expansion entries and 1000000 evaluation work units.
Model tokens/CST nodes consume work once before fact allocation, not result entries.
Exit codes: 0 complete, 1 incomplete semantic report/candidate, 2 query/input/model/usage/I/O errors.
Incomplete previews emit source/diff with stderr dependencies; --write refuses them.
Query, input and usage errors leave stdout empty.
";

#[derive(Clone, Copy, PartialEq, Eq)]
enum OutputMode {
    Source,
    Diff,
    Write,
}

enum RequestSource {
    Query(String),
    Program(String),
    ProgramFile(PathBuf),
}

struct Arguments {
    request_source: RequestSource,
    program_limits: crate::ProgramLimits,
    parameters: Vec<String>,
    path: Option<PathBuf>,
    stdin_filepath: Option<PathBuf>,
    output: OutputMode,
    model_path: Option<PathBuf>,
    model_options: ModelOptions,
}

/// Shared entry point for both command spellings. Arguments exclude the binary
/// or subcommand name. The complete result is buffered before writing stdout.
pub fn run(arguments: Vec<OsString>, invocation: &str) -> ExitCode {
    match execute(arguments, invocation) {
        Ok(status) => ExitCode::from(status),
        Err(message) => {
            let _ = writeln!(io::stderr(), "{message}");
            ExitCode::from(2)
        }
    }
}

fn execute(arguments: Vec<OsString>, invocation: &str) -> Result<u8, String> {
    if arguments.len() == 1 && matches!(arguments[0].to_str(), Some("-h" | "--help")) {
        return io::stdout()
            .write_all(HELP.replacen("zincite-query", invocation, 1).as_bytes())
            .map(|()| 0)
            .map_err(|error| format!("stdout: {error}"));
    }
    let arguments =
        parse_arguments(arguments).map_err(|message| format!("{invocation}: {message}"))?;
    let limits = Limits::default();
    let (program_source, program_label, query_source) = match arguments.request_source {
        RequestSource::Query(source) => (None, None, Some(source)),
        RequestSource::Program(source) => (Some(source), Some("<program>".to_owned()), None),
        RequestSource::ProgramFile(path) => {
            let source = fs::read_to_string(&path)
                .map_err(|error| format!("{}: {error}", path.display()))?;
            (
                Some(source),
                Some(path.to_string_lossy().into_owned()),
                None,
            )
        }
    };
    let program = program_source
        .as_ref()
        .map(|source| {
            crate::Program::parse(source, arguments.program_limits).map_err(|error| {
                located(
                    program_label.as_ref().unwrap(),
                    source.as_bytes(),
                    &error.range,
                    &error.message,
                )
            })
        })
        .transpose()?;
    let query = query_source
        .as_ref()
        .map(|source| {
            Query::parse(source, limits).map_err(|error| query_diagnostic(source, &error))
        })
        .transpose()?;
    if let Some(source) = &query_source {
        zirql::admit_parameters(
            &[],
            zirql::ParameterInputs::Cli(&arguments.parameters),
            &[],
            0..source.len(),
            &mut zirql::Budget::for_program(zirql::ProgramLimits {
                work: arguments.program_limits.work,
                results: arguments.program_limits.results,
                nesting: arguments.program_limits.nesting,
                stream_items: arguments.program_limits.stream_items,
            }),
        )
        .map_err(|error| query_diagnostic(source, &crate::pipeline::core_error(error)))?;
    }
    if arguments.output != OutputMode::Source
        && !query.as_ref().is_some_and(Query::is_editing)
        && !program.as_ref().is_some_and(crate::Program::is_editing)
    {
        return Err(if program.is_some() {
            format!("{invocation}: --diff and --write are unavailable for read-only programs")
        } else {
            format!("{invocation}: --diff and --write require an editing pipeline or program")
        });
    }
    if arguments.output == OutputMode::Write && arguments.path.is_none() {
        return Err(format!(
            "{invocation}: --write requires an explicit regular file; stdin is not supported"
        ));
    }
    let path = arguments
        .path
        .as_ref()
        .or(arguments.stdin_filepath.as_ref());
    let label = path.map_or_else(
        || "<stdin>".to_owned(),
        |path| path.to_string_lossy().into_owned(),
    );
    let mode = path.map_or(FileMode::Model, FileMode::from_path);
    let bytes = if let Some(path) = &arguments.path {
        if arguments.output == OutputMode::Write {
            let metadata =
                fs::symlink_metadata(path).map_err(|error| format!("{label}: {error}"))?;
            if !metadata.file_type().is_file() {
                return Err(format!(
                    "{label}: --write requires a regular non-symlink file"
                ));
            }
        }
        if path.is_dir() {
            return Err(format!("{label}: directory inputs are not supported"));
        }
        fs::read(path).map_err(|error| format!("{label}: {error}"))?
    } else {
        let mut bytes = Vec::new();
        io::stdin()
            .read_to_end(&mut bytes)
            .map_err(|error| format!("{label}: {error}"))?;
        bytes
    };
    let input = Input::parse_named(bytes.clone(), mode, label.clone()).map_err(|error| {
        error
            .diagnostics
            .iter()
            .map(|diagnostic| located(&label, &bytes, &diagnostic.range, &diagnostic.message))
            .collect::<Vec<_>>()
            .join("\n")
    })?;
    let model_path = arguments.model_path.as_ref().or_else(|| {
        (mode == FileMode::Model)
            .then_some(arguments.path.as_ref())
            .flatten()
    });
    let model = model_path
        .filter(|_| {
            (program.is_some() && arguments.model_path.is_some())
                || query.as_ref().is_some_and(Query::requires_model_facts)
                || program
                    .as_ref()
                    .is_some_and(crate::Program::requires_model_facts)
        })
        .map(|path| load_model(path, &arguments.model_options));
    if let Some(model) = &model
        && !model.errors.is_empty()
    {
        return Err(model
            .errors
            .iter()
            .map(|error| model_diagnostic(&error.location, &error.message, "error"))
            .collect::<Vec<_>>()
            .join("\n"));
    }
    if let Some(program) = program {
        let source = program_source.as_ref().unwrap();
        let label = program_label.as_ref().unwrap();
        let diagnostic = |error: QueryError| match &error.location {
            ErrorLocation::Query => located(label, source.as_bytes(), &error.range, &error.message),
            ErrorLocation::Input => located(
                input.file(),
                input.source_bytes(),
                &error.range,
                &error.message,
            ),
            ErrorLocation::Model(location) => model_diagnostic(location, &error.message, "error"),
        };
        let mut result = program
            .evaluate_with_model_and_parameters(
                &input,
                model.as_ref(),
                zirql::ParameterInputs::Cli(&arguments.parameters),
                arguments.program_limits,
            )
            .map_err(diagnostic)?;
        let output = result.render().map_err(diagnostic)?;
        for limitation in result.limitations() {
            writeln!(
                io::stderr(),
                "{}",
                model_diagnostic(&limitation.location, &limitation.message, "incomplete")
            )
            .map_err(|error| format!("stderr: {error}"))?;
        }
        let status = u8::from(!result.is_complete());
        if arguments.output == OutputMode::Write && !result.is_complete() {
            return Ok(1);
        }
        let output = edit_output(
            arguments.output,
            &arguments.path,
            path,
            &bytes,
            output,
            input.file(),
        )?;
        return io::stdout()
            .write_all(&output)
            .map(|()| status)
            .map_err(|error| format!("stdout: {error}"));
    }
    let query = query.unwrap();
    let result = query
        .evaluate_with_model(&input, model.as_ref(), limits)
        .map_err(|error| match error.location {
            ErrorLocation::Query => query_diagnostic(query_source.as_ref().unwrap(), &error),
            ErrorLocation::Input => {
                located(&label, input.source_bytes(), &error.range, &error.message)
            }
            ErrorLocation::Model(ref location) => {
                model_diagnostic(location, &error.message, "error")
            }
        })?;
    let mut status = 0;
    if let QueryResult::Reduction(reduction) = &result
        && !reduction.is_complete()
    {
        status = 1;
        for dependency in reduction.unresolved() {
            writeln!(
                io::stderr(),
                "{}",
                model_diagnostic(&dependency.location, &dependency.message, "incomplete")
            )
            .map_err(|error| format!("stderr: {error}"))?;
        }
        if arguments.output == OutputMode::Write {
            return Ok(1);
        }
    }
    if let QueryResult::Semantic(semantic) = &result
        && !semantic.is_complete()
    {
        status = 1;
        for limitation in semantic.limitations() {
            writeln!(
                io::stderr(),
                "{}",
                model_diagnostic(&limitation.location, &limitation.message, "incomplete")
            )
            .map_err(|error| format!("stderr: {error}"))?;
        }
    }
    let candidate = result.render();
    let output = edit_output(
        arguments.output,
        &arguments.path,
        path,
        &bytes,
        candidate,
        &label,
    )?;
    io::stdout()
        .write_all(&output)
        .map(|()| status)
        .map_err(|error| format!("stdout: {error}"))
}

fn edit_output(
    mode: OutputMode,
    write_path: &Option<PathBuf>,
    label_path: Option<&PathBuf>,
    original: &[u8],
    candidate: Vec<u8>,
    label: &str,
) -> Result<Vec<u8>, String> {
    match mode {
        OutputMode::Source => Ok(candidate),
        OutputMode::Diff => {
            let snapshot = SourceSnapshot::new(
                label_path
                    .cloned()
                    .unwrap_or_else(|| PathBuf::from("<stdin>")),
                original,
            );
            let mut diff = Vec::new();
            write_fix_diff(&snapshot, &candidate, &mut diff)
                .map_err(|error| format!("diff: {error}"))?;
            Ok(diff)
        }
        OutputMode::Write => {
            let snapshot = SourceSnapshot::new(write_path.as_ref().unwrap(), original);
            replace_source_file(&snapshot, &candidate)
                .map_err(|error| format!("{label}: {error}"))?;
            Ok(Vec::new())
        }
    }
}

fn parse_arguments(arguments: Vec<OsString>) -> Result<Arguments, String> {
    let mut positional = Vec::new();
    let mut parameters = Vec::new();
    let mut request_source = None;
    let mut program_limits = crate::ProgramLimits::default();
    let mut explicit_program_limits = false;
    let mut stdin_filepath = None;
    let mut output = OutputMode::Source;
    let mut model_path = None;
    let mut model_options = ModelOptions {
        include_dirs: Vec::new(),
        stdlib_dir: std::env::var_os("MZN_STDLIB_DIR").map(PathBuf::from),
    };
    let mut explicit_stdlib = false;
    let mut options = true;
    let mut arguments = arguments.into_iter();
    while let Some(argument) = arguments.next() {
        if options && argument == "--" {
            options = false;
        } else if options && matches!(argument.to_str(), Some("--diff" | "--write")) {
            if output != OutputMode::Source {
                return Err(
                    "--diff and --write are mutually exclusive and may be supplied only once"
                        .into(),
                );
            }
            output = if argument == "--diff" {
                OutputMode::Diff
            } else {
                OutputMode::Write
            };
        } else if options
            && matches!(
                argument.to_str(),
                Some("--program" | "-f" | "--program-file")
            )
        {
            if request_source.is_some() {
                return Err("--program and --program-file are mutually exclusive and may be supplied only once".into());
            }
            request_source = Some(if argument == "--program" {
                RequestSource::Program(
                    arguments
                        .next()
                        .ok_or("--program requires text")?
                        .into_string()
                        .map_err(|_| "program argument must be UTF-8")?,
                )
            } else {
                RequestSource::ProgramFile(PathBuf::from(
                    arguments.next().ok_or("--program-file requires a path")?,
                ))
            });
        } else if options
            && matches!(
                argument.to_str(),
                Some("--max-work" | "--max-results" | "--max-items" | "--max-nesting")
            )
        {
            let value = arguments
                .next()
                .and_then(|value| value.to_str().and_then(|value| value.parse::<usize>().ok()))
                .ok_or("program limits require a non-negative integer")?;
            match argument.to_str().unwrap() {
                "--max-work" => program_limits.work = value,
                "--max-results" => program_limits.results = value,
                "--max-items" => program_limits.stream_items = value,
                _ => program_limits.nesting = value,
            }
            explicit_program_limits = true;
        } else if options && argument == "--param" {
            parameters.push(
                arguments
                    .next()
                    .ok_or("--param requires name=value")?
                    .into_string()
                    .map_err(|_| "parameter argument must be UTF-8")?,
            );
        } else if options && argument == "--model" {
            if model_path.is_some() {
                return Err("repeated --model".into());
            }
            model_path = Some(PathBuf::from(
                arguments.next().ok_or("--model requires a path")?,
            ));
        } else if options && argument == "-I" {
            model_options.include_dirs.push(PathBuf::from(
                arguments.next().ok_or("-I requires a directory")?,
            ));
        } else if options && argument == "--stdlib-dir" {
            if explicit_stdlib {
                return Err("repeated --stdlib-dir".into());
            }
            explicit_stdlib = true;
            model_options.stdlib_dir = Some(PathBuf::from(
                arguments
                    .next()
                    .ok_or("--stdlib-dir requires a directory")?,
            ));
        } else if options && argument == "--stdin-filepath" {
            if stdin_filepath.is_some() {
                return Err("repeated --stdin-filepath".into());
            }
            stdin_filepath = Some(PathBuf::from(
                arguments.next().ok_or("--stdin-filepath requires a path")?,
            ));
        } else {
            if options && argument != "-" && argument.to_string_lossy().starts_with('-') {
                return Err("unsupported option; use --help for usage".into());
            }
            positional.push(argument);
        }
    }
    let (request_source, path) = if let Some(request_source) = request_source {
        if positional.len() > 1 {
            return Err("program input accepts at most one input file or '-'".into());
        }
        (
            request_source,
            positional
                .pop()
                .map(PathBuf::from)
                .filter(|path| path != Path::new("-")),
        )
    } else {
        if explicit_program_limits {
            return Err("program limits require --program or --program-file".into());
        }
        if positional.is_empty() || positional.len() > 2 {
            return Err("expected one query and at most one input file or '-'".into());
        }
        let path = if positional.len() == 2 {
            positional
                .pop()
                .map(PathBuf::from)
                .filter(|path| path != Path::new("-"))
        } else {
            None
        };
        let query = positional
            .pop()
            .unwrap()
            .into_string()
            .map_err(|_| "query argument must be UTF-8")?;
        (RequestSource::Query(query), path)
    };
    if path.is_some() && stdin_filepath.is_some() {
        return Err("--stdin-filepath requires stdin input".into());
    }
    Ok(Arguments {
        request_source,
        program_limits,
        parameters,
        path,
        stdin_filepath,
        output,
        model_path,
        model_options,
    })
}

fn query_diagnostic(query: &str, error: &QueryError) -> String {
    located("<query>", query.as_bytes(), &error.range, &error.message)
}

fn located(label: &str, bytes: &[u8], range: &std::ops::Range<usize>, message: &str) -> String {
    let (line, column) = byte_line_column(bytes, range.start);
    format!(
        "{label}:{line}:{column}: error: {message} (bytes {}..{})",
        range.start, range.end
    )
}

fn model_diagnostic(location: &SourceLocation, message: &str, level: &str) -> String {
    format!(
        "{}:{}:{}: {level}: {message} (bytes {}..{})",
        location.path.display(),
        location.line,
        location.column,
        location.range.start,
        location.range.end
    )
}