Skip to main content

sql_dialect_fmt_formatter/
range.rs

1//! Statement-level range formatting.
2//!
3//! Whole-document formatting reflows everything; range formatting reflows only the **top-level
4//! statements that intersect a caller-supplied byte range** and leaves the rest of the document
5//! byte-for-byte unchanged. This mirrors editor "Format Selection" and an LSP `rangeFormatting`
6//! request, and is built on the same lossless CST as [`crate::format`].
7//!
8//! The unit of work is a whole statement: a statement is reformatted if the range touches any of it.
9//! The returned [`RangeEdit`] replaces the span from the first selected statement's first significant
10//! token through the last selected statement's terminating semicolon, so leading blank lines and
11//! same-line trailing comments around the selection are preserved exactly.
12
13use std::ops::Range;
14
15use sql_dialect_fmt_syntax::{SyntaxKind, SyntaxNode};
16
17use crate::{format, FormatOptions};
18
19/// A minimal edit produced by [`format_range`]: replace `range` (byte offsets into the original
20/// source) with `new_text`. Everything outside `range` is unchanged.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct RangeEdit {
23    /// Byte offsets into the original source that should be replaced.
24    pub range: Range<usize>,
25    /// Replacement text for `range`.
26    pub new_text: String,
27}
28
29/// Reformat only the top-level statements intersecting `target` (byte offsets into `source`),
30/// leaving the rest of the document unchanged.
31///
32/// Returns `None` when nothing should change: the source cannot be parsed cleanly (same conservative
33/// contract as [`crate::format`]), the range touches no statement, or the selection is already
34/// formatted.
35pub fn format_range(
36    source: &str,
37    target: Range<usize>,
38    options: &FormatOptions,
39) -> Option<RangeEdit> {
40    // Only reflow input the parser fully accepts, exactly like whole-document formatting: a dirty
41    // parse means we keep everything verbatim rather than risk mangling a fragmented tree.
42    let parse = sql_dialect_fmt_parser::parse_with_dialect(source, options.dialect);
43    if !parse.errors().is_empty() {
44        return None;
45    }
46
47    let statements = collect_statements(&parse.syntax());
48    let mut selected = statements
49        .iter()
50        .filter(|statement| statement.intersects(&target));
51    let first = selected.next()?;
52    let last = selected.next_back().unwrap_or(first);
53
54    let block_range = first.sig_start..last.full_end;
55    let block = source.get(block_range.clone())?;
56    let formatted = format(block, options);
57    // The block ends at a semicolon (or statement end), never a newline, so `format` always appends
58    // one that the untouched tail already provides — drop it so a same-line trailing comment in the
59    // tail stays on the statement's line instead of being pushed down.
60    let new_text = strip_one_trailing_newline(&formatted);
61
62    if new_text == block {
63        return None;
64    }
65    Some(RangeEdit {
66        range: block_range,
67        new_text: new_text.to_string(),
68    })
69}
70
71/// A top-level statement's significant span: from its first non-trivia token through its terminating
72/// semicolon (or the node's end when it has none).
73struct StatementSpan {
74    sig_start: usize,
75    full_end: usize,
76}
77
78impl StatementSpan {
79    /// Whether `target` overlaps `[sig_start, full_end]` (inclusive, so a cursor at either edge or
80    /// anywhere inside the statement counts).
81    fn intersects(&self, target: &Range<usize>) -> bool {
82        self.sig_start <= target.end && target.start <= self.full_end
83    }
84}
85
86fn collect_statements(root: &SyntaxNode) -> Vec<StatementSpan> {
87    let mut spans = Vec::new();
88    // A statement node is followed by a root-level `;` token; pair them up as we walk in order.
89    let mut pending: Option<(usize, usize)> = None; // (sig_start, node_end)
90    for element in root.children_with_tokens() {
91        if let Some(node) = element.as_node() {
92            if let Some((sig_start, node_end)) = pending.take() {
93                spans.push(StatementSpan {
94                    sig_start,
95                    full_end: node_end,
96                });
97            }
98            let node_start = usize::from(node.text_range().start());
99            let node_end = usize::from(node.text_range().end());
100            pending = Some((significant_start(node).unwrap_or(node_start), node_end));
101        } else if let Some(token) = element.as_token() {
102            if token.kind() == SyntaxKind::SEMICOLON {
103                if let Some((sig_start, _node_end)) = pending.take() {
104                    spans.push(StatementSpan {
105                        sig_start,
106                        full_end: usize::from(token.text_range().end()),
107                    });
108                }
109            }
110        }
111    }
112    if let Some((sig_start, node_end)) = pending.take() {
113        spans.push(StatementSpan {
114            sig_start,
115            full_end: node_end,
116        });
117    }
118    spans
119}
120
121/// Byte offset of a statement node's first non-trivia token — past any leading whitespace, blank
122/// lines, and comments, which stay verbatim in the untouched head.
123fn significant_start(node: &SyntaxNode) -> Option<usize> {
124    node.descendants_with_tokens()
125        .filter_map(|element| element.into_token())
126        .find(|token| !token.kind().is_trivia())
127        .map(|token| usize::from(token.text_range().start()))
128}
129
130fn strip_one_trailing_newline(text: &str) -> &str {
131    text.strip_suffix("\r\n")
132        .or_else(|| text.strip_suffix('\n'))
133        .unwrap_or(text)
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    fn apply(source: &str, edit: &RangeEdit) -> String {
141        let mut out = String::new();
142        out.push_str(&source[..edit.range.start]);
143        out.push_str(&edit.new_text);
144        out.push_str(&source[edit.range.end..]);
145        out
146    }
147
148    fn range_format(source: &str, target: Range<usize>) -> Option<String> {
149        format_range(source, target, &FormatOptions::default()).map(|edit| apply(source, &edit))
150    }
151
152    #[test]
153    fn reformats_only_the_selected_statement() {
154        let source = "select 1 ;\n\n\nselect a,b from t;\n\nSELECT 3;\n";
155        // Target the middle statement only.
156        let start = source.find("select a").unwrap();
157        let out = range_format(source, start..start + 5).expect("edit");
158        assert_eq!(out, "select 1 ;\n\n\nSELECT a, b\nFROM t;\n\nSELECT 3;\n");
159        // The untouched statements are byte-identical.
160        assert!(out.starts_with("select 1 ;\n\n\n"));
161        assert!(out.ends_with("\n\nSELECT 3;\n"));
162    }
163
164    #[test]
165    fn keeps_same_line_trailing_comment_attached() {
166        let source = "select a,b from t;  -- keep me\nselect 2;\n";
167        let out = range_format(source, 0..3).expect("edit");
168        assert_eq!(out, "SELECT a, b\nFROM t;  -- keep me\nselect 2;\n");
169    }
170
171    #[test]
172    fn full_range_matches_whole_document_format() {
173        let source = "select 1;\nselect 2;\n";
174        let out = range_format(source, 0..source.len()).expect("edit");
175        assert_eq!(out, format(source, &FormatOptions::default()));
176    }
177
178    #[test]
179    fn cursor_between_statements_makes_no_edit() {
180        let source = "select 1;\n\nselect 2;\n";
181        // Offset in the blank line between the two statements.
182        let gap = source.find("\n\n").unwrap() + 1;
183        assert_eq!(
184            format_range(source, gap..gap, &FormatOptions::default()),
185            None
186        );
187    }
188
189    #[test]
190    fn already_formatted_selection_makes_no_edit() {
191        let source = "SELECT 1;\nSELECT a, b\nFROM t;\n";
192        assert_eq!(format_range(source, 0..8, &FormatOptions::default()), None);
193    }
194
195    #[test]
196    fn unparseable_source_makes_no_edit() {
197        let source = "ALTER TABLE t ADD COLUMN c INT;\n";
198        assert_eq!(format_range(source, 0..5, &FormatOptions::default()), None);
199    }
200
201    #[test]
202    fn range_formatting_is_idempotent() {
203        let source = "select 1 ;\n\nselect a,b from t;\n";
204        let start = source.find("select a").unwrap();
205        let once = range_format(source, start..start + 5).expect("edit");
206        // Re-formatting the same statement in the already-formatted output is a no-op.
207        let new_start = once.find("SELECT a").unwrap();
208        assert_eq!(
209            format_range(&once, new_start..new_start + 5, &FormatOptions::default()),
210            None
211        );
212    }
213
214    #[test]
215    fn selection_spanning_two_statements_reformats_both() {
216        let source = "select 1;select 2;\nSELECT 3;\n";
217        let out = range_format(source, 0..12).expect("edit");
218        assert_eq!(out, "SELECT 1;\nSELECT 2;\nSELECT 3;\n");
219    }
220}