Skip to main content

qql_core/fmt/
mod.rs

1//! Canonical QQL formatter.
2//!
3//! Parses source text into the typed AST and re-emits it in canonical form.
4//! The output is normalized (canonical clause order, canonical keyword casing,
5//! escaped string literals) while preserving comments, file headers, and
6//! blank-line trivia, and always re-parses to an identical AST:
7//!
8//! ```text
9//! parse(format(parse(input))) == parse(input)
10//! format(format(input))        == format(input)
11//! ```
12
13pub(crate) mod ddl;
14pub(crate) mod expr;
15pub(crate) mod filter;
16pub(crate) mod formula;
17pub(crate) mod index_quota;
18pub(crate) mod mutation;
19pub(crate) mod query;
20pub(crate) mod query_expr;
21
22#[cfg(test)]
23mod tests;
24
25pub use filter::render_filter;
26pub(crate) use mutation::render_point_selector;
27pub(crate) use query::render_search_params;
28
29use crate::ast::Stmt;
30use crate::error::QqlError;
31use crate::parser::Parser;
32use alloc::string::String;
33use alloc::vec::Vec;
34
35/// Default maximum dimensions shown in compact vector previews before `... (N dims)`.
36pub const DEFAULT_PREVIEW_MAX_DIMS: usize = 2;
37
38/// Parse `source` and render it in canonical QQL form, preserving comments and blank lines.
39pub fn format(source: &str) -> Result<String, QqlError> {
40    let statements_with_spans = Parser::parse_all_with_spans(source)?;
41    if statements_with_spans.is_empty() {
42        let (comments, _) = parse_trivia_lines_with_trailing_blank(source);
43        if comments.is_empty() {
44            return Ok(String::new());
45        }
46        let mut out = String::new();
47        for line in comments {
48            out.push_str(&line);
49            out.push('\n');
50        }
51        return Ok(out);
52    }
53
54    let mut out = String::new();
55    let n = statements_with_spans.len();
56
57    // 1. File header before the first statement
58    let first_stmt_start = statements_with_spans[0].1.start;
59    let header_slice = &source[..first_stmt_start];
60    let (header_comments, had_blank_after_header) =
61        parse_trivia_lines_with_trailing_blank(header_slice);
62    if !header_comments.is_empty() {
63        for line in &header_comments {
64            out.push_str(line);
65            out.push('\n');
66        }
67        if had_blank_after_header {
68            out.push('\n');
69        }
70    }
71
72    // 2. Format each statement and following trivia
73    for i in 0..n {
74        let (stmt, stmt_span) = &statements_with_spans[i];
75
76        // Check if there are any inline comments inside the statement body
77        let inner_slice = &source[stmt_span.start..stmt_span.end];
78        let inline_comments = find_comments_in_slice(inner_slice);
79        for comment in inline_comments {
80            out.push_str(comment);
81            out.push('\n');
82        }
83
84        // Render the statement
85        out.push_str(&format_stmt(stmt));
86        out.push(';');
87
88        // Check gap following statement
89        let gap_start = stmt_span.end;
90        let gap_end = if i + 1 < n {
91            statements_with_spans[i + 1].1.start
92        } else {
93            source.len()
94        };
95        let gap_slice = &source[gap_start..gap_end];
96
97        let (same_line, rest) = match gap_slice.find('\n') {
98            Some(idx) => (&gap_slice[..idx], &gap_slice[idx + 1..]),
99            None => (gap_slice, ""),
100        };
101
102        // Trailing comment on the same line after semicolon
103        if let Some(pos) = same_line.find("--") {
104            out.push(' ');
105            out.push_str(same_line[pos..].trim_end());
106        }
107        out.push('\n');
108
109        if i + 1 < n {
110            // Inter-statement gap
111            let (gap_comments, had_blank_after_gap) = parse_trivia_lines_with_trailing_blank(rest);
112            if gap_comments.is_empty() {
113                // No comments: check if there was a blank line between statements
114                if gap_slice.matches('\n').count() >= 2 {
115                    out.push('\n');
116                }
117            } else {
118                // Comments between statements: precede with a blank line for readability
119                out.push('\n');
120                for line in &gap_comments {
121                    out.push_str(line);
122                    out.push('\n');
123                }
124                if had_blank_after_gap {
125                    out.push('\n');
126                }
127            }
128        } else {
129            // Trailing trivia after last statement
130            let (trailer_comments, _) = parse_trivia_lines_with_trailing_blank(rest);
131            if !trailer_comments.is_empty() {
132                out.push('\n');
133                for line in &trailer_comments {
134                    out.push_str(line);
135                    out.push('\n');
136                }
137            }
138        }
139    }
140
141    Ok(out)
142}
143
144fn parse_trivia_lines_with_trailing_blank(slice: &str) -> (Vec<String>, bool) {
145    let mut comments = Vec::new();
146    let mut pending_blank = false;
147
148    let had_blank_after = if let Some(last_comment_idx) = slice.rfind("--") {
149        let after_comment = &slice[last_comment_idx..];
150        if let Some(nl_idx) = after_comment.find('\n') {
151            after_comment[nl_idx + 1..].contains('\n')
152        } else {
153            false
154        }
155    } else {
156        false
157    };
158
159    for line in slice.lines() {
160        let trimmed = line.trim();
161        if trimmed.is_empty() {
162            if !comments.is_empty() {
163                pending_blank = true;
164            }
165        } else if trimmed.starts_with("--") {
166            if pending_blank {
167                comments.push(String::new());
168                pending_blank = false;
169            }
170            comments.push(trimmed.into());
171        }
172    }
173
174    (comments, had_blank_after)
175}
176
177fn find_comments_in_slice(slice: &str) -> Vec<&str> {
178    let mut comments = Vec::new();
179    let bytes = slice.as_bytes();
180    let mut pos = 0;
181    while pos < bytes.len() {
182        match bytes[pos] {
183            b'\'' | b'"' | b'`' => {
184                let quote = bytes[pos];
185                pos += 1;
186                let is_triple =
187                    pos + 1 < bytes.len() && bytes[pos] == quote && bytes[pos + 1] == quote;
188                if is_triple {
189                    pos += 2;
190                    let delim = if quote == b'\'' { "'''" } else { "\"\"\"" };
191                    if let Some(idx) = slice[pos..].find(delim) {
192                        pos += idx + 3;
193                    } else {
194                        break;
195                    }
196                } else {
197                    while pos < bytes.len() {
198                        if bytes[pos] == b'\\' {
199                            pos += 2;
200                            continue;
201                        }
202                        if bytes[pos] == quote {
203                            if quote == b'\'' && pos + 1 < bytes.len() && bytes[pos + 1] == b'\'' {
204                                pos += 2;
205                                continue;
206                            }
207                            pos += 1;
208                            break;
209                        }
210                        pos += 1;
211                    }
212                }
213            }
214            b'-' if pos + 1 < bytes.len() && bytes[pos + 1] == b'-' => {
215                let start = pos;
216                pos += 2;
217                while pos < bytes.len() && bytes[pos] != b'\n' {
218                    pos += 1;
219                }
220                comments.push(slice[start..pos].trim_end());
221            }
222            _ => {
223                pos += 1;
224            }
225        }
226    }
227    comments
228}
229
230/// Render a list of statements as a canonical script (each terminated by `;`,
231/// joined by newlines).
232pub fn format_script(statements: &[Stmt]) -> String {
233    let mut out = String::new();
234    for (i, statement) in statements.iter().enumerate() {
235        if i > 0 {
236            out.push('\n');
237        }
238        out.push_str(&format_stmt(statement));
239        out.push(';');
240    }
241    out
242}
243
244/// Render a single statement in canonical QQL form (no trailing `;`).
245pub fn format_stmt(statement: &Stmt) -> String {
246    match statement {
247        Stmt::Query(query) => query::render_query_body(query),
248        Stmt::Scroll(statement) => mutation::render_scroll(statement),
249        Stmt::Upsert(statement) => mutation::render_upsert(statement),
250        Stmt::CreateCollection(statement) => ddl::render_create_collection(statement),
251        Stmt::CreateIndex(statement) => ddl::render_create_index(statement),
252        Stmt::DropIndex(statement) => ddl::render_drop_index(statement),
253        Stmt::CreateShardKey(statement) => ddl::render_create_shard_key(statement),
254        Stmt::DropShardKey(statement) => ddl::render_drop_shard_key(statement),
255        Stmt::AlterCollection(statement) => ddl::render_alter_collection(statement),
256        Stmt::DropCollection(statement) => ddl::render_drop_collection(statement),
257        Stmt::ShowCollections => ddl::render_show_collections(),
258        Stmt::ShowCollection(collection) => ddl::render_show_collection(collection),
259        Stmt::ShowShardKeys(collection) => ddl::render_show_shard_keys(collection),
260        Stmt::ShowQuotas => ddl::render_show_quotas(),
261        Stmt::SetQuota(stmt) => ddl::render_set_quota(stmt),
262        Stmt::Delete(statement) => mutation::render_delete(statement),
263        Stmt::ClearPayload(statement) => mutation::render_clear_payload(statement),
264        Stmt::DeletePayload(statement) => mutation::render_delete_payload(statement),
265        Stmt::DeleteVector(statement) => mutation::render_delete_vector(statement),
266        Stmt::UpdateVector(statement) => mutation::render_update_vector(statement),
267        Stmt::UpdatePayload(statement) => mutation::render_update_payload(statement),
268        Stmt::Count(statement) => mutation::render_count(statement),
269        Stmt::Facet(statement) => mutation::render_facet(statement),
270        Stmt::Batch(statement) => render_batch(statement),
271    }
272}
273
274/// Render a `BATCH { … }` block with one canonical member per line.
275fn render_batch(statement: &crate::ast::BatchStmt) -> String {
276    let mut out = String::from("BATCH {");
277    for member in &statement.statements {
278        out.push_str("\n  ");
279        // Members may themselves span lines (multi-line upserts); indent
280        // continuations so the block stays readable and re-parseable.
281        let rendered = format_stmt(member).replace('\n', "\n  ");
282        out.push_str(&rendered);
283        out.push(';');
284    }
285    out.push_str("\n}");
286    if let Some(wait) = statement.wait {
287        out.push_str(&alloc::format!(" WAIT {wait}"));
288    }
289    if let Some(params) = &statement.params {
290        out.push_str(&alloc::format!(
291            " PARAMS ({})",
292            render_search_params(params)
293        ));
294    }
295    out
296}
297
298/// Render a single statement in human-readable QQL form with compact vector literals.
299pub fn format_stmt_readable(statement: &Stmt) -> String {
300    crate::params::truncate_vector_literals(&format_stmt(statement), DEFAULT_PREVIEW_MAX_DIMS)
301}