From 650dcc74f22305ef0cec11766a7b8d4cc5a0fcd7 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 24 Oct 2026 21:00:55 +0000 Subject: [PATCH 1/2] fix(splitter): don't panic on a WITH at the end of the text look_ahead expected a token after the current one, which doesn't exist at EOF. A document that ends in `with`, e.g. while typing a CTE at the end of a file, made the splitter panic. --- crates/pgls_statement_splitter/src/lib.rs | 13 +++++++++++++ crates/pgls_statement_splitter/src/splitter.rs | 12 +++++------- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/pgls_statement_splitter/src/lib.rs b/crates/pgls_statement_splitter/src/lib.rs index bd5741e0c..d745a93d8 100644 --- a/crates/pgls_statement_splitter/src/lib.rs +++ b/crates/pgls_statement_splitter/src/lib.rs @@ -1166,6 +1166,19 @@ VALUES .assert_no_errors(); } + #[test] + fn with_at_the_end_does_not_panic() { + for (sql, last) in [ + ("with", "with"), + ("select 1;\nwith", "with"), + ("''with", "with"), + ("with recursive", "with recursive"), + ] { + let ranges = split(sql).ranges; + assert_eq!(ranges.last().map(|range| &sql[*range]), Some(last), "{sql}"); + } + } + #[test] fn empty_input() { assert_eq!(split("").ranges.len(), 0); diff --git a/crates/pgls_statement_splitter/src/splitter.rs b/crates/pgls_statement_splitter/src/splitter.rs index 567320f0a..112db90fd 100644 --- a/crates/pgls_statement_splitter/src/splitter.rs +++ b/crates/pgls_statement_splitter/src/splitter.rs @@ -176,17 +176,15 @@ impl<'a> Splitter<'a> { Ok(self.lexed.kind(self.current_pos)) } + /// The next token, or EOF at the end. fn look_ahead(&self, ignore_trivia: bool) -> SyntaxKind { let pos = if ignore_trivia { - (self.current_pos + 1..self.lexed.len()) - .find(|&idx| !self.is_trivia(idx)) - .expect("lexed should have non-trivia eof token") + (self.current_pos + 1..self.lexed.len()).find(|&idx| !self.is_trivia(idx)) } else { - (self.current_pos + 1..self.lexed.len()) - .next() - .expect("lexed should have a eof token") + (self.current_pos + 1..self.lexed.len()).next() }; - self.lexed.kind(pos) + // There is nothing after EOF, e.g. after a `WITH` at the end of the text. + pos.map_or(SyntaxKind::EOF, |pos| self.lexed.kind(pos)) } /// Returns `None` if there are no previous relevant tokens From 733779b14d5b3d0231c8b3af60969d6030749972 Mon Sep 17 00:00:00 2001 From: psteinroe Date: Sat, 24 Oct 2026 21:01:20 +0000 Subject: [PATCH 2/2] perf(workspace): split only the statements around a change Every change of a document split and lexed the whole text again and scanned every line for suppressions: 30 ms per keystroke on a file of 20,000 statements in a release build, 660 ms in a debug build. split_edited finds the changed part from the common prefix and suffix, splits the statements around it again, and keeps the rest of the previous split, shifted by the change in length. Two statements of context on each side have to come out as before, and changes that touch a quote, a comment or a psql command split the whole text, so the result is always the same as a full split. Documents without a suppression comment skip the suppressions parser, and line suppressions are looked up by binary search. --- .../src/diagnostics.rs | 11 + crates/pgls_statement_splitter/src/lib.rs | 358 +++++++++++++++++- crates/pgls_suppressions/src/lib.rs | 3 + crates/pgls_suppressions/src/line_index.rs | 13 +- crates/pgls_suppressions/src/parser.rs | 37 ++ .../src/workspace/server/document.rs | 106 ++++-- 6 files changed, 484 insertions(+), 44 deletions(-) diff --git a/crates/pgls_statement_splitter/src/diagnostics.rs b/crates/pgls_statement_splitter/src/diagnostics.rs index c929ad3b6..7f7c37ba8 100644 --- a/crates/pgls_statement_splitter/src/diagnostics.rs +++ b/crates/pgls_statement_splitter/src/diagnostics.rs @@ -27,6 +27,17 @@ impl SplitDiagnostic { } } +impl SplitDiagnostic { + pub(crate) fn span(&self) -> Option { + self.span + } + + pub(crate) fn with_span(mut self, span: TextRange) -> Self { + self.span = Some(span); + self + } +} + impl From for SplitDiagnostic { fn from(lex_diagnostic: LexDiagnostic) -> Self { Self { diff --git a/crates/pgls_statement_splitter/src/lib.rs b/crates/pgls_statement_splitter/src/lib.rs index d745a93d8..21efb2e1c 100644 --- a/crates/pgls_statement_splitter/src/lib.rs +++ b/crates/pgls_statement_splitter/src/lib.rs @@ -6,12 +6,16 @@ mod splitter; use diagnostics::SplitDiagnostic; use pgls_lexer::{Lexed, Lexer, SyntaxKind, convert_to_positional_params}; -use pgls_text_size::TextRange; +use pgls_text_size::{TextRange, TextSize}; use splitter::{SplitResult as PassResult, Splitter, source}; +#[derive(Debug, Clone, PartialEq)] pub struct SplitResult { pub ranges: Vec, + /// The errors of the lexer, ordered by position, then those of the splitter, by statement. pub errors: Vec, + /// How many of the `errors` come from the lexer. + lex_errors: usize, } /// Splits `sql` into the ranges of the statements it contains. @@ -46,6 +50,7 @@ pub fn split(sql: &str) -> SplitResult { let mut ranges = Vec::with_capacity(coarse.ranges.len()); let mut errors: Vec = lexed.errors().into_iter().map(Into::into).collect(); + let lex_errors = errors.len(); for range in coarse.ranges { let contains_blank_line = blank_lines.as_mut().is_some_and(|blank_lines| { @@ -96,7 +101,224 @@ pub fn split(sql: &str) -> SplitResult { ); } - SplitResult { ranges, errors } + SplitResult { + ranges, + errors, + lex_errors, + } +} + +/// The statements around a change that are split again by [`split_edited`], on each side of it. +/// The ones farthest from the change have to come out as before, which shows that the split +/// around the change doesn't depend on the text outside of it. +const EDIT_CONTEXT_STATEMENTS: usize = 2; + +/// Bytes that can open or close a string, a quoted identifier, a comment or a psql command. A +/// change that touches one can change how everything after it is lexed. +const LEXER_STATE_BYTES: &[u8] = b"$'\"/*-\\"; + +/// Splits `new` like [`split`], reusing `previous`, the split of `old`. +/// +/// Only the statements around the part of the text that changed are split again: from +/// [`EDIT_CONTEXT_STATEMENTS`] statements before it to as many after it. The rest is taken from +/// `previous`, shifted by the change in length. The result is the same as `split(new)`. It +/// falls back to splitting all of `new` when the change can affect statements far from it: +/// when it touches a quote, a comment or a psql command, or when the statements at the edges of +/// the region don't come out as before. +pub fn split_edited(old: &str, previous: &SplitResult, new: &str) -> SplitResult { + split_region(old, previous, new).unwrap_or_else(|| split(new)) +} + +/// Compared in blocks first, which is much faster than byte by byte. +const COMPARE_BLOCK_BYTES: usize = 64; + +/// The length of the longest common prefix of `a` and `b`. +fn common_prefix(a: &[u8], b: &[u8]) -> usize { + let len = a.len().min(b.len()); + let mut equal = 0; + while equal + COMPARE_BLOCK_BYTES <= len + && a[equal..equal + COMPARE_BLOCK_BYTES] == b[equal..equal + COMPARE_BLOCK_BYTES] + { + equal += COMPARE_BLOCK_BYTES; + } + equal + + a[equal..len] + .iter() + .zip(&b[equal..len]) + .take_while(|(a, b)| a == b) + .count() +} + +/// The length of the longest common suffix of `a` and `b`, which have the same length. +fn common_suffix(a: &[u8], b: &[u8]) -> usize { + let len = a.len(); + let mut equal = 0; + while equal + COMPARE_BLOCK_BYTES <= len + && a[len - equal - COMPARE_BLOCK_BYTES..len - equal] + == b[len - equal - COMPARE_BLOCK_BYTES..len - equal] + { + equal += COMPARE_BLOCK_BYTES; + } + equal + + a[..len - equal] + .iter() + .rev() + .zip(b[..len - equal].iter().rev()) + .take_while(|(a, b)| a == b) + .count() +} + +fn split_region(old: &str, previous: &SplitResult, new: &str) -> Option { + let (old_bytes, new_bytes) = (old.as_bytes(), new.as_bytes()); + let mut prefix = common_prefix(old_bytes, new_bytes); + while !old.is_char_boundary(prefix) || !new.is_char_boundary(prefix) { + prefix -= 1; + } + let max_suffix = old.len().min(new.len()) - prefix; + let mut suffix = common_suffix( + &old_bytes[old.len() - max_suffix..], + &new_bytes[new.len() - max_suffix..], + ); + while !old.is_char_boundary(old.len() - suffix) || !new.is_char_boundary(new.len() - suffix) { + suffix -= 1; + } + let (old_end, new_end) = (old.len() - suffix, new.len() - suffix); + if old_end == prefix && new_end == prefix { + return Some(previous.clone()); + } + + // The change, with a byte of context on each side, because two bytes can form a token like + // `/*` or `--` together. + let touches_lexer_state = |text: &[u8], end: usize| { + text[prefix.saturating_sub(1)..(end + 1).min(text.len())] + .iter() + .any(|byte| LEXER_STATE_BYTES.contains(byte)) + }; + if touches_lexer_state(old_bytes, old_end) || touches_lexer_state(new_bytes, new_end) { + return None; + } + + let ranges = &previous.ranges; + let to_size = |offset: usize| TextSize::try_from(offset).ok(); + let (prefix_size, old_end_size) = (to_size(prefix)?, to_size(old_end)?); + // The statements from `first` to `last` (exclusive) are split again: the ones that the + // change touches, and `EDIT_CONTEXT_STATEMENTS` on each side. + let touched = ranges.partition_point(|range| range.end() < prefix_size); + let after = ranges.partition_point(|range| range.start() < old_end_size); + let first = touched.saturating_sub(EDIT_CONTEXT_STATEMENTS); + let last = (after + EDIT_CONTEXT_STATEMENTS).min(ranges.len()); + + let start = if first == 0 { + 0 + } else { + usize::from(ranges[first].start()) + }; + let new_region_end = if last == ranges.len() { + new.len() + } else { + (usize::from(ranges[last - 1].end()) + new.len()).checked_sub(old.len())? + }; + if start > prefix || new_region_end < new_end { + return None; + } + let offset = to_size(start)?; + let shift = |range: TextRange| -> Option { + let start = (usize::from(range.start()) + new.len()).checked_sub(old.len())?; + let end = (usize::from(range.end()) + new.len()).checked_sub(old.len())?; + Some(TextRange::new(to_size(start)?, to_size(end)?)) + }; + + let region = split(&new[start..new_region_end]); + let region_ranges: Vec = region.ranges.iter().map(|range| *range + offset).collect(); + + // The statements before and after the change come out as before, so the region doesn't + // depend on the text around it. + let before = touched - first; + if first > 0 + && (region_ranges.len() < before || region_ranges[..before] != ranges[first..touched]) + { + return None; + } + if last < ranges.len() { + let after_count = last - after; + if region_ranges.len() < after_count { + return None; + } + let expected = ranges[after..last] + .iter() + .map(|range| shift(*range)) + .collect::>>()?; + if region_ranges[region_ranges.len() - after_count..] != expected[..] { + return None; + } + } + + // The first and the last statement of the region only show that the region lines up with + // the statements around it: the first one is split without the statements before it, and + // the last one runs into the end of the region. Both are unchanged, so they are taken from + // `previous`, like the statements outside the region. + let low = if first > 0 { + ranges[first].end() + } else { + TextSize::from(0) + }; + let (old_high, new_high) = if last < ranges.len() { + let old_high = ranges[last - 1].start(); + (old_high, shift(TextRange::empty(old_high))?.start()) + } else { + (to_size(old.len())?, to_size(new.len())?) + }; + let kept_before = if first > 0 { first + 1 } else { 0 }; + let kept_after = if last < ranges.len() { + last - 1 + } else { + ranges.len() + }; + + let mut result_ranges = Vec::with_capacity(ranges.len() + region_ranges.len()); + result_ranges.extend_from_slice(&ranges[..kept_before]); + result_ranges.extend( + region_ranges + .into_iter() + .filter(|range| range.start() >= low && range.start() < new_high), + ); + for range in &ranges[kept_after..] { + result_ranges.push(shift(*range)?); + } + + // The same for the errors. The errors of the lexer come first, as in `split`. + let place = |errors: &[SplitDiagnostic], region: &[SplitDiagnostic]| { + let mut placed = Vec::new(); + for error in errors { + if error.span()?.end() <= low { + placed.push(error.clone()); + } + } + for error in region { + let span = error.span()? + offset; + if span.start() >= low && span.end() <= new_high { + placed.push(error.clone().with_span(span)); + } + } + for error in errors { + let span = error.span()?; + if span.start() >= old_high { + placed.push(error.clone().with_span(shift(span)?)); + } + } + Some(placed) + }; + let (old_lex, old_split) = previous.errors.split_at(previous.lex_errors); + let (region_lex, region_split) = region.errors.split_at(region.lex_errors); + let mut errors = place(old_lex, region_lex)?; + let lex_errors = errors.len(); + errors.extend(place(old_split, region_split)?); + + Some(SplitResult { + ranges: result_ranges, + errors, + lex_errors, + }) } fn split_pass(lexed: &Lexed, blank_line_is_boundary: bool) -> PassResult { @@ -1184,4 +1406,136 @@ VALUES assert_eq!(split("").ranges.len(), 0); assert_eq!(split(" \n \n ").ranges.len(), 0); } + + /// A small deterministic random number generator (xorshift), so that a failure reproduces. + struct Rng(u64); + + impl Rng { + fn below(&mut self, n: usize) -> usize { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 % n.max(1) as u64) as usize + } + } + + /// Statements of the kinds the splitter treats differently. + const STATEMENTS: &[&str] = &[ + "create table t (id bigint primary key, name text not null);", + "create index t_name_idx on t (name);", + "create function f(a int) returns int language sql as $$ select a + 1 $$;", + "create function g(a int) returns int language sql begin atomic select a; select a + 1; end;", + "select c.relname from pg_class c where c.relnatts = 1 order by 1;", + "insert into t (id, name) values (1, 'x') on conflict (id) do update set name = 'y';", + "update t set name = 'z' where id = 1", + "delete from t where id in (select id from t)", + "with x as (select 1) select * from x;", + "-- a comment\nselect 1;", + "/* a block\ncomment */ select 2;", + "select 'a;b', \"c;d\";", + "alter table t add column c int;", + "begin;", + "commit;", + "select 1\n\nfrom t;", + "selec 1;", + "grant select on t to public;", + ]; + + /// Text inserted by the edits: whole statements, and fragments that can merge, cut or quote + /// statements. + const SNIPPETS: &[&str] = &[ + ";", + "\n", + "\n\n", + " ", + "x", + "select 1", + "select", + "from t", + "begin atomic", + "end;", + "(", + ")", + "'", + "$$", + "/*", + "*/", + "--", + ",", + "with", + "delete", + "create table q (a int);", + "\\d t\n", + "é", + "insert into", + "case when true then 1 end", + ]; + + fn document(rng: &mut Rng, statements: usize) -> String { + let mut sql = String::new(); + for _ in 0..statements { + sql.push_str(STATEMENTS[rng.below(STATEMENTS.len())]); + sql.push_str(["\n", "\n\n", " "][rng.below(3)]); + } + sql + } + + fn char_boundary(sql: &str, mut offset: usize) -> usize { + while !sql.is_char_boundary(offset) { + offset -= 1; + } + offset + } + + fn edit(rng: &mut Rng, sql: &str) -> String { + let at = char_boundary(sql, rng.below(sql.len() + 1)); + let end = char_boundary(sql, (at + rng.below(40)).min(sql.len())); + let inserted = match rng.below(4) { + 0 => "", + 1 => STATEMENTS[rng.below(STATEMENTS.len())], + _ => SNIPPETS[rng.below(SNIPPETS.len())], + }; + let end = if inserted.is_empty() || rng.below(2) == 0 { + end + } else { + at + }; + format!("{}{inserted}{}", &sql[..at], &sql[end..]) + } + + #[test] + fn split_edited_is_split() { + let seed = + std::env::var("SPLIT_SEED").map_or(0x9e37_79b9_7f4a_7c15, |seed| seed.parse().unwrap()); + let edits: usize = + std::env::var("SPLIT_EDITS").map_or(3_000, |edits| edits.parse().unwrap()); + let mut rng = Rng(seed); + let mut incremental_edits = 0; + for document_index in 0..10 { + let mut sql = document(&mut rng, 60); + let mut previous = split(&sql); + for _ in 0..edits / 10 { + // Sometimes two edits at once, which the region has to cover together. + let mut new = edit(&mut rng, &sql); + if rng.below(4) == 0 { + new = edit(&mut rng, &new); + } + let expected = split(&new); + let edited = split_edited(&sql, &previous, &new); + assert_eq!( + edited, expected, + "document {document_index}, from\n{sql}\nto\n{new}" + ); + incremental_edits += usize::from(split_region(&sql, &previous, &new).is_some()); + sql = new; + previous = edited; + } + } + // Most edits are split incrementally; the others fall back to a full split. + println!("{incremental_edits} of {edits} edits split incrementally"); + assert!( + incremental_edits > edits / 3, + "{incremental_edits} of {edits}" + ); + } } diff --git a/crates/pgls_suppressions/src/lib.rs b/crates/pgls_suppressions/src/lib.rs index b799cb59d..e2d032a23 100644 --- a/crates/pgls_suppressions/src/lib.rs +++ b/crates/pgls_suppressions/src/lib.rs @@ -170,6 +170,9 @@ impl Suppressions { &self, diagnostic: &D, ) -> Vec<&Suppression> { + if self.line_suppressions.is_empty() { + return Vec::new(); + } self.diagnostic_line_range(diagnostic) .map(|(start, end)| { self.line_suppressions diff --git a/crates/pgls_suppressions/src/line_index.rs b/crates/pgls_suppressions/src/line_index.rs index ca71e235d..ba0558195 100644 --- a/crates/pgls_suppressions/src/line_index.rs +++ b/crates/pgls_suppressions/src/line_index.rs @@ -28,16 +28,9 @@ impl LineIndex { } pub fn line_for_offset(&self, offset: TextSize) -> Option { + // The offsets are ordered: the last line that starts at or before `offset`. self.line_offset - .iter() - .enumerate() - .filter_map(|(i, line_offset)| { - if offset >= *line_offset { - Some(i) - } else { - None - } - }) - .next_back() + .partition_point(|line_offset| *line_offset <= offset) + .checked_sub(1) } } diff --git a/crates/pgls_suppressions/src/parser.rs b/crates/pgls_suppressions/src/parser.rs index c126b066f..db1abfc41 100644 --- a/crates/pgls_suppressions/src/parser.rs +++ b/crates/pgls_suppressions/src/parser.rs @@ -40,6 +40,16 @@ impl<'a> SuppressionsParser<'a> { } pub fn parse(doc: &str) -> Suppressions { + // Every suppression comment contains `-ignore`. Most documents have none, and finding + // that out is much faster than looking at each line. Without line suppressions, the + // line index isn't needed either. + if !doc.contains("-ignore") { + return Suppressions::default(); + } + Self::parse_lines(doc) + } + + fn parse_lines(doc: &str) -> Suppressions { let mut parser = SuppressionsParser::new(doc); parser.parse_file_suppressions(); @@ -176,6 +186,33 @@ impl<'a> SuppressionsParser<'a> { #[cfg(test)] mod tests { + use super::SuppressionsParser; + + #[test] + fn documents_without_suppressions_skip_the_lines() { + for doc in [ + "", + "select 1;", + "-- a comment\nselect 1;\r\n\r\nselect 2;\n", + "-- pgls\n--ignore\nselect 1;", + ] { + let fast = SuppressionsParser::parse(doc); + let slow = SuppressionsParser::parse_lines(doc); + assert!(slow.file_suppressions.is_empty(), "{doc}"); + assert!(slow.line_suppressions.is_empty(), "{doc}"); + assert!(slow.range_suppressions.is_empty(), "{doc}"); + assert!(slow.diagnostics.is_empty(), "{doc}"); + if !doc.contains("-ignore") { + assert_eq!( + format!("{fast:?}"), + format!("{:?}", super::Suppressions::default()) + ); + } else { + assert_eq!(format!("{fast:?}"), format!("{slow:?}")); + } + } + } + use super::*; use crate::suppression::{RuleSpecifier, SuppressionKind}; diff --git a/crates/pgls_workspace/src/workspace/server/document.rs b/crates/pgls_workspace/src/workspace/server/document.rs index d278b1b3e..df86fb4db 100644 --- a/crates/pgls_workspace/src/workspace/server/document.rs +++ b/crates/pgls_workspace/src/workspace/server/document.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use pgls_analyser::{AnalysableStatement, UnparsableStatement}; use pgls_diagnostics::{Diagnostic, DiagnosticExt, serde::Diagnostic as SDiagnostic}; use pgls_query_ext::diagnostics::SyntaxDiagnostic; +use pgls_statement_splitter::SplitResult; use pgls_suppressions::Suppressions; use pgls_text_size::{TextRange, TextSize}; use rustc_hash::FxHashSet; @@ -18,7 +19,8 @@ use super::{ pub struct Document { content: String, version: i32, - ranges: Vec, + /// The statements of `content`, kept to split the next version of it incrementally. + split: SplitResult, diagnostics: Vec, ast_db: PgQueryStore, cst_db: TreeSitterStore, @@ -36,10 +38,11 @@ impl Document { let annotation_db = AnnotationStore::new(); let suppressions = Suppressions::from(content.as_str()); - let (ranges, diagnostics) = split_with_diagnostics(&content, None); + let split = pgls_statement_splitter::split(&content); + let diagnostics = split_diagnostics(&split); Document { - ranges, + split, diagnostics, content, version, @@ -52,18 +55,18 @@ impl Document { } pub fn update_content(&mut self, content: String, version: i32) { + // Only the statements around the change are split again. + self.split = pgls_statement_splitter::split_edited(&self.content, &self.split, &content); self.content = content; self.version = version; - let (ranges, diagnostics) = split_with_diagnostics(&self.content, None); - - self.ranges = ranges; - self.diagnostics = diagnostics; + self.diagnostics = split_diagnostics(&self.split); self.suppressions = Suppressions::from(self.content.as_str()); // Keep what is cached for the statements that are still in the document. The bodies // of SQL functions are kept with their function. let statements: FxHashSet<&str> = self + .split .ranges .iter() .map(|range| &self.content[*range]) @@ -155,8 +158,9 @@ impl Document { { // The ranges are ordered and don't overlap, so only the first one that ends after // `position` can contain it. - let first = self.ranges.partition_point(|range| range.end() <= position); - let ranges = &self.ranges[first..self.ranges.len().min(first + 1)]; + let ranges = &self.split.ranges; + let first = ranges.partition_point(|range| range.end() <= position); + let ranges = &ranges[first..ranges.len().min(first + 1)]; ParseIterator { ranges: ranges.iter(), ..ParseIterator::new(self, mapper, CursorPositionFilter::new(position)) @@ -193,7 +197,7 @@ impl<'a, M, F> ParseIterator<'a, M, F> { parser, mapper, filter, - ranges: parser.ranges.iter(), + ranges: parser.split.ranges.iter(), pending_sub_statements: Vec::new(), } } @@ -486,28 +490,13 @@ impl StatementFilter<'_> for IdFilter { } } -/// Helper function that wraps the statement splitter and returns the ranges with unified -/// diagnostics -pub(crate) fn split_with_diagnostics( - content: &str, - offset: Option, -) -> (Vec, Vec) { - let o = offset.unwrap_or_else(|| 0.into()); - let result = pgls_statement_splitter::split(content); - - ( - result.ranges, - result - .errors - .into_iter() - .map(|err| { - SDiagnostic::new( - err.clone() - .with_file_span(err.location().span.map(|r| r + o)), - ) - }) - .collect(), - ) +/// The errors of the statement splitter as diagnostics. +fn split_diagnostics(split: &SplitResult) -> Vec { + split + .errors + .iter() + .map(|err| SDiagnostic::new(err.clone().with_file_span(err.location().span))) + .collect() } #[cfg(test)] @@ -822,6 +811,59 @@ $$ LANGUAGE plpgsql;"; )); } + /// Each change splits only the statements around it, with the same result as splitting the + /// whole document. + #[test] + fn updates_split_like_the_whole_document() { + let statements = [ + "create table t (id bigint primary key, name text);", + "create function f(a int) returns int language sql as $$ select a $$;", + "select c.relname from pg_class c where c.relnatts = 1;", + "insert into t values (1, 'x');", + "-- comment\nselect 1;", + "update t set name = 'y'", + ]; + let snippets = [ + "x", + " ", + "\n", + "\n\n", + ";", + "select 2;", + "'", + "$$", + "(", + ")", + "", + ]; + let mut seed: u64 = 0x2545_f491_4f6c_dd1d; + let mut below = |n: usize| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + (seed % n as u64) as usize + }; + let mut content: String = (0..200) + .map(|i| statements[i % statements.len()]) + .collect::>() + .join("\n"); + let mut d = Document::new(content.clone(), 0); + for version in 1..1_000 { + let at = below(content.len() + 1); + let end = (at + below(20)).min(content.len()); + let inserted = snippets[below(snippets.len())]; + content = format!("{}{inserted}{}", &content[..at], &content[end..]); + d.update_content(content.clone(), version); + + let whole = pgls_statement_splitter::split(&content); + assert_eq!(d.split, whole, "{content}"); + assert_eq!( + format!("{:?}", d.diagnostics), + format!("{:?}", split_diagnostics(&whole)) + ); + } + } + #[test] fn test_id_filter() { let input = "SELECT 1; SELECT 2;";