diff --git a/Cargo.lock b/Cargo.lock index c99237b66..760e5fcb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2394,6 +2394,7 @@ dependencies = [ "futures-util", "globset", "insta", + "pgls_lexer", "pgls_query", "pgls_test_utils", "pgls_text_size", diff --git a/crates/pgls_catalog/Cargo.toml b/crates/pgls_catalog/Cargo.toml index 7cf18e3a2..21ebb71f2 100644 --- a/crates/pgls_catalog/Cargo.toml +++ b/crates/pgls_catalog/Cargo.toml @@ -19,6 +19,7 @@ schema = ["dep:schemars"] [dependencies] futures-util = { version = "0.3.31", optional = true } globset = "0.4.16" +pgls_lexer = { workspace = true } pgls_query = { workspace = true } pgls_text_size = { workspace = true } rustc-hash = { workspace = true } diff --git a/crates/pgls_catalog/src/identifier.rs b/crates/pgls_catalog/src/identifier.rs index ec72a8eb0..87172d964 100644 --- a/crates/pgls_catalog/src/identifier.rs +++ b/crates/pgls_catalog/src/identifier.rs @@ -1,5 +1,9 @@ //! Identifiers in SQL text and the names they stand for. +use std::borrow::Cow; + +use pgls_lexer::{KeywordCategory, keyword_category}; + /// The name an identifier as written in SQL stands for, like Postgres' scanner reads it: a /// quoted identifier without its quotes and with `""` unescaped, an unquoted identifier /// downcased. Like [`downcase_identifier`] in a multibyte encoding such as UTF8, only ASCII @@ -19,9 +23,64 @@ pub fn identifier_name(text: &str) -> String { } } +/// Where a name is written, which decides the keywords it can be without quotes. See the name +/// classification hierarchy in [`gram.y`]. +/// +/// [`gram.y`]: https://github.com/postgres/postgres/blob/REL_18_6/src/backend/parser/gram.y#L17620 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NamePosition { + /// The first part of a column, table or schema name (`ColId`): any keyword except the + /// reserved ones and those that name types or functions. + ColId, + /// An unqualified function or type name (`type_function_name`): any keyword except the + /// reserved ones and those that name columns, and except `operator`, which starts an + /// operator like `OPERATOR(pg_catalog.+)` when a `(` follows ([`qual_Op`]). + /// + /// [`qual_Op`]: https://github.com/postgres/postgres/blob/REL_18_6/src/backend/parser/gram.y#L16658 + TypeFunctionName, + /// A name after a dot (`attr_name`, a `ColLabel`): any keyword. + ColLabel, +} + +/// The identifier to write in SQL for `name`: the name itself if Postgres reads it back as the +/// same name, else the name quoted, with `"` escaped as `""`. The inverse of +/// [`identifier_name`]. +pub fn quote_identifier(name: &str, position: NamePosition) -> Cow<'_, str> { + if needs_quotes(name, position) { + Cow::Owned(format!("\"{}\"", name.replace('"', "\"\""))) + } else { + Cow::Borrowed(name) + } +} + +/// Whether `name` must be quoted at `position`. Unquoted, it must be an identifier by +/// [`scan.l`]'s rules that downcasing leaves unchanged, and not a keyword that can't be used +/// there. +/// +/// [`scan.l`]: https://github.com/postgres/postgres/blob/REL_18_6/src/backend/parser/scan.l#L332 +pub fn needs_quotes(name: &str, position: NamePosition) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return true; + }; + let is_start = |c: char| c.is_ascii_lowercase() || c == '_' || !c.is_ascii(); + if !is_start(first) || !chars.all(|c| is_start(c) || c.is_ascii_digit() || c == '$') { + return true; + } + if position == NamePosition::TypeFunctionName && name == "operator" { + return true; + } + match keyword_category(name) { + None | Some(KeywordCategory::Unreserved) => false, + Some(KeywordCategory::Reserved) => position != NamePosition::ColLabel, + Some(KeywordCategory::ColName) => position == NamePosition::TypeFunctionName, + Some(KeywordCategory::TypeFuncName) => position == NamePosition::ColId, + } +} + #[cfg(test)] mod tests { - use super::identifier_name; + use super::{NamePosition, identifier_name, needs_quotes, quote_identifier}; #[test] fn unquoted_identifiers_are_downcased() { @@ -43,4 +102,76 @@ mod tests { assert_eq!(identifier_name(r#""Foo"#), r#""foo"#); assert_eq!(identifier_name(r#"""#), r#"""#); } + + #[test] + fn names_are_quoted_when_postgres_would_read_another_name() { + let quoted = |name| quote_identifier(name, NamePosition::ColId).into_owned(); + assert_eq!(quoted("users"), "users"); + assert_eq!(quoted("_t1$x"), "_t1$x"); + assert_eq!(quoted("Äbc"), "Äbc"); + assert_eq!(quoted("MyTable"), r#""MyTable""#); + assert_eq!(quoted("my table"), r#""my table""#); + assert_eq!(quoted("1st"), r#""1st""#); + assert_eq!(quoted(r#"a"b"#), r#""a""b""#); + assert_eq!(quoted(""), r#""""#); + for name in ["users", "MyTable", "my table", r#"a"b"#] { + assert_eq!(identifier_name("ed(name)), name); + } + } + + #[test] + fn keywords_are_quoted_where_the_grammar_rejects_them() { + use NamePosition::*; + // Unreserved: `name`. Column name: `between`. Type or function name: `left`. + // Reserved: `select`. + for (name, col_id, type_function_name) in [ + ("name", false, false), + ("between", false, true), + ("left", true, false), + ("select", true, true), + ] { + assert_eq!(needs_quotes(name, ColId), col_id, "{name}"); + assert_eq!( + needs_quotes(name, TypeFunctionName), + type_function_name, + "{name}" + ); + assert!(!needs_quotes(name, ColLabel), "{name}"); + } + } + + /// Every keyword, written by [`quote_identifier`] at each position, parses, e.g. a function + /// named `operator` is called as `"operator"()`. + #[test] + fn keywords_round_trip_through_the_parser() { + use NamePosition::*; + let kwlist = include_str!( + "../../pgls_query/vendor/libpg_query/src/postgres/include/parser/kwlist.h" + ); + let keywords = kwlist + .lines() + .filter_map(|line| line.strip_prefix("PG_KEYWORD(\"")) + .filter_map(|line| line.split('"').next()); + let mut failures = Vec::new(); + for keyword in keywords { + let col_id = quote_identifier(keyword, ColId); + let function = quote_identifier(keyword, TypeFunctionName); + let label = quote_identifier(keyword, ColLabel); + for sql in [ + format!("select {col_id} from {col_id}.t {col_id} where {col_id} = 1"), + format!("select {col_id}.{label}, s.{label}() from {col_id}"), + format!("select {function}(), a + {function}(1)"), + ] { + if pgls_query::parse(&sql).is_err() { + failures.push(sql); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); + assert_eq!( + quote_identifier("operator", TypeFunctionName), + r#""operator""# + ); + assert_eq!(quote_identifier("operator", ColId), "operator"); + } } diff --git a/crates/pgls_catalog/src/lib.rs b/crates/pgls_catalog/src/lib.rs index 21901d992..a1bfc1f8f 100644 --- a/crates/pgls_catalog/src/lib.rs +++ b/crates/pgls_catalog/src/lib.rs @@ -11,7 +11,8 @@ //! - [`CatalogView`] (`lookup.rs`): the lookups the resolver makes. [`Catalog`] implements it. //! - [`resolve`] (`resolve/`): name resolution of a single statement against a [`CatalogView`], //! with one module per parse node in `resolve/nodes/`. -//! - [`identifier_name`] (`identifier.rs`): the name an identifier in SQL text stands for. +//! - [`identifier_name`] (`identifier.rs`): the name an identifier in SQL text stands for, and +//! [`quote_identifier`] the identifier to write for a name. //! //! The analyser walks a file statement by statement. For each statement it runs the rules, //! which resolve names against the current [`Catalog`] with the [`Session`]'s search path, and @@ -27,7 +28,7 @@ pub mod snapshot; pub mod typing; pub use catalog::{Catalog, CatalogBase}; -pub use identifier::identifier_name; +pub use identifier::{NamePosition, identifier_name, needs_quotes, quote_identifier}; pub use lookup::{ Candidates, CastContext, CastInfo, CastMethod, CatalogView, ColumnInfo, FunctionArgument, FunctionArgumentMode, FunctionInfo, FunctionKind, FunctionSignature, Lookup, OperatorInfo, diff --git a/crates/pgls_completions/src/providers/columns.rs b/crates/pgls_completions/src/providers/columns.rs index e90d6deaf..d17a3ea58 100644 --- a/crates/pgls_completions/src/providers/columns.rs +++ b/crates/pgls_completions/src/providers/columns.rs @@ -1,4 +1,4 @@ -use pgls_catalog::{Column, Snapshot}; +use pgls_catalog::{Column, NamePosition, Snapshot, identifier_name}; use pgls_treesitter::TreesitterContext; use crate::{ @@ -8,7 +8,7 @@ use crate::{ relevance::{CompletionRelevanceData, filtering::CompletionFilter, scoring::CompletionScore}, }; -use super::helper::with_schema_or_alias; +use super::helper::{Qualifier, with_schema_or_alias}; pub fn complete_columns<'a>( ctx: &TreesitterContext<'a>, @@ -35,9 +35,17 @@ pub fn complete_columns<'a>( } fn get_completion_text(ctx: &TreesitterContext, col: &Column) -> CompletionText { - let alias = ctx.get_used_alias_for_table(col.table_name.as_str()); - - let with_schema_or_alias = with_schema_or_alias(ctx, col.name.as_str(), alias.as_deref()); + // The alias as a name, which is quoted again if needed. + let alias = ctx + .get_used_alias_for_table(col.table_name.as_str()) + .map(|alias| identifier_name(&alias)); + + let with_schema_or_alias = with_schema_or_alias( + ctx, + col.name.as_str(), + alias.as_deref().map(Qualifier::Alias), + NamePosition::ColId, + ); let range = get_range_to_replace(ctx); diff --git a/crates/pgls_completions/src/providers/functions.rs b/crates/pgls_completions/src/providers/functions.rs index 45f57d838..0a35a5a54 100644 --- a/crates/pgls_completions/src/providers/functions.rs +++ b/crates/pgls_completions/src/providers/functions.rs @@ -1,4 +1,4 @@ -use pgls_catalog::{Function, Snapshot}; +use pgls_catalog::{Function, NamePosition, Snapshot}; use pgls_treesitter::TreesitterContext; use crate::{ @@ -8,7 +8,7 @@ use crate::{ relevance::{CompletionRelevanceData, filtering::CompletionFilter, scoring::CompletionScore}, }; -use super::helper::with_schema_or_alias; +use super::helper::{Qualifier, with_schema_or_alias}; pub fn complete_functions<'a>( ctx: &'a TreesitterContext, @@ -35,7 +35,12 @@ pub fn complete_functions<'a>( } fn get_completion_text(ctx: &TreesitterContext, func: &Function) -> CompletionText { - let mut text = with_schema_or_alias(ctx, func.name.as_str(), Some(func.schema.as_str())); + let mut text = with_schema_or_alias( + ctx, + func.name.as_str(), + Some(Qualifier::Schema(func.schema.as_str())), + NamePosition::TypeFunctionName, + ); let mut range = get_range_to_replace(ctx); diff --git a/crates/pgls_completions/src/providers/helper.rs b/crates/pgls_completions/src/providers/helper.rs index 790e96f3c..5684855cd 100644 --- a/crates/pgls_completions/src/providers/helper.rs +++ b/crates/pgls_completions/src/providers/helper.rs @@ -1,3 +1,4 @@ +use pgls_catalog::{NamePosition, quote_identifier}; use pgls_text_size::TextRange; use pgls_treesitter::TreesitterContext; @@ -36,31 +37,166 @@ pub(crate) fn only_leading_quote(ctx: &TreesitterContext) -> bool { is_sanitized_token_with_quote(node_under_cursor_txt) } +/// The name that qualifies a completed item. +pub(crate) enum Qualifier<'a> { + /// The schema of a table or function, left out if it is `public`. + Schema(&'a str), + /// The alias of the table of a column, also if it is named `public`. + Alias(&'a str), +} + +/// The text to insert for `item_name`, prefixed with its qualifier unless that is the `public` +/// schema or the user already wrote a qualifier. Names are quoted where Postgres would read +/// another name unquoted. `position` is where an unqualified `item_name` stands. pub(crate) fn with_schema_or_alias( ctx: &TreesitterContext, item_name: &str, - schema_or_alias_name: Option<&str>, + qualifier: Option, + position: NamePosition, ) -> String { let is_already_prefixed_with_schema_name = ctx.has_any_qualifier(); + let prefix = match qualifier { + Some(Qualifier::Schema("public")) | None => None, + Some(Qualifier::Schema(name) | Qualifier::Alias(name)) => Some(name), + }; + let with_quotes = node_text_surrounded_by_quotes(ctx); let single_leading_quote = only_leading_quote(ctx); - if schema_or_alias_name.is_none_or(|s| s == "public") || is_already_prefixed_with_schema_name { - if single_leading_quote { - format!(r#"{item_name}""#) - } else { - item_name.to_string() + // Inside quotes the user already typed, a name only needs its `"` escaped. + let escaped = |name: &str| name.replace('"', r#""""#); + + match prefix { + Some(prefix) if !is_already_prefixed_with_schema_name => { + if single_leading_quote { + format!(r#"{}"."{}""#, escaped(prefix), escaped(item_name)) + } else if with_quotes { + format!(r#"{}"."{}"#, escaped(prefix), escaped(item_name)) + } else { + format!( + "{}.{}", + quote_identifier(prefix, NamePosition::ColId), + quote_identifier(item_name, NamePosition::ColLabel) + ) + } + } + _ => { + if single_leading_quote { + format!(r#"{}""#, escaped(item_name)) + } else if with_quotes { + escaped(item_name) + } else if is_already_prefixed_with_schema_name { + quote_identifier(item_name, NamePosition::ColLabel).into_owned() + } else { + quote_identifier(item_name, position).into_owned() + } } - } else { - let schema_or_als = schema_or_alias_name.unwrap(); - - if single_leading_quote { - format!(r#"{}"."{}""#, schema_or_als.replace('"', ""), item_name) - } else if with_quotes { - format!(r#"{}"."{}"#, schema_or_als.replace('"', ""), item_name) - } else { - format!("{schema_or_als}.{item_name}") + } +} + +#[cfg(test)] +mod tests { + use pgls_test_utils::QueryWithCursorPosition; + use pgls_text_size::TextRange; + use sqlx::{Executor, PgPool}; + + use crate::{ + CompletionItem, complete, + test_helper::{get_test_deps, get_test_params}, + }; + + /// The completion item `label` at `{c}` in `query`. + async fn item(pool: &PgPool, query: &str, label: &str) -> Option { + let query = query.replace("{c}", &QueryWithCursorPosition::cursor_marker().to_string()); + let (tree, cache) = get_test_deps(None, query.as_str().into(), pool).await; + let params = get_test_params(&tree, &cache, query.as_str().into()); + complete(params) + .into_iter() + .find(|item| item.label == label) + } + + /// The text inserted for the completion item `label` at `{c}` in `query`. + async fn inserted(pool: &PgPool, query: &str, label: &str) -> Option { + item(pool, query, label) + .await + .map(|item| item.completion_text.map_or(item.label, |text| text.text)) + } + + #[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")] + async fn quotes_names_that_need_it(pool: PgPool) { + pool.execute( + r#" + create schema "My Schema"; + create table "MyTable" ("Id" int, "select" int, plain int); + create table "My Schema"."Other" (x int); + create function "MyFunc"() returns int language sql as 'select 1'; + create function "My Schema".helper() returns int language sql as 'select 1'; + create function "operator"() returns int language sql as 'select 1'; + "#, + ) + .await + .unwrap(); + + for (query, label, expected) in [ + // Tables and schemas. + ("select * from {c}", "MyTable", r#""MyTable""#), + ("select * from {c}", "My Schema", r#""My Schema""#), + ("select * from {c}", "Other", r#""My Schema"."Other""#), + (r#"select * from "My Schema".{c}"#, "Other", r#""Other""#), + // After an opening quote, only the closing one is missing. + (r#"select * from "{c}"#, "MyTable", r#"MyTable""#), + (r#"select * from "{c}"#, "Other", r#"My Schema"."Other""#), + // Columns. A keyword needs quotes on its own, but not after a dot. + (r#"select {c} from "MyTable""#, "Id", r#""Id""#), + (r#"select {c} from "MyTable""#, "select", r#""select""#), + (r#"select {c} from "MyTable""#, "plain", "plain"), + (r#"select t.{c} from "MyTable" t"#, "select", "select"), + (r#"select t.{c} from "MyTable" t"#, "Id", r#""Id""#), + // An alias named `public` still qualifies its columns. + ( + r#"select {c} from "MyTable" "public""#, + "plain", + "public.plain", + ), + ( + r#"select {c} from "MyTable" PUBLIC"#, + "plain", + "public.plain", + ), + // Functions. `operator(` would start an operator. + ("select {c}", "MyFunc", r#""MyFunc"()"#), + ("select {c}", "helper", r#""My Schema".helper()"#), + ("select {c}", "operator", r#""operator"()"#), + ] { + assert_eq!( + inserted(&pool, query, label).await.as_deref(), + Some(expected), + "{query} → {label}" + ); + } + } + + /// The range to replace is in bytes, like the cursor, also after multibyte characters. + #[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")] + async fn replaces_at_the_cursor_after_multibyte_characters(pool: PgPool) { + pool.execute("create schema private; create table people (name text);") + .await + .unwrap(); + + for (query, label) in [ + ("select 'é' from {c} ;", "private"), + ("select 'é' from {c}", "private"), + ("select 'é', {c} from people", "name"), + ] { + let cursor = query.find("{c}").unwrap() as u32; + let item = item(&pool, query, label).await; + assert_eq!( + item.and_then(|item| item.completion_text) + .map(|text| text.range), + Some(TextRange::empty(cursor.into())), + "{query} → {label}" + ); } } } diff --git a/crates/pgls_completions/src/providers/schemas.rs b/crates/pgls_completions/src/providers/schemas.rs index a17946b05..aef029b32 100644 --- a/crates/pgls_completions/src/providers/schemas.rs +++ b/crates/pgls_completions/src/providers/schemas.rs @@ -1,12 +1,14 @@ +use super::helper::{get_range_to_replace, with_schema_or_alias}; use crate::{ + CompletionText, builder::{CompletionBuilder, PossibleCompletionItem}, relevance::{CompletionRelevanceData, filtering::CompletionFilter, scoring::CompletionScore}, }; -use pgls_catalog::Snapshot; +use pgls_catalog::{NamePosition, Snapshot}; use pgls_treesitter::TreesitterContext; pub fn complete_schemas<'a>( - _ctx: &'a TreesitterContext, + ctx: &'a TreesitterContext, snapshot: &'a Snapshot, builder: &mut CompletionBuilder<'a>, ) { @@ -22,7 +24,11 @@ pub fn complete_schemas<'a>( score: CompletionScore::from(relevance.clone()), filter: CompletionFilter::from(relevance), detail: None, - completion_text: None, + completion_text: Some(CompletionText { + is_snippet: false, + range: get_range_to_replace(ctx), + text: with_schema_or_alias(ctx, schema.name.as_str(), None, NamePosition::ColId), + }), }; builder.add_item(item); diff --git a/crates/pgls_completions/src/providers/tables.rs b/crates/pgls_completions/src/providers/tables.rs index 0b4b41185..bd59b929d 100644 --- a/crates/pgls_completions/src/providers/tables.rs +++ b/crates/pgls_completions/src/providers/tables.rs @@ -1,4 +1,4 @@ -use pgls_catalog::{Snapshot, Table}; +use pgls_catalog::{NamePosition, Snapshot, Table}; use pgls_treesitter::TreesitterContext; use crate::{ @@ -9,7 +9,7 @@ use crate::{ relevance::{CompletionRelevanceData, filtering::CompletionFilter, scoring::CompletionScore}, }; -use super::helper::with_schema_or_alias; +use super::helper::{Qualifier, with_schema_or_alias}; pub fn complete_tables<'a>( ctx: &'a TreesitterContext, @@ -42,7 +42,12 @@ pub fn complete_tables<'a>( } fn get_completion_text(ctx: &TreesitterContext, table: &Table) -> CompletionText { - let text = with_schema_or_alias(ctx, table.name.as_str(), Some(table.schema.as_str())); + let text = with_schema_or_alias( + ctx, + table.name.as_str(), + Some(Qualifier::Schema(table.schema.as_str())), + NamePosition::ColId, + ); let range = get_range_to_replace(ctx); diff --git a/crates/pgls_completions/src/sanitization.rs b/crates/pgls_completions/src/sanitization.rs index 6c66342d8..0cdbe4368 100644 --- a/crates/pgls_completions/src/sanitization.rs +++ b/crates/pgls_completions/src/sanitization.rs @@ -1,4 +1,4 @@ -use std::{borrow::Cow, cmp::max}; +use std::borrow::Cow; use pgls_text_size::TextSize; @@ -73,48 +73,44 @@ where 'larger: 'smaller, { fn with_adjusted_sql(params: CompletionParams<'larger>) -> Self { + // the position is a byte offset: the text is walked by its bytes, not by its chars, + // or a multi-byte char before the cursor puts the token after it let cursor_pos: usize = params.position.into(); let mut sql = String::new(); - let mut sql_iter = params.text.chars(); - - let max = max(cursor_pos + 1, params.text.len()); - let has_uneven_quotes = params.text.chars().filter(|c| *c == '"').count() % 2 != 0; let mut opened_quote = false; + let mut inserted = false; - for idx in 0..max { - match sql_iter.next() { - Some(c) => { - if c == '"' { - opened_quote = !opened_quote; - } - - if idx == cursor_pos { - if opened_quote && has_uneven_quotes { - sql.push_str(SANITIZED_TOKEN_WITH_QUOTE); - opened_quote = false; - } else { - sql.push_str(SANITIZED_TOKEN); - } - } - - sql.push(c); - } - None => { - // the cursor is outside the statement, - // we want to push spaces until we arrive at the cursor position. - // we'll then add the SANITIZED_TOKEN - if idx == cursor_pos { - if opened_quote && has_uneven_quotes { - sql.push_str(SANITIZED_TOKEN_WITH_QUOTE); - } else { - sql.push_str(SANITIZED_TOKEN); - } - } else { - sql.push(' '); - } + for (idx, c) in params.text.char_indices() { + if c == '"' { + opened_quote = !opened_quote; + } + + if idx == cursor_pos { + if opened_quote && has_uneven_quotes { + sql.push_str(SANITIZED_TOKEN_WITH_QUOTE); + opened_quote = false; + } else { + sql.push_str(SANITIZED_TOKEN); } + inserted = true; + } + + sql.push(c); + } + + if !inserted { + // the cursor is outside the statement, + // we want to push spaces until we arrive at the cursor position. + // we'll then add the SANITIZED_TOKEN + for _ in params.text.len()..cursor_pos { + sql.push(' '); + } + if opened_quote && has_uneven_quotes { + sql.push_str(SANITIZED_TOKEN_WITH_QUOTE); + } else { + sql.push_str(SANITIZED_TOKEN); } } @@ -142,6 +138,16 @@ where } } +/// The char that ends right at the byte offset `position`, if any. +fn char_before(sql: &str, position: usize) -> Option { + sql.get(..position).and_then(|s| s.chars().next_back()) +} + +/// The char that starts at the byte offset `position`, if any. +fn char_at(sql: &str, position: usize) -> Option { + sql.get(position..).and_then(|s| s.chars().next()) +} + /// Checks if the cursor is positioned inbetween two SQL nodes. /// /// ```sql @@ -151,13 +157,10 @@ where /// ``` fn cursor_inbetween_nodes(sql: &str, position: TextSize) -> bool { let position: usize = position.into(); - let mut chars = sql.chars(); - let previous_whitespace = chars - .nth(position.saturating_sub(1)) - .is_some_and(|c| c.is_ascii_whitespace()); + let previous_whitespace = char_before(sql, position).is_some_and(|c| c.is_ascii_whitespace()); - let current_whitespace = chars.next().is_some_and(|c| c.is_ascii_whitespace()); + let current_whitespace = char_at(sql, position).is_some_and(|c| c.is_ascii_whitespace()); previous_whitespace && current_whitespace } @@ -177,9 +180,7 @@ fn cursor_prepared_to_write_token_after_last_node(sql: &str, position: TextSize) fn cursor_on_a_dot(sql: &str, position: TextSize) -> bool { let position: usize = position.into(); - sql.chars() - .nth(position.saturating_sub(1)) - .is_some_and(|c| c == '.') + char_before(sql, position).is_some_and(|c| c == '.') } fn cursor_before_semicolon(tree: &tree_sitter::Tree, position: TextSize) -> bool { @@ -230,7 +231,7 @@ fn cursor_between_parentheses(sql: &str, position: TextSize) -> bool { let mut matching_open_idx = None; let mut matching_close_idx = None; - for (idx, char) in sql.chars().enumerate() { + for (idx, char) in sql.char_indices() { if char == '(' { tracking_open_idx = Some(idx); level += 1; @@ -253,11 +254,8 @@ fn cursor_between_parentheses(sql: &str, position: TextSize) -> bool { // early check: '(|)' // however, we want to check this after the level nesting. - let mut chars = sql.chars(); - if chars - .nth(position.saturating_sub(1)) - .is_some_and(|c| c == '(') - && chars.next().is_some_and(|c| c == ')') + if char_before(sql, position).is_some_and(|c| c == '(') + && char_at(sql, position).is_some_and(|c| c == ')') { return true; } @@ -276,8 +274,9 @@ fn cursor_between_parentheses(sql: &str, position: TextSize) -> bool { .unwrap_or_default(); let after = sql + .get(position..) + .unwrap_or_default() .chars() - .skip(position) .find(|c| !c.is_whitespace()) .unwrap_or_default(); @@ -678,11 +677,55 @@ mod tests { assert_eq!( sanitized.text, - r#"select * from "auth"."REPLACED_TOKEN_WITH_QUOTE"é; "# + r#"select * from "auth"."REPLACED_TOKEN_WITH_QUOTE"é;"# ); assert_eq!(sanitized.position, TextSize::new(22)); // leaves cursor position as is } + { + // multi-byte chars before the cursor: the position is a byte offset + // select 'щщ', u.id from users u where u.| + let input = "select 'щщ', u.id from users u where u."; + let position = TextSize::new(input.len() as u32); + + assert!(cursor_on_a_dot(input, position)); + + let params = get_test_params(input, position); + + let sanitized = SanitizedCompletionParams::from(params); + + assert_eq!( + sanitized.text, + "select 'щщ', u.id from users u where u.REPLACED_TOKEN" + ); + } + + { + // -- заказы + // select | from users; + let input = "-- заказы\nselect from users;"; + let position = TextSize::new("-- заказы\nselect ".len() as u32); + + assert!(cursor_inbetween_nodes(input, position)); + + let params = get_test_params(input, position); + + let sanitized = SanitizedCompletionParams::from(params); + + assert_eq!( + sanitized.text, + "-- заказы\nselect REPLACED_TOKEN from users;" + ); + } + + { + // select 'щ' in (|) + let input = "select 'щ' in ()"; + let position = TextSize::new("select 'щ' in (".len() as u32); + + assert!(cursor_between_parentheses(input, position)); + } + { let input = "insert into instruments (name, id, €, )"; let position = TextSize::new(36); diff --git a/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__completes_quoted_columns.snap b/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__completes_quoted_columns.snap index 48a8b1d9a..fa9303a00 100644 --- a/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__completes_quoted_columns.snap +++ b/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__completes_quoted_columns.snap @@ -33,7 +33,7 @@ email - private.users.email (Column) id - private.users.id (Column) name - private.users.name (Column) quoted_column - private.users.quoted_column (Column) -pg_catalog.RI_FKey_cascade_del() - Schema: pg_catalog.RI_FKey_cascade_del (Function) +pg_catalog."RI_FKey_cascade_del"() - Schema: pg_catalog.RI_FKey_cascade_del (Function) -------------- @@ -104,10 +104,10 @@ private.users - private.users (Table) select "email" from "| Results: -private - private (Schema) -information_schema - information_schema (Schema) -pg_catalog - pg_catalog (Schema) -pg_toast - pg_toast (Schema) +private" - private (Schema) +information_schema" - information_schema (Schema) +pg_catalog" - pg_catalog (Schema) +pg_toast" - pg_toast (Schema) private"."users" - private.users (Table) -------------- diff --git a/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__completes_quoted_columns_with_aliases.snap b/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__completes_quoted_columns_with_aliases.snap index ba6ed1d84..3c35a7a03 100644 --- a/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__completes_quoted_columns_with_aliases.snap +++ b/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__completes_quoted_columns_with_aliases.snap @@ -173,10 +173,10 @@ s| from private.users "pr" select | from private.users "pr" Results: -"pr".email - private.users.email (Column) -"pr".id - private.users.id (Column) -"pr".name - private.users.name (Column) -"pr".quoted_column - private.users.quoted_column (Column) +pr.email - private.users.email (Column) +pr.id - private.users.id (Column) +pr.name - private.users.name (Column) +pr.quoted_column - private.users.quoted_column (Column) name - public.names.name (Column) -------------- @@ -225,10 +225,10 @@ s| from private.users "pr" select | from private.users "pr" Results: -"pr".email - private.users.email (Column) -"pr".id - private.users.id (Column) -"pr".name - private.users.name (Column) -"pr".quoted_column - private.users.quoted_column (Column) +pr.email - private.users.email (Column) +pr.id - private.users.id (Column) +pr.name - private.users.name (Column) +pr.quoted_column - private.users.quoted_column (Column) name - public.names.name (Column) -------------- @@ -236,10 +236,10 @@ name - public.names.name (Column) select p| from private.users "pr" Results: -"pr".email - private.users.email (Column) -"pr".id - private.users.id (Column) -"pr".name - private.users.name (Column) -"pr".quoted_column - private.users.quoted_column (Column) +pr.email - private.users.email (Column) +pr.id - private.users.id (Column) +pr.name - private.users.name (Column) +pr.quoted_column - private.users.quoted_column (Column) pad_attribute - information_schema.collations.pad_attribute (Column) -------------- @@ -294,10 +294,10 @@ s| from private.users "pr" select | from private.users "pr" Results: -"pr".email - private.users.email (Column) -"pr".id - private.users.id (Column) -"pr".name - private.users.name (Column) -"pr".quoted_column - private.users.quoted_column (Column) +pr.email - private.users.email (Column) +pr.id - private.users.id (Column) +pr.name - private.users.name (Column) +pr.quoted_column - private.users.quoted_column (Column) name - public.names.name (Column) -------------- @@ -385,10 +385,10 @@ s| from private.users "pr" select | from private.users "pr" Results: -"pr".email - private.users.email (Column) -"pr".id - private.users.id (Column) -"pr".name - private.users.name (Column) -"pr".quoted_column - private.users.quoted_column (Column) +pr.email - private.users.email (Column) +pr.id - private.users.id (Column) +pr.name - private.users.name (Column) +pr.quoted_column - private.users.quoted_column (Column) name - public.names.name (Column) -------------- @@ -396,10 +396,10 @@ name - public.names.name (Column) select p| from private.users "pr" Results: -"pr".email - private.users.email (Column) -"pr".id - private.users.id (Column) -"pr".name - private.users.name (Column) -"pr".quoted_column - private.users.quoted_column (Column) +pr.email - private.users.email (Column) +pr.id - private.users.id (Column) +pr.name - private.users.name (Column) +pr.quoted_column - private.users.quoted_column (Column) pad_attribute - information_schema.collations.pad_attribute (Column) -------------- @@ -437,9 +437,9 @@ select | from private.users "pr" join public.names n on pr.id = n.uid; Results: n.name - public.names.name (Column) n.uid - public.names.uid (Column) -"pr".email - private.users.email (Column) -"pr".id - private.users.id (Column) -"pr".name - private.users.name (Column) +pr.email - private.users.email (Column) +pr.id - private.users.id (Column) +pr.name - private.users.name (Column) -------------- @@ -500,9 +500,9 @@ select "pr"."email", | from private.users "pr" join public.names n on pr.id = n. Results: n.name - public.names.name (Column) n.uid - public.names.uid (Column) -"pr".id - private.users.id (Column) -"pr".name - private.users.name (Column) -"pr".quoted_column - private.users.quoted_column (Column) +pr.id - private.users.id (Column) +pr.name - private.users.name (Column) +pr.quoted_column - private.users.quoted_column (Column) -------------- diff --git a/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__suggests_relevant_columns_without_letters.snap b/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__suggests_relevant_columns_without_letters.snap index 764f01ab6..e46596bff 100644 --- a/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__suggests_relevant_columns_without_letters.snap +++ b/crates/pgls_completions/src/snapshots/pgls_completions__test_helper__suggests_relevant_columns_without_letters.snap @@ -32,7 +32,7 @@ address - public.users.address (Column) email - public.users.email (Column) id - public.users.id (Column) name - public.users.name (Column) -pg_catalog.RI_FKey_cascade_del() - Schema: pg_catalog.RI_FKey_cascade_del (Function) +pg_catalog."RI_FKey_cascade_del"() - Schema: pg_catalog.RI_FKey_cascade_del (Function) -------------- diff --git a/crates/pgls_lexer/src/lib.rs b/crates/pgls_lexer/src/lib.rs index fdd46cd3e..7339600cf 100644 --- a/crates/pgls_lexer/src/lib.rs +++ b/crates/pgls_lexer/src/lib.rs @@ -11,6 +11,11 @@ pub use crate::params::{ convert_to_positional_params_with_metadata, }; +/// The category of the keyword `word`, compared case-insensitively. `None` if it's no keyword. +pub fn keyword_category(word: &str) -> Option { + SyntaxKind::from_keyword(word)?.keyword_category() +} + /// Lex the input string into tokens and diagnostics pub fn lex(input: &str) -> Lexed<'_> { Lexer::new(input).lex()