Skip to main content

zincite_query/
lib.rs

1//! Source inspection and explicit data edits over the retained MiniZinc CST.
2//!
3//! [`Input`] owns original bytes and the CST. [`Query`] parses a fixed pipeline
4//! for navigating nodes, inspecting literals or editing data collections/assignments.
5//! Results stay native until source or JSON rendering. Source rendering retains
6//! original bytes, including opaque comment bytes. Literal inspection and
7//! comparisons are bounded and never evaluate computed input expressions or
8//! includes. [`Program`] constructs reports and atomic model-backed data batches through Zirql.
9
10#![forbid(unsafe_code)]
11
12use std::ops::Range;
13
14use zincite_lint::{
15    EditPart, ModelContext, SourceLocation, SourceSnapshot, TextEdit, prepare_text_edits,
16};
17use zincite_syntax::{
18    ByteParsedFile, Diagnostic, FileMode, NodeKind, ParsedFile, SyntaxElement, SyntaxNode,
19    TokenKind, byte_line_column, parse_bytes_with_mode,
20};
21
22pub mod cli;
23mod comprehension;
24mod data;
25mod inspection;
26mod language;
27mod pipeline;
28mod program;
29pub use program::{
30    CaptureMatches, Program, ProgramEmission, ProgramFunction, ProgramLimits, ProgramNative,
31    ProgramResult, ProgramValue, ReportValue, SourceCopies,
32};
33pub use zirql::{ConstructedValue, ParameterDeclaration, ParameterInputs, ParameterType};
34mod reduction;
35mod semantic;
36mod structured;
37pub use inspection::{JsonResult, NodeSelection, SelectedNode};
38pub use reduction::{ReductionResult, UnresolvedDependency};
39pub use semantic::{
40    SemanticEntity, SemanticInstantiation, SemanticResult, SemanticStream, SemanticSubject,
41    SemanticType,
42};
43pub use structured::{
44    ArrayIndexing, LiteralEntry, LiteralField, LiteralKind, LiteralLabel, LiteralRow,
45    LiteralSelection, LiteralValue,
46};
47
48use language::{Stage, StageKind};
49
50/// Finite bounds for query evaluation and expansion.
51/// `work` counts stage visits and scans, including model tokens and CST nodes
52/// admitted once before semantic fact allocation. It is not a wall-clock bound.
53/// `collection` bounds produced selections, results and expansion frontiers;
54/// loaded model source tokens/nodes do not consume this result allowance.
55/// `nesting` lowers the ceiling of 64 query parentheses/`not` operators and
56/// recursive literal levels.
57#[derive(Clone, Copy, Debug)]
58pub struct Limits {
59    pub nesting: usize,
60    pub work: usize,
61    pub collection: usize,
62}
63
64impl Default for Limits {
65    fn default() -> Self {
66        Self {
67            nesting: 64,
68            work: 1_000_000,
69            collection: 100_000,
70        }
71    }
72}
73
74/// Coordinate space of a query failure.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum ErrorLocation {
77    Query,
78    Input,
79    Model(SourceLocation),
80}
81
82#[derive(Clone, Debug, PartialEq, Eq)]
83pub struct QueryError {
84    pub location: ErrorLocation,
85    pub range: Range<usize>,
86    pub message: String,
87}
88
89impl QueryError {
90    fn query(range: Range<usize>, message: impl Into<String>) -> Self {
91        Self {
92            location: ErrorLocation::Query,
93            range,
94            message: message.into(),
95        }
96    }
97
98    fn input(range: Range<usize>, message: impl Into<String>) -> Self {
99        Self {
100            location: ErrorLocation::Input,
101            range,
102            message: message.into(),
103        }
104    }
105}
106
107#[derive(Debug)]
108pub struct InputError {
109    /// Syntax/byte diagnostics in original input coordinates.
110    pub diagnostics: Vec<Diagnostic>,
111}
112
113/// Read-only original input and its byte-preserving parser view.
114#[derive(Debug)]
115pub struct Input {
116    bytes: Vec<u8>,
117    parsed: ByteParsedFile,
118    syntax_offset: usize,
119    mode: FileMode,
120    file: String,
121}
122
123impl Input {
124    pub fn parse(bytes: Vec<u8>, mode: FileMode) -> Result<Self, InputError> {
125        Self::parse_named(bytes, mode, "<input>")
126    }
127
128    /// Parse original bytes with the file identity used by inspection output.
129    pub fn parse_named(
130        bytes: Vec<u8>,
131        mode: FileMode,
132        file: impl Into<String>,
133    ) -> Result<Self, InputError> {
134        let syntax_offset = if bytes.starts_with(b"\xef\xbb\xbf") {
135            3
136        } else {
137            0
138        };
139        let parsed =
140            parse_bytes_with_mode(bytes[syntax_offset..].to_vec(), mode).map_err(|diagnostic| {
141                InputError {
142                    diagnostics: vec![shift_diagnostic(diagnostic, syntax_offset)],
143                }
144            })?;
145        if !parsed.analysis_file().diagnostics().is_empty() {
146            return Err(InputError {
147                diagnostics: parsed
148                    .analysis_file()
149                    .diagnostics()
150                    .iter()
151                    .cloned()
152                    .map(|diagnostic| shift_diagnostic(diagnostic, syntax_offset))
153                    .collect(),
154            });
155        }
156        Ok(Self {
157            bytes,
158            parsed,
159            syntax_offset,
160            mode,
161            file: file.into(),
162        })
163    }
164
165    pub fn file(&self) -> &str {
166        &self.file
167    }
168
169    pub fn source_bytes(&self) -> &[u8] {
170        &self.bytes
171    }
172
173    /// The analysis CST uses offsets after any leading BOM. Use `token_range`
174    /// and selected item ranges for coordinates in the original input.
175    pub fn syntax(&self) -> &ParsedFile {
176        self.parsed.analysis_file()
177    }
178
179    pub fn syntax_offset(&self) -> usize {
180        self.syntax_offset
181    }
182
183    pub fn token_range(&self, index: usize) -> Range<usize> {
184        self.original_range(self.syntax().tokens()[index].range.clone())
185    }
186
187    pub fn token_bytes(&self, index: usize) -> &[u8] {
188        &self.bytes[self.token_range(index)]
189    }
190
191    pub fn line_column(&self, offset: usize) -> (usize, usize) {
192        byte_line_column(&self.bytes, offset)
193    }
194
195    fn original_range(&self, range: Range<usize>) -> Range<usize> {
196        range.start + self.syntax_offset..range.end + self.syntax_offset
197    }
198}
199
200fn shift_diagnostic(mut diagnostic: Diagnostic, offset: usize) -> Diagnostic {
201    diagnostic.range = diagnostic.range.start + offset..diagnostic.range.end + offset;
202    diagnostic
203}
204
205/// Accepted `kind` predicate values, independent of expression node kinds.
206#[derive(Clone, Copy, Debug, PartialEq, Eq)]
207pub enum ItemKind {
208    Assignment,
209    Declaration,
210    Enum,
211    TypeAlias,
212    Function,
213    Predicate,
214    Test,
215    Annotation,
216    Constraint,
217    Include,
218    Output,
219    Solve,
220}
221
222impl ItemKind {
223    pub fn as_str(self) -> &'static str {
224        match self {
225            Self::Assignment => "assignment",
226            Self::Declaration => "declaration",
227            Self::Enum => "enum",
228            Self::TypeAlias => "type_alias",
229            Self::Function => "function",
230            Self::Predicate => "predicate",
231            Self::Test => "test",
232            Self::Annotation => "annotation",
233            Self::Constraint => "constraint",
234            Self::Include => "include",
235            Self::Output => "output",
236            Self::Solve => "solve",
237        }
238    }
239
240    fn parse(value: &str) -> Option<Self> {
241        Some(match value {
242            "assignment" => Self::Assignment,
243            "declaration" => Self::Declaration,
244            "enum" => Self::Enum,
245            "type_alias" => Self::TypeAlias,
246            "function" => Self::Function,
247            "predicate" => Self::Predicate,
248            "test" => Self::Test,
249            "annotation" => Self::Annotation,
250            "constraint" => Self::Constraint,
251            "include" => Self::Include,
252            "output" => Self::Output,
253            "solve" => Self::Solve,
254            _ => return None,
255        })
256    }
257
258    fn from_node(kind: NodeKind) -> Self {
259        match kind {
260            NodeKind::Assignment => Self::Assignment,
261            NodeKind::Declaration => Self::Declaration,
262            NodeKind::EnumDeclaration => Self::Enum,
263            NodeKind::TypeAlias => Self::TypeAlias,
264            NodeKind::FunctionDeclaration => Self::Function,
265            NodeKind::PredicateDeclaration => Self::Predicate,
266            NodeKind::TestDeclaration => Self::Test,
267            NodeKind::AnnotationDeclaration => Self::Annotation,
268            NodeKind::Constraint => Self::Constraint,
269            NodeKind::Include => Self::Include,
270            NodeKind::Output => Self::Output,
271            NodeKind::Solve | NodeKind::SolveMinimize | NodeKind::SolveMaximize => Self::Solve,
272            _ => unreachable!("validated input contains only supported root items"),
273        }
274    }
275
276    fn is_named(self) -> bool {
277        matches!(
278            self,
279            Self::Assignment
280                | Self::Declaration
281                | Self::Enum
282                | Self::TypeAlias
283                | Self::Function
284                | Self::Predicate
285                | Self::Test
286                | Self::Annotation
287        )
288    }
289}
290
291/// One retained item. Ranges use original input coordinates; the borrowed CST
292/// node itself uses the parser's coordinates described by `Input::syntax`.
293#[derive(Clone, Debug)]
294pub struct SelectedItem<'a> {
295    pub node: &'a SyntaxNode,
296    pub kind: ItemKind,
297    /// Identifier identity without quote delimiters; expressions are not names.
298    pub name: Option<&'a str>,
299    pub range: Range<usize>,
300    /// Item plus attached comments and their original surrounding whitespace.
301    pub source_range: Range<usize>,
302}
303
304#[derive(Debug)]
305pub struct Selection<'a> {
306    input: &'a Input,
307    items: Vec<SelectedItem<'a>>,
308    document: bool,
309}
310
311impl<'a> Selection<'a> {
312    pub fn input(&self) -> &'a Input {
313        self.input
314    }
315
316    pub fn items(&self) -> &[SelectedItem<'a>] {
317        &self.items
318    }
319
320    /// Only the untouched initial selection or `items` emits the whole document.
321    pub fn is_document(&self) -> bool {
322        self.document
323    }
324}
325
326#[derive(Debug)]
327pub enum QueryResult<'a> {
328    Selection(Selection<'a>),
329    Count(usize),
330    Nodes(NodeSelection<'a>),
331    Strings(Vec<String>),
332    Literals(LiteralSelection<'a>),
333    Tally(Vec<(String, usize)>),
334    Json(JsonResult<'a>),
335    /// Complete validated candidate from one intentional data transformation.
336    Document(Vec<u8>),
337    /// Reparsed enum reduction with any remaining located dependencies.
338    Reduction(ReductionResult),
339    /// Read-only semantic inspection, retaining its context, facts and limitations.
340    Semantic(SemanticResult<'a>),
341}
342
343impl QueryResult<'_> {
344    /// Render the complete result. Counts use decimal text and one LF; source
345    /// selections and documents reproduce bytes without adding separators or LF.
346    pub fn render(&self) -> Vec<u8> {
347        match self {
348            Self::Nodes(selection) => selection
349                .nodes()
350                .iter()
351                .flat_map(|node| &selection.input().bytes[node.range.clone()])
352                .copied()
353                .collect(),
354            Self::Literals(selection) => selection
355                .values()
356                .iter()
357                .flat_map(|value| value.spelling)
358                .copied()
359                .collect(),
360            Self::Strings(values) => values
361                .iter()
362                .flat_map(|value| value.bytes().chain(std::iter::once(b'\n')))
363                .collect(),
364            Self::Tally(values) => {
365                let mut bytes = serde_json::to_vec(&inspection::tally_json(values))
366                    .expect("string-key counts serialize");
367                bytes.push(b'\n');
368                bytes
369            }
370            Self::Json(result) => result.render(),
371            Self::Count(count) => format!("{count}\n").into_bytes(),
372            Self::Document(bytes) => bytes.clone(),
373            Self::Reduction(result) => result.candidate().source_bytes().to_vec(),
374            Self::Semantic(result) => result.render(),
375            Self::Selection(selection) if selection.document => selection.input.bytes.clone(),
376            Self::Selection(selection) => {
377                let mut bytes = Vec::new();
378                for item in &selection.items {
379                    bytes.extend_from_slice(&selection.input.bytes[item.source_range.clone()]);
380                }
381                bytes
382            }
383        }
384    }
385}
386
387/// One fixed-stage query. Empty text selects and emits the original document.
388#[derive(Clone, Debug)]
389pub struct Query {
390    stages: Vec<Stage>,
391    range: Range<usize>,
392    nesting_limit: usize,
393}
394
395impl Query {
396    pub fn parse(expression: &str, limits: Limits) -> Result<Self, QueryError> {
397        language::parse(expression, limits)
398    }
399
400    pub fn is_editing(&self) -> bool {
401        self.stages.iter().any(|stage| {
402            matches!(
403                stage.kind,
404                StageKind::SetValue(_)
405                    | StageKind::Remove
406                    | StageKind::FilterElements(_)
407                    | StageKind::ReduceEnum(_, _)
408            )
409        })
410    }
411
412    pub fn requires_model_facts(&self) -> bool {
413        self.stages.iter().any(|stage| {
414            matches!(stage.kind, StageKind::ReduceEnum(_, _)) || semantic::requests_facts(stage)
415        })
416    }
417
418    pub fn evaluate<'a>(
419        &self,
420        input: &'a Input,
421        limits: Limits,
422    ) -> Result<QueryResult<'a>, QueryError> {
423        self.evaluate_with_model(input, None, limits)
424    }
425
426    /// Evaluate against reusable root-local facts. The returned semantic result
427    /// retains the owner; each evaluation still charges its own scan and output.
428    pub fn evaluate_with_facts<'a>(
429        &self,
430        input: &'a Input,
431        facts: std::rc::Rc<zincite_lint::ModelFacts<'a>>,
432        limits: Limits,
433    ) -> Result<QueryResult<'a>, QueryError> {
434        let model = facts.model();
435        pipeline::evaluate(self, input, Some(facts), Some(model), limits)
436    }
437
438    /// Semantic inspection borrows the supplied model and requires exact root bytes.
439    /// Enum reduction keeps its separate optional data/model interpretation contract.
440    pub fn evaluate_with_model<'a>(
441        &self,
442        input: &'a Input,
443        model: Option<&'a ModelContext>,
444        limits: Limits,
445    ) -> Result<QueryResult<'a>, QueryError> {
446        let facts = if self.stages.iter().any(semantic::requests_facts) {
447            model.map(|model| std::rc::Rc::new(zincite_lint::ModelFacts::new(model)))
448        } else {
449            None
450        };
451        pipeline::evaluate(self, input, facts, model, limits)
452    }
453}
454
455fn evaluate_stage<'a>(
456    input: &'a Input,
457    model: Option<&'a ModelContext>,
458    result: QueryResult<'a>,
459    stage: &Stage,
460    work: &mut Work,
461    limits: Limits,
462) -> Result<QueryResult<'a>, QueryError> {
463    Ok(match &stage.kind {
464        StageKind::Items => {
465            inspection::require_nodes(&result, &stage.range)?;
466            QueryResult::Selection(Selection {
467                input,
468                items: collect_items(input, limits.collection, work, &stage.range, true)?,
469                document: true,
470            })
471        }
472        StageKind::Filter(_)
473        | StageKind::SharedFilter(_)
474        | StageKind::Head(_)
475        | StageKind::Tail(_)
476        | StageKind::Reverse
477        | StageKind::Check { .. }
478        | StageKind::Count
479        | StageKind::Unique
480        | StageKind::Tally => unreachable!("the core evaluates common stages"),
481        StageKind::Values => structured::values(result, stage, work, limits)?,
482        StageKind::Fields | StageKind::Elements | StageKind::Keys => {
483            structured::project(result, stage, work, limits)?
484        }
485        StageKind::FilterElements(comparison) => QueryResult::Document(
486            structured::transform_elements(input, result, comparison, &stage.range, work, limits)?,
487        ),
488        StageKind::ReduceEnum(name, retention) => {
489            if !matches!(result, QueryResult::Selection(_)) {
490                return Err(inspection::type_error(
491                    &stage.range,
492                    "reduce_enum requires an item selection",
493                ));
494            }
495            QueryResult::Reduction(reduction::reduce(
496                input,
497                model,
498                name,
499                retention,
500                &stage.range,
501                work,
502                limits,
503            )?)
504        }
505        StageKind::SetValue(_) | StageKind::Remove => {
506            let QueryResult::Selection(selection) = result else {
507                return Err(inspection::type_error(
508                    &stage.range,
509                    "transformations require an item selection",
510                ));
511            };
512            QueryResult::Document(transform_assignments(
513                input,
514                &selection.items,
515                stage,
516                work,
517                limits.collection,
518            )?)
519        }
520        StageKind::Emit => {
521            if !matches!(
522                result,
523                QueryResult::Selection(_)
524                    | QueryResult::Nodes(_)
525                    | QueryResult::Strings(_)
526                    | QueryResult::Tally(_)
527                    | QueryResult::Literals(_)
528            ) {
529                return Err(inspection::type_error(
530                    &stage.range,
531                    "emit requires a node, literal or string stream or tally",
532                ));
533            }
534            result
535        }
536        StageKind::EmitDocument => result,
537        StageKind::Expressions | StageKind::Children | StageKind::Subtree | StageKind::Range(_) => {
538            inspection::navigate(result, stage, work, limits.collection)?
539        }
540        StageKind::Names | StageKind::CallNames | StageKind::AnnotationNames | StageKind::Text => {
541            inspection::project(result, stage, work, limits.collection)?
542        }
543        StageKind::Json => inspection::json(result, work, &stage.range)?,
544        StageKind::References
545        | StageKind::Declarations
546        | StageKind::Uses
547        | StageKind::TransitiveReferences(_)
548        | StageKind::ReferencedDeclarations
549        | StageKind::Types
550        | StageKind::Instantiations => {
551            return Err(inspection::type_error(
552                &stage.range,
553                "stage requires semantic inspection",
554            ));
555        }
556    })
557}
558
559// Build one original-coordinate batch, validate its complete data candidate,
560// then return bytes. No selection is evaluated against the changed document.
561fn transform_assignments(
562    input: &Input,
563    items: &[SelectedItem<'_>],
564    stage: &Stage,
565    work: &mut Work,
566    collection_limit: usize,
567) -> Result<Vec<u8>, QueryError> {
568    if input.mode != FileMode::Data {
569        return Err(QueryError::query(
570            stage.range.clone(),
571            "assignment transformations require data-file mode (.dzn)",
572        ));
573    }
574    let replacement = match &stage.kind {
575        StageKind::SetValue(value) => {
576            work.spend(value.len(), &stage.range)?;
577            validate_replacement(value, &stage.range)?;
578            Some(value)
579        }
580        StageKind::Remove => None,
581        _ => unreachable!("only transformation stages construct a candidate"),
582    };
583    // Directive attachments can cross a blank-line boundary. Keep the ordinary
584    // comment attachments too, so separated section comments survive removal.
585    let ordinary_items = if replacement.is_none() {
586        collect_items(input, collection_limit, work, &stage.range, false)?
587    } else {
588        Vec::new()
589    };
590    let mut edits = Vec::with_capacity(items.len());
591    for item in items {
592        work.visit(&stage.range)?;
593        if item.kind != ItemKind::Assignment {
594            return Err(QueryError::input(
595                item.range.clone(),
596                "selected item is not an assignment",
597            ));
598        }
599        let edit = if let Some(value) = replacement {
600            work.spend(value.len(), &stage.range)?;
601            let rhs = assignment_rhs(item.node).ok_or_else(|| {
602                QueryError::input(
603                    item.range.clone(),
604                    "assignment has no single right-hand side",
605                )
606            })?;
607            let range = input.original_range(rhs.range());
608            let first = input
609                .syntax()
610                .tokens()
611                .partition_point(|token| token.range.end + input.syntax_offset <= range.start);
612            for (index, token) in input.syntax().tokens().iter().enumerate().skip(first) {
613                if token.range.start + input.syntax_offset >= range.end {
614                    break;
615                }
616                work.visit(&stage.range)?;
617                if matches!(token.kind, TokenKind::LineComment | TokenKind::BlockComment) {
618                    return Err(QueryError::input(
619                        input.token_range(index),
620                        "cannot replace a right-hand side with internal comments without choosing their new placement",
621                    ));
622                }
623            }
624            TextEdit {
625                range,
626                replacement: vec![EditPart::Text(value.clone())],
627            }
628        } else {
629            let ordinary = ordinary_items
630                .binary_search_by_key(&item.range.start, |item| item.range.start)
631                .unwrap();
632            removal_edit(
633                input,
634                item,
635                &ordinary_items[ordinary].source_range,
636                work,
637                &stage.range,
638            )?
639        };
640        edits.push(edit);
641    }
642    let removed = if replacement.is_none() { items } else { &[] };
643    finish_candidate(input, &edits, removed, work, &stage.range).map(|candidate| candidate.bytes)
644}
645
646fn finish_candidate(
647    input: &Input,
648    edits: &[TextEdit],
649    removed: &[SelectedItem<'_>],
650    work: &mut Work,
651    range: &Range<usize>,
652) -> Result<Input, QueryError> {
653    validate_document_directives(input, removed, work, range)?;
654    let snapshot = SourceSnapshot::new("<query>.dzn", input.source_bytes());
655    let candidate = prepare_text_edits(&snapshot, input.source_bytes(), edits)
656        .map_err(|error| QueryError::query(range.clone(), error.to_string()))?;
657    let candidate_input =
658        Input::parse_named(candidate, FileMode::Data, input.file()).map_err(|error| {
659            QueryError::query(
660                range.clone(),
661                format!(
662                    "invalid data edit candidate: {}",
663                    error.diagnostics[0].message
664                ),
665            )
666        })?;
667    validate_document_directives(&candidate_input, &[], work, range).map_err(|error| {
668        QueryError::query(
669            range.clone(),
670            format!("invalid data edit candidate: {}", error.message),
671        )
672    })?;
673    Ok(candidate_input)
674}
675
676fn removal_edit(
677    input: &Input,
678    item: &SelectedItem<'_>,
679    ordinary_range: &Range<usize>,
680    work: &mut Work,
681    query_range: &Range<usize>,
682) -> Result<TextEdit, QueryError> {
683    let range = item.source_range.clone();
684    let mut deleted = Vec::new();
685    let tokens = input.syntax().tokens();
686    let first =
687        tokens.partition_point(|token| token.range.end + input.syntax_offset <= range.start);
688    for (index, token) in tokens.iter().enumerate().skip(first) {
689        if token.range.start + input.syntax_offset >= range.end {
690            break;
691        }
692        work.visit(query_range)?;
693        if !matches!(token.kind, TokenKind::LineComment | TokenKind::BlockComment)
694            || directive(input.token_bytes(index)).is_none()
695        {
696            continue;
697        }
698        let marker = input.token_range(index);
699        let start = line_indent_start(&input.bytes, marker.start, range.start);
700        let end = tokens
701            .get(index + 1)
702            .filter(|token| token.kind == TokenKind::Whitespace)
703            .map_or(marker.end, |token| token.range.end + input.syntax_offset)
704            .min(range.end);
705        deleted.push(start..end);
706    }
707    deleted.push(ordinary_range.start.max(range.start)..ordinary_range.end.min(range.end));
708    deleted.sort_by_key(|range| (range.start, range.end));
709    let mut replacement = Vec::new();
710    let mut cursor = range.start;
711    for deleted in deleted {
712        if cursor < deleted.start {
713            replacement.push(EditPart::Original(cursor..deleted.start));
714        }
715        cursor = cursor.max(deleted.end);
716    }
717    if cursor < range.end {
718        replacement.push(EditPart::Original(cursor..range.end));
719    }
720    Ok(TextEdit { range, replacement })
721}
722
723fn assignment_rhs(node: &SyntaxNode) -> Option<&SyntaxNode> {
724    let mut children = node.child_nodes();
725    let rhs = children.next()?;
726    children.next().is_none().then_some(rhs)
727}
728
729fn validate_replacement(value: &str, range: &Range<usize>) -> Result<(), QueryError> {
730    let source = format!("zincite_value = {value};");
731    let input = Input::parse(source.into_bytes(), FileMode::Data).map_err(|error| {
732        QueryError::query(
733            range.clone(),
734            format!(
735                "invalid replacement expression: {}",
736                error.diagnostics[0].message
737            ),
738        )
739    })?;
740    // A final item may omit its semicolon. Require our separator to survive
741    // tokenization so a trailing line comment cannot consume original source.
742    if !input
743        .syntax()
744        .tokens()
745        .last()
746        .is_some_and(|token| token.kind == TokenKind::Semicolon)
747    {
748        return Err(QueryError::query(
749            range.clone(),
750            "invalid replacement expression: trailing comment would hide the assignment separator",
751        ));
752    }
753    let mut items = input.syntax().tree().child_nodes();
754    if !items
755        .next()
756        .is_some_and(|item| assignment_rhs(item).is_some())
757        || items.next().is_some()
758    {
759        return Err(QueryError::query(
760            range.clone(),
761            "replacement must contain exactly one expression",
762        ));
763    }
764    Ok(())
765}
766
767fn validate_document_directives(
768    input: &Input,
769    removed: &[SelectedItem<'_>],
770    work: &mut Work,
771    query_range: &Range<usize>,
772) -> Result<(), QueryError> {
773    let mut open = None;
774    let mut removal = 0;
775    let mut root_items = input.syntax().tree().child_nodes().peekable();
776    for (index, token) in input.syntax().tokens().iter().enumerate() {
777        work.visit(query_range)?;
778        if !matches!(token.kind, TokenKind::LineComment | TokenKind::BlockComment) {
779            continue;
780        }
781        let range = input.token_range(index);
782        while removed
783            .get(removal)
784            .is_some_and(|item| item.source_range.end <= range.start)
785        {
786            removal += 1;
787        }
788        let deleted = removed.get(removal).is_some_and(|item| {
789            item.source_range.start <= range.start && range.end <= item.source_range.end
790        });
791        while root_items
792            .peek()
793            .is_some_and(|node| node.range().end + input.syntax_offset <= range.start)
794        {
795            root_items.next();
796        }
797        let marker = directive(input.token_bytes(index));
798        let between_items = root_items
799            .peek()
800            .is_none_or(|node| node.range().start + input.syntax_offset >= range.end);
801        let standalone = standalone(&input.bytes, range.start, input.syntax_offset);
802        if matches!(marker, Some(Directive::Next))
803            && standalone
804            && between_items
805            && let Some(target) = root_items.peek()
806        {
807            let target = target.range().start + input.syntax_offset;
808            let target_deleted = removed
809                .binary_search_by_key(&target, |item| item.range.start)
810                .is_ok();
811            if deleted != target_deleted {
812                return Err(QueryError::input(
813                    range,
814                    "transformation would separate a next-item directive from its target",
815                ));
816            }
817        }
818        if deleted {
819            continue;
820        }
821        if matches!(marker, Some(Directive::Off | Directive::On)) {
822            if !standalone || !between_items {
823                return Err(QueryError::input(
824                    range,
825                    "paired formatter directives must be standalone comments between complete items",
826                ));
827            }
828            match marker {
829                Some(Directive::Off) if open.is_none() => open = Some(range),
830                Some(Directive::On) if open.is_some() => open = None,
831                _ => {
832                    return Err(QueryError::input(
833                        range,
834                        "transformation would unbalance paired formatter directives",
835                    ));
836                }
837            }
838        }
839    }
840    if let Some(range) = open {
841        return Err(QueryError::input(
842            range,
843            "transformation would unbalance paired formatter directives",
844        ));
845    }
846    Ok(())
847}
848
849// Host scans and the shared evaluator spend the same request allowance.
850struct Work<'a> {
851    budget: &'a mut zirql::Budget,
852}
853
854impl Work<'_> {
855    fn produce(&mut self, units: usize, range: &Range<usize>) -> Result<(), QueryError> {
856        self.budget
857            .charge_results(units, zirql::Location::Query(range.clone()))
858            .map_err(pipeline::core_error)
859    }
860
861    fn visit(&mut self, range: &Range<usize>) -> Result<(), QueryError> {
862        self.spend(1, range)
863    }
864
865    fn spend(&mut self, amount: usize, range: &Range<usize>) -> Result<(), QueryError> {
866        self.budget
867            .charge(amount, zirql::Location::Query(range.clone()))
868            .map_err(pipeline::core_error)
869    }
870}
871
872fn identifier_identity(written: &str) -> &str {
873    written
874        .strip_prefix('\'')
875        .and_then(|name| name.strip_suffix('\''))
876        .unwrap_or(written)
877}
878
879fn collect_items<'a>(
880    input: &'a Input,
881    collection_limit: usize,
882    work: &mut Work,
883    range: &Range<usize>,
884    include_directives: bool,
885) -> Result<Vec<SelectedItem<'a>>, QueryError> {
886    let mut items = Vec::new();
887    for node in input.syntax().tree().child_nodes() {
888        work.visit(range)?;
889        if items.len() >= collection_limit {
890            return Err(QueryError::query(
891                range.clone(),
892                "query collection limit exceeded",
893            ));
894        }
895        let kind = ItemKind::from_node(node.kind());
896        let name = kind.is_named().then(|| {
897            node.children().iter().find_map(|child| {
898                let SyntaxElement::Token(index) = child else {
899                    return None;
900                };
901                let token = &input.syntax().tokens()[*index];
902                matches!(
903                    token.kind,
904                    TokenKind::Identifier | TokenKind::QuotedIdentifier
905                )
906                .then(|| identifier_identity(&input.syntax().source()[token.range.clone()]))
907            })
908        });
909        work.produce(1, range)?;
910        let range = input.original_range(node.range());
911        items.push(SelectedItem {
912            node,
913            kind,
914            name: name.flatten(),
915            source_range: range.clone(),
916            range,
917        });
918    }
919    attach_comments(input, &mut items, include_directives);
920    Ok(items)
921}
922
923fn attach_comments(input: &Input, items: &mut [SelectedItem<'_>], include_directives: bool) {
924    let tokens = input.syntax().tokens();
925    for next in 0..=items.len() {
926        let gap_start = if next == 0 {
927            input.syntax_offset
928        } else {
929            items[next - 1].range.end
930        };
931        let gap_end = items
932            .get(next)
933            .map_or(input.bytes.len(), |item| item.range.start);
934        let first =
935            tokens.partition_point(|token| token.range.end + input.syntax_offset <= gap_start);
936        let last =
937            tokens.partition_point(|token| token.range.start + input.syntax_offset < gap_end);
938        let gap_tokens = &tokens[first..last];
939        let mut boundary = gap_start;
940        if next > 0 {
941            // Same-line comments and the first line ending stay with the preceding item.
942            let mut end = gap_start;
943            for token in gap_tokens {
944                let range = input.original_range(token.range.clone());
945                let bytes = &input.bytes[range.clone()];
946                if token.kind == TokenKind::Whitespace {
947                    if let Some(length) = first_line_end(bytes) {
948                        end = range.start + length;
949                        break;
950                    }
951                    end = range.end;
952                } else {
953                    end = range.end;
954                }
955            }
956            items[next - 1].source_range.end = end;
957            boundary = end;
958        }
959        if next < items.len() {
960            let mut start = gap_end;
961            for token in gap_tokens.iter().rev() {
962                let range = input.original_range(token.range.clone());
963                if range.end <= boundary {
964                    break;
965                }
966                let bytes = &input.bytes[range.clone()];
967                if token.kind == TokenKind::Whitespace {
968                    // Keep indentation, but a blank line separates standalone blocks.
969                    start = line_indent_start(&input.bytes, start, boundary);
970                    if line_breaks(bytes) >= 2 {
971                        break;
972                    }
973                } else if standalone(&input.bytes, range.start, boundary) {
974                    start = line_indent_start(&input.bytes, range.start, boundary);
975                } else {
976                    break;
977                }
978            }
979            items[next].source_range.start = start.max(boundary);
980        }
981        if !include_directives {
982            continue;
983        }
984        // Next-item markers stay with their target even across blank lines.
985        // The closing formatter marker belongs to the last item in its region.
986        for token in gap_tokens {
987            let range = input.original_range(token.range.clone());
988            if range.start < boundary || !standalone(&input.bytes, range.start, boundary) {
989                continue;
990            }
991            match directive(&input.bytes[range.clone()]) {
992                Some(Directive::Next | Directive::Off) if next < items.len() => {
993                    items[next].source_range.start = items[next]
994                        .source_range
995                        .start
996                        .min(line_indent_start(&input.bytes, range.start, boundary));
997                }
998                Some(Directive::On) if next > 0 => {
999                    let end = range.end
1000                        + first_line_end(&input.bytes[range.end..gap_end])
1001                            .unwrap_or(gap_end - range.end);
1002                    items[next - 1].source_range.end = items[next - 1].source_range.end.max(end);
1003                    if next < items.len() {
1004                        items[next].source_range.start = items[next].source_range.start.max(end);
1005                    }
1006                }
1007                _ => {}
1008            }
1009        }
1010    }
1011}
1012
1013fn first_line_end(bytes: &[u8]) -> Option<usize> {
1014    bytes
1015        .iter()
1016        .position(|byte| matches!(byte, b'\r' | b'\n'))
1017        .map(|index| {
1018            index + 1 + usize::from(bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n'))
1019        })
1020}
1021
1022fn line_breaks(bytes: &[u8]) -> usize {
1023    bytes
1024        .iter()
1025        .enumerate()
1026        .filter(|(index, byte)| {
1027            **byte == b'\r' || (**byte == b'\n' && (*index == 0 || bytes[*index - 1] != b'\r'))
1028        })
1029        .count()
1030}
1031
1032fn line_indent_start(bytes: &[u8], offset: usize, boundary: usize) -> usize {
1033    let mut start = offset;
1034    while start > boundary && matches!(bytes[start - 1], b' ' | b'\t') {
1035        start -= 1;
1036    }
1037    start
1038}
1039
1040fn standalone(bytes: &[u8], offset: usize, boundary: usize) -> bool {
1041    let start = line_indent_start(bytes, offset, boundary);
1042    start == boundary || matches!(bytes[start - 1], b'\r' | b'\n')
1043}
1044
1045#[derive(Clone, Copy)]
1046enum Directive {
1047    Next,
1048    Off,
1049    On,
1050}
1051
1052fn directive(bytes: &[u8]) -> Option<Directive> {
1053    let text = std::str::from_utf8(bytes).ok()?.trim();
1054    if let Some(command) = text.strip_prefix("% zincite-fmt:").map(str::trim) {
1055        match command {
1056            "skip" => Some(Directive::Next),
1057            "off" => Some(Directive::Off),
1058            "on" => Some(Directive::On),
1059            _ => None,
1060        }
1061    } else if text
1062        .strip_prefix("% zincite-lint:")
1063        .map(str::trim)
1064        .is_some_and(|command| command == "ignore" || command.starts_with("ignore "))
1065    {
1066        Some(Directive::Next)
1067    } else {
1068        None
1069    }
1070}
1071
1072fn validate_fragment_directives(
1073    input: &Input,
1074    items: &[SelectedItem<'_>],
1075    work: &mut Work<'_>,
1076    query_range: &Range<usize>,
1077) -> Result<(), QueryError> {
1078    // Source order is only for attachment lookup; rendering keeps the selected order.
1079    let search_work = items
1080        .len()
1081        .checked_mul(items.len().max(1).ilog2() as usize + 1)
1082        .and_then(|units| units.checked_mul(2))
1083        .ok_or_else(|| {
1084            QueryError::query(query_range.clone(), "fragment validation size overflow")
1085        })?;
1086    work.spend(search_work, query_range)?;
1087    work.produce(items.len(), query_range)?;
1088    let mut source_order: Vec<_> = items.iter().collect();
1089    source_order.sort_unstable_by_key(|item| item.source_range.start);
1090    for pair in source_order.windows(2) {
1091        work.visit(query_range)?;
1092        if pair[0].source_range.end > pair[1].source_range.start {
1093            return Err(QueryError::input(
1094                pair[1].source_range.clone(),
1095                "fragment selection would overlap directive attachments",
1096            ));
1097        }
1098    }
1099    let mut selected = 0;
1100    let mut root_items = input.syntax().tree().child_nodes().peekable();
1101    for (index, token) in input.syntax().tokens().iter().enumerate() {
1102        work.visit(query_range)?;
1103        if !matches!(token.kind, TokenKind::LineComment | TokenKind::BlockComment) {
1104            continue;
1105        }
1106        let range = input.token_range(index);
1107        work.spend(range.len(), query_range)?;
1108        while root_items
1109            .peek()
1110            .is_some_and(|node| node.range().end + input.syntax_offset <= range.start)
1111        {
1112            work.visit(query_range)?;
1113            root_items.next();
1114        }
1115        while source_order
1116            .get(selected)
1117            .is_some_and(|item| item.source_range.end <= range.start)
1118        {
1119            work.visit(query_range)?;
1120            selected += 1;
1121        }
1122        let retained = source_order.get(selected).filter(|item| {
1123            item.source_range.start <= range.start && range.end <= item.source_range.end
1124        });
1125        if matches!(directive(input.token_bytes(index)), Some(Directive::Next))
1126            && standalone(&input.bytes, range.start, input.syntax_offset)
1127            && root_items
1128                .peek()
1129                .is_some_and(|node| node.range().start + input.syntax_offset >= range.end)
1130        {
1131            let target = root_items.peek().unwrap().range().start + input.syntax_offset;
1132            work.spend(items.len().max(1).ilog2() as usize + 1, query_range)?;
1133            let target_item = source_order
1134                .binary_search_by_key(&target, |item| item.range.start)
1135                .ok();
1136            if retained.is_some() != target_item.is_some()
1137                || target_item.is_some_and(|index| retained.is_some() && index != selected)
1138            {
1139                return Err(QueryError::input(
1140                    range,
1141                    "fragment selection would separate a next-item directive from its target",
1142                ));
1143            }
1144        }
1145    }
1146    let mut open = None;
1147    let tokens = input.syntax().tokens();
1148    for item in items {
1149        work.spend(tokens.len().max(1).ilog2() as usize + 1, query_range)?;
1150        let first = tokens.partition_point(|token| {
1151            token.range.end + input.syntax_offset <= item.source_range.start
1152        });
1153        for (index, token) in tokens.iter().enumerate().skip(first) {
1154            work.visit(query_range)?;
1155            let range = input.token_range(index);
1156            if range.start >= item.source_range.end {
1157                break;
1158            }
1159            if !matches!(token.kind, TokenKind::LineComment | TokenKind::BlockComment) {
1160                continue;
1161            }
1162            work.spend(range.len(), query_range)?;
1163            match directive(input.token_bytes(index)) {
1164                Some(Directive::Off) if open.is_none() => open = Some(range),
1165                Some(Directive::On) if open.is_some() => open = None,
1166                Some(Directive::Off | Directive::On) => {
1167                    return Err(QueryError::input(
1168                        range,
1169                        "fragment selection would unbalance paired formatter directives",
1170                    ));
1171                }
1172                _ => {}
1173            }
1174        }
1175    }
1176    if let Some(range) = open {
1177        return Err(QueryError::input(
1178            range,
1179            "fragment selection would unbalance paired formatter directives",
1180        ));
1181    }
1182    Ok(())
1183}