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,
}
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
)
}