Skip to main content

sql_dialect_fmt_wasm/
lib.rs

1//! Raw WebAssembly bindings for browser extension use.
2//!
3//! This crate deliberately avoids `wasm-bindgen` so the Chrome extension can load a single local
4//! `.wasm` file without generated JavaScript glue. JavaScript owns input buffers allocated through
5//! `sql_dialect_fmt_alloc`; this module owns the last formatted result until the next call or
6//! `sql_dialect_fmt_clear_result`.
7
8use std::{cell::RefCell, mem, ptr, slice, str};
9
10use sql_dialect_fmt_formatter::{format, Dialect, FormatOptions};
11
12thread_local! {
13    static LAST_RESULT: RefCell<Option<Box<[u8]>>> = const { RefCell::new(None) };
14}
15
16/// Allocate a writable byte buffer in Wasm memory.
17///
18/// JavaScript should copy UTF-8 bytes into the returned pointer, call `sql_dialect_fmt_format`, then free
19/// this input buffer with `sql_dialect_fmt_dealloc(ptr, len)`.
20#[no_mangle]
21pub extern "C" fn sql_dialect_fmt_alloc(len: u32) -> u32 {
22    let mut buffer = Vec::<u8>::with_capacity(len as usize);
23    let ptr = buffer.as_mut_ptr();
24    mem::forget(buffer);
25    ptr as u32
26}
27
28/// Free a buffer previously allocated by `sql_dialect_fmt_alloc`.
29///
30/// # Safety
31///
32/// `ptr` must be a pointer returned by `sql_dialect_fmt_alloc` with the same `capacity`, and it must not
33/// have already been freed. Passing any other pointer or capacity is undefined behavior.
34#[no_mangle]
35pub unsafe extern "C" fn sql_dialect_fmt_dealloc(ptr: u32, capacity: u32) {
36    if ptr == 0 || capacity == 0 {
37        return;
38    }
39    drop(Vec::from_raw_parts(ptr as *mut u8, 0, capacity as usize));
40}
41
42/// Format the UTF-8 SQL source stored at `ptr..ptr + len`.
43///
44/// Returns:
45/// - `0`: success; read `sql_dialect_fmt_result_ptr()` and `sql_dialect_fmt_result_len()`.
46/// - `1`: invalid UTF-8 input; no result is stored.
47///
48/// # Safety
49///
50/// `ptr` must point to `len` initialized bytes in Wasm memory for the duration of the call.
51#[no_mangle]
52pub unsafe extern "C" fn sql_dialect_fmt_format(
53    ptr: u32,
54    len: u32,
55    line_width: u32,
56    indent_width: u32,
57    uppercase_keywords: u32,
58) -> u32 {
59    sql_dialect_fmt_format_with_dialect(ptr, len, line_width, indent_width, uppercase_keywords, 0)
60}
61
62/// Format the UTF-8 SQL source stored at `ptr..ptr + len` using an explicit dialect.
63///
64/// `dialect` values:
65/// - `0`: Snowflake
66/// - `1`: Databricks
67///
68/// Unknown values fall back to Snowflake for forwards-compatible callers.
69///
70/// # Safety
71///
72/// `ptr` must point to `len` initialized bytes in Wasm memory for the duration of the call.
73#[no_mangle]
74pub unsafe extern "C" fn sql_dialect_fmt_format_with_dialect(
75    ptr: u32,
76    len: u32,
77    line_width: u32,
78    indent_width: u32,
79    uppercase_keywords: u32,
80    dialect: u32,
81) -> u32 {
82    let bytes = slice::from_raw_parts(ptr as *const u8, len as usize);
83    format_bytes(bytes, line_width, indent_width, uppercase_keywords, dialect)
84}
85
86/// The safe core of the format ABI: validate `bytes` as UTF-8, format with the decoded raw
87/// options, and stash the result for the `result_ptr`/`result_len` accessors. Split out so the
88/// exact option decoding (clamping, dialect fallback) is testable without Wasm linear memory.
89fn format_bytes(
90    bytes: &[u8],
91    line_width: u32,
92    indent_width: u32,
93    uppercase_keywords: u32,
94    dialect: u32,
95) -> u32 {
96    clear_last_result();
97
98    let Ok(source) = str::from_utf8(bytes) else {
99        return 1;
100    };
101
102    let options = FormatOptions::default()
103        .with_line_width(line_width.max(1) as usize)
104        .with_indent_width(indent_width.clamp(1, 16) as usize)
105        .with_uppercase_keywords(uppercase_keywords != 0)
106        .with_dialect(dialect_from_u32(dialect));
107
108    store_last_result(format(source, &options).into_bytes().into_boxed_slice());
109    0
110}
111
112fn dialect_from_u32(dialect: u32) -> Dialect {
113    match dialect {
114        1 => Dialect::Databricks,
115        _ => Dialect::Snowflake,
116    }
117}
118
119/// Pointer to the most recent formatted result.
120///
121/// # Safety
122///
123/// The returned pointer is valid only until the next `sql_dialect_fmt_format` or `sql_dialect_fmt_clear_result`
124/// call. Callers must pair it with `sql_dialect_fmt_result_len` and copy the bytes before releasing it.
125#[no_mangle]
126pub unsafe extern "C" fn sql_dialect_fmt_result_ptr() -> u32 {
127    last_result_ptr() as u32
128}
129
130/// Byte length of the most recent formatted result.
131///
132/// # Safety
133///
134/// The value describes the buffer returned by `sql_dialect_fmt_result_ptr` and is valid only until the next
135/// `sql_dialect_fmt_format` or `sql_dialect_fmt_clear_result` call.
136#[no_mangle]
137pub unsafe extern "C" fn sql_dialect_fmt_result_len() -> u32 {
138    last_result_len() as u32
139}
140
141/// Release the most recent formatted result, if any.
142///
143/// # Safety
144///
145/// Callers must not read from a previously returned result pointer after this function runs.
146#[no_mangle]
147pub unsafe extern "C" fn sql_dialect_fmt_clear_result() {
148    clear_last_result();
149}
150
151fn clear_last_result() {
152    LAST_RESULT.with(|last_result| {
153        last_result.borrow_mut().take();
154    });
155}
156
157fn store_last_result(result: Box<[u8]>) {
158    LAST_RESULT.with(|last_result| {
159        *last_result.borrow_mut() = Some(result);
160    });
161}
162
163fn last_result_ptr() -> *const u8 {
164    LAST_RESULT.with(|last_result| {
165        last_result
166            .borrow()
167            .as_deref()
168            .map_or(ptr::null(), |result| result.as_ptr())
169    })
170}
171
172fn last_result_len() -> usize {
173    LAST_RESULT.with(|last_result| last_result.borrow().as_deref().map_or(0, <[u8]>::len))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use sql_dialect_fmt_test_fixtures::{
180        javascript_routine_trailing_whitespace_input,
181        JAVASCRIPT_ROUTINE_TRAILING_WHITESPACE_EXPECTED,
182    };
183
184    fn last_result_bytes() -> Vec<u8> {
185        let ptr = last_result_ptr();
186        let len = last_result_len();
187
188        if len == 0 {
189            return Vec::new();
190        }
191
192        assert!(!ptr.is_null());
193        unsafe { slice::from_raw_parts(ptr, len).to_vec() }
194    }
195
196    #[test]
197    fn stores_result_bytes() {
198        clear_last_result();
199
200        store_last_result(b"select 1".to_vec().into_boxed_slice());
201
202        assert_eq!(last_result_len(), 8);
203        assert_eq!(last_result_bytes(), b"select 1");
204
205        clear_last_result();
206    }
207
208    #[test]
209    fn replacing_result_exposes_only_new_bytes() {
210        clear_last_result();
211
212        store_last_result(b"old result".to_vec().into_boxed_slice());
213        store_last_result(b"new".to_vec().into_boxed_slice());
214
215        assert_eq!(last_result_len(), 3);
216        assert_eq!(last_result_bytes(), b"new");
217
218        clear_last_result();
219    }
220
221    #[test]
222    fn clear_result_removes_state_and_is_idempotent() {
223        clear_last_result();
224        store_last_result(b"temporary".to_vec().into_boxed_slice());
225
226        clear_last_result();
227        assert!(last_result_ptr().is_null());
228        assert_eq!(last_result_len(), 0);
229        assert_eq!(last_result_bytes(), b"");
230
231        clear_last_result();
232        assert!(last_result_ptr().is_null());
233        assert_eq!(last_result_len(), 0);
234    }
235
236    /// Run [`format_bytes`] and copy the stored result out, mirroring how JavaScript reads the
237    /// `result_ptr`/`result_len` pair after a successful call.
238    fn format_to_string(
239        source: &str,
240        line_width: u32,
241        indent_width: u32,
242        uppercase_keywords: u32,
243        dialect: u32,
244    ) -> String {
245        let status = format_bytes(
246            source.as_bytes(),
247            line_width,
248            indent_width,
249            uppercase_keywords,
250            dialect,
251        );
252        assert_eq!(status, 0, "format_bytes({source:?}) failed");
253        let result = String::from_utf8(last_result_bytes()).expect("UTF-8 result");
254        clear_last_result();
255        result
256    }
257
258    #[test]
259    fn format_defaults_produce_uppercase_snowflake_output() {
260        assert_eq!(
261            format_to_string("select a,b from t", 80, 4, 1, 0),
262            "SELECT a, b\nFROM t;\n"
263        );
264    }
265
266    #[test]
267    fn uppercase_flag_zero_preserves_source_keyword_case() {
268        // `uppercase_keywords = 0` maps to KeywordCase::Preserve, not lowercasing.
269        assert_eq!(
270            format_to_string("select a from t", 80, 4, 0, 0),
271            "select a\nfrom t;\n"
272        );
273        assert_eq!(
274            format_to_string("SELECT a from t", 80, 4, 0, 0),
275            "SELECT a\nfrom t;\n"
276        );
277    }
278
279    #[test]
280    fn line_width_controls_select_list_wrapping() {
281        let source = "select aaaa, bbbb, cccc from t";
282        assert_eq!(
283            format_to_string(source, 100, 4, 1, 0),
284            "SELECT aaaa, bbbb, cccc\nFROM t;\n"
285        );
286        assert_eq!(
287            format_to_string(source, 10, 4, 1, 0),
288            "SELECT\n    aaaa,\n    bbbb,\n    cccc\nFROM t;\n"
289        );
290    }
291
292    #[test]
293    fn indent_width_is_applied_and_clamped() {
294        let source = "select aaaa, bbbb from t";
295        assert_eq!(
296            format_to_string(source, 10, 8, 1, 0),
297            "SELECT\n        aaaa,\n        bbbb\nFROM t;\n"
298        );
299        // 0 clamps up to 1 space, out-of-range values clamp down to 16.
300        assert_eq!(
301            format_to_string(source, 10, 0, 1, 0),
302            "SELECT\n aaaa,\n bbbb\nFROM t;\n"
303        );
304        assert_eq!(
305            format_to_string(source, 10, 999, 1, 0),
306            format_to_string(source, 10, 16, 1, 0)
307        );
308    }
309
310    #[test]
311    fn zero_line_width_is_clamped_instead_of_panicking() {
312        assert_eq!(
313            format_to_string("select a from t", 0, 4, 1, 0),
314            format_to_string("select a from t", 1, 4, 1, 0)
315        );
316    }
317
318    #[test]
319    fn dialect_one_selects_databricks_and_unknown_values_fall_back_to_snowflake() {
320        // `a <=> b` only parses under Databricks; Snowflake passes the statement through verbatim.
321        let source = "select a <=> b from t";
322        assert_eq!(
323            format_to_string(source, 80, 4, 1, 1),
324            "SELECT a <=> b\nFROM t;\n"
325        );
326        let snowflake = format_to_string(source, 80, 4, 1, 0);
327        assert_eq!(format_to_string(source, 80, 4, 1, 999), snowflake);
328        assert_eq!(dialect_from_u32(0), Dialect::Snowflake);
329        assert_eq!(dialect_from_u32(1), Dialect::Databricks);
330        assert_eq!(dialect_from_u32(u32::MAX), Dialect::Snowflake);
331    }
332
333    #[test]
334    fn formatting_is_idempotent() {
335        let first = format_to_string("select a,b from t where x=1", 80, 4, 1, 0);
336        assert_eq!(format_to_string(&first, 80, 4, 1, 0), first);
337    }
338
339    #[test]
340    fn javascript_routine_regression_formats_through_the_wasm_abi() {
341        let input = javascript_routine_trailing_whitespace_input();
342        let output = format_to_string(&input, 100, 4, 1, 0);
343
344        assert_eq!(output, JAVASCRIPT_ROUTINE_TRAILING_WHITESPACE_EXPECTED);
345        assert_eq!(format_to_string(&output, 100, 4, 1, 0), output);
346    }
347
348    #[test]
349    fn unparseable_statements_pass_through_verbatim() {
350        let source = "select from where";
351        let result = format_to_string(source, 80, 4, 1, 0);
352        assert!(
353            result.contains(source),
354            "broken input should survive verbatim, got {result:?}"
355        );
356    }
357
358    #[test]
359    fn invalid_utf8_input_reports_failure_and_stores_no_result() {
360        // Leave a stale result behind to prove a failed call clears it.
361        store_last_result(b"stale".to_vec().into_boxed_slice());
362
363        let status = format_bytes(&[0x66, 0xFF, 0xFE], 80, 4, 1, 0);
364
365        assert_eq!(status, 1);
366        assert!(last_result_ptr().is_null());
367        assert_eq!(last_result_len(), 0);
368    }
369
370    #[test]
371    fn empty_input_formats_to_empty_output() {
372        assert_eq!(format_bytes(b"", 80, 4, 1, 0), 0);
373        assert_eq!(last_result_len(), 0);
374        clear_last_result();
375    }
376
377    #[test]
378    fn sequential_calls_replace_the_stored_result() {
379        assert_eq!(format_bytes(b"select 1", 80, 4, 1, 0), 0);
380        assert_eq!(last_result_bytes(), b"SELECT 1;\n");
381
382        assert_eq!(format_bytes(b"select 2", 80, 4, 1, 0), 0);
383        assert_eq!(last_result_bytes(), b"SELECT 2;\n");
384        clear_last_result();
385    }
386
387    #[test]
388    fn dealloc_ignores_null_and_zero_capacity_buffers() {
389        // The guard clauses must make these calls no-ops on any target.
390        unsafe {
391            sql_dialect_fmt_dealloc(0, 0);
392            sql_dialect_fmt_dealloc(0, 16);
393            sql_dialect_fmt_dealloc(4, 0);
394        }
395    }
396}