diff --git a/README.md b/README.md index c2362815a..2a00c6031 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ This project provides a toolchain for Postgres development, built on Postgres' o The following features are available today: -- [Autocompletion & Hover](https://pg-language-server.com/latest/features/editor_features/) +- [Autocompletion, Hover, Inlay Hints & Semantic Highlighting](https://pg-language-server.com/latest/features/editor_features/) - [Syntax Diagnostics](https://pg-language-server.com/latest/features/syntax_diagnostics/) - [Type Checking](https://pg-language-server.com/latest/features/type_checking/) - [Formatting](https://pg-language-server.com/latest/features/formatting/) diff --git a/crates/pgls_configuration/src/inlay_hints.rs b/crates/pgls_configuration/src/inlay_hints.rs new file mode 100644 index 000000000..937d4bd5a --- /dev/null +++ b/crates/pgls_configuration/src/inlay_hints.rs @@ -0,0 +1,20 @@ +use bpaf::Bpaf; +use pgls_configuration_macros::{Merge, Partial}; +use serde::{Deserialize, Serialize}; + +/// The configuration for inlay hints. +#[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)] +#[partial(derive(Bpaf, Clone, Eq, PartialEq, Merge))] +#[partial(cfg_attr(feature = "schema", derive(schemars::JsonSchema)))] +#[partial(serde(rename_all = "camelCase", default, deny_unknown_fields))] +pub struct InlayHintsConfiguration { + /// if `false`, the language server sends no inlay hints. `true` by default + #[partial(bpaf(hide))] + pub enabled: bool, +} + +impl Default for InlayHintsConfiguration { + fn default() -> Self { + Self { enabled: true } + } +} diff --git a/crates/pgls_configuration/src/lib.rs b/crates/pgls_configuration/src/lib.rs index 579d73086..aa285af14 100644 --- a/crates/pgls_configuration/src/lib.rs +++ b/crates/pgls_configuration/src/lib.rs @@ -10,11 +10,13 @@ pub mod database; pub mod diagnostics; pub mod files; pub mod format; +pub mod inlay_hints; pub mod linter; pub mod migrations; pub mod pglinter; pub mod plpgsql_check; pub mod rules; +pub mod semantic_tokens; pub mod splinter; pub mod typecheck; pub mod utils; @@ -37,6 +39,9 @@ pub use format::{ FormatConfiguration, FunctionArgumentGroups, IndentStyle as FormatIndentStyle, KeywordCase, PartialFormatConfiguration, partial_format_configuration, }; +use inlay_hints::{ + InlayHintsConfiguration, PartialInlayHintsConfiguration, partial_inlay_hints_configuration, +}; pub use linter::{ LinterConfiguration, LinterRuleSettings, PartialLinterConfiguration, Rules, partial_linter_configuration, @@ -57,6 +62,10 @@ pub use rules::{ AnalyzerGroup, RuleConfiguration, RuleFixConfiguration, RulePlainConfiguration, RuleSelector, RuleWithFixOptions, RuleWithOptions, }; +use semantic_tokens::{ + PartialSemanticTokensConfiguration, SemanticTokensConfiguration, + partial_semantic_tokens_configuration, +}; use serde::{Deserialize, Serialize}; use splinter::{ PartialSplinterConfiguration, SplinterConfiguration, partial_splinter_configuration, @@ -131,6 +140,20 @@ pub struct Configuration { bpaf(external(partial_database_configuration), optional, hide_usage) )] pub db: DatabaseConfiguration, + + /// The configuration for inlay hints in editors + #[partial( + type, + bpaf(external(partial_inlay_hints_configuration), optional, hide_usage) + )] + pub inlay_hints: InlayHintsConfiguration, + + /// The configuration for semantic highlighting in editors + #[partial( + type, + bpaf(external(partial_semantic_tokens_configuration), optional, hide_usage) + )] + pub semantic_tokens: SemanticTokensConfiguration, } impl PartialConfiguration { @@ -197,6 +220,8 @@ impl PartialConfiguration { conn_timeout_secs: Some(10), disable_connection: Some(false), }), + inlay_hints: None, + semantic_tokens: None, } } diff --git a/crates/pgls_configuration/src/semantic_tokens.rs b/crates/pgls_configuration/src/semantic_tokens.rs new file mode 100644 index 000000000..93273eca1 --- /dev/null +++ b/crates/pgls_configuration/src/semantic_tokens.rs @@ -0,0 +1,21 @@ +use bpaf::Bpaf; +use pgls_configuration_macros::{Merge, Partial}; +use serde::{Deserialize, Serialize}; + +/// The configuration for semantic highlighting. +#[derive(Clone, Debug, Deserialize, Eq, Partial, PartialEq, Serialize)] +#[partial(derive(Bpaf, Clone, Eq, PartialEq, Merge))] +#[partial(cfg_attr(feature = "schema", derive(schemars::JsonSchema)))] +#[partial(serde(rename_all = "camelCase", default, deny_unknown_fields))] +pub struct SemanticTokensConfiguration { + /// if `false`, the language server sends no semantic tokens, and editors keep the + /// highlighting of their grammar. `true` by default + #[partial(bpaf(hide))] + pub enabled: bool, +} + +impl Default for SemanticTokensConfiguration { + fn default() -> Self { + Self { enabled: true } + } +} diff --git a/crates/pgls_lsp/src/configuration.rs b/crates/pgls_lsp/src/configuration.rs index f876dc6ef..8bdb6cab3 100644 --- a/crates/pgls_lsp/src/configuration.rs +++ b/crates/pgls_lsp/src/configuration.rs @@ -123,7 +123,9 @@ define_section_mask!( pglinter, typecheck, plpgsql_check, - db + db, + inlay_hints, + semantic_tokens ); /// The sticky layer of configuration a client supplies at runtime. diff --git a/crates/pgls_lsp/src/handlers/code_actions.rs b/crates/pgls_lsp/src/handlers/code_actions.rs index 6eba5640a..9270299a8 100644 --- a/crates/pgls_lsp/src/handlers/code_actions.rs +++ b/crates/pgls_lsp/src/handlers/code_actions.rs @@ -332,6 +332,8 @@ pub async fn execute_command( } "pgls.invalidateSchemaCache" => { session.workspace.invalidate_schema_cache(true)?; + // Everything that depends on the schema is out of date. + session.update_all_diagnostics().await; session .client diff --git a/crates/pgls_lsp/src/handlers/inlay_hints.rs b/crates/pgls_lsp/src/handlers/inlay_hints.rs index 6325aa20e..f7180e5f2 100644 --- a/crates/pgls_lsp/src/handlers/inlay_hints.rs +++ b/crates/pgls_lsp/src/handlers/inlay_hints.rs @@ -29,7 +29,10 @@ pub(crate) fn inlay_hints( }) { Ok(result) => result, Err(e) => match e { - WorkspaceError::DatabaseConnectionError(_) => return Ok(None), + WorkspaceError::DatabaseConnectionError(_) => { + session.editor_features_without_database(); + return Ok(None); + } _ => return Err(e.into()), }, }; diff --git a/crates/pgls_lsp/src/handlers/semantic_tokens.rs b/crates/pgls_lsp/src/handlers/semantic_tokens.rs index 6b08c8be9..e33576365 100644 --- a/crates/pgls_lsp/src/handlers/semantic_tokens.rs +++ b/crates/pgls_lsp/src/handlers/semantic_tokens.rs @@ -89,7 +89,10 @@ fn semantic_tokens( }) { Ok(result) => result, Err(e) => match e { - WorkspaceError::DatabaseConnectionError(_) => return Ok(None), + WorkspaceError::DatabaseConnectionError(_) => { + session.editor_features_without_database(); + return Ok(None); + } _ => return Err(e.into()), }, }; diff --git a/crates/pgls_lsp/src/session.rs b/crates/pgls_lsp/src/session.rs index a25056c90..571c11581 100644 --- a/crates/pgls_lsp/src/session.rs +++ b/crates/pgls_lsp/src/session.rs @@ -154,6 +154,11 @@ pub(crate) struct Session { /// before a new task is spawned. This ensures that only one analysis run /// fires at the end of a rapid-fire burst rather than one per keystroke. diagnostic_debounce: Arc>>, + + /// Set when inlay hints or semantic tokens were requested while the database was + /// unreachable. The next diagnostics run that finds it reachable asks the client to request + /// them again. + editor_features_without_database: AtomicBool, } /// The parameters provided by the client in the "initialize" request @@ -263,6 +268,7 @@ impl Session { #[cfg(test)] max_running_transitions: std::sync::atomic::AtomicUsize::new(0), diagnostic_debounce: Arc::new(Mutex::new(FxHashMap::default())), + editor_features_without_database: AtomicBool::new(false), } } @@ -511,9 +517,64 @@ impl Session { .publish_diagnostics(url, diagnostics, Some(captured_version)) .await; + if self + .editor_features_without_database + .load(Ordering::Relaxed) + { + let connection = self.workspace.check_database_connection( + features::diagnostics::CheckDatabaseConnectionParams { + project_key: self.project_key(&pgls_path), + }, + ); + if connection.is_ok_and(|connection| connection.error.is_none()) + && self + .editor_features_without_database + .swap(false, Ordering::Relaxed) + { + self.refresh_editor_features(); + } + } + Ok(()) } + /// Records that inlay hints or semantic tokens were requested while the database was + /// unreachable, so that the client is asked to request them again once it is reachable. + pub(crate) fn editor_features_without_database(&self) { + self.editor_features_without_database + .store(true, Ordering::Relaxed); + } + + /// Asks the client to request inlay hints and semantic tokens again, if it supports that. + /// Called whenever their results may have changed without an edit: the configuration was + /// reloaded, the schema changed, or the database became reachable. + pub(crate) fn refresh_editor_features(&self) { + let workspace = self + .client_capabilities() + .and_then(|capabilities| capabilities.workspace.as_ref()); + let inlay_hints = workspace + .and_then(|workspace| workspace.inlay_hint.as_ref()) + .and_then(|inlay_hint| inlay_hint.refresh_support) + .unwrap_or(false); + let semantic_tokens = workspace + .and_then(|workspace| workspace.semantic_tokens.as_ref()) + .and_then(|semantic_tokens| semantic_tokens.refresh_support) + .unwrap_or(false); + if !inlay_hints && !semantic_tokens { + return; + } + // The client answers these requests asynchronously, so don't wait for it. + let client = self.client.clone(); + tokio::spawn(async move { + if inlay_hints && let Err(err) = client.inlay_hint_refresh().await { + error!("Failed to refresh inlay hints: {err}"); + } + if semantic_tokens && let Err(err) = client.semantic_tokens_refresh().await { + error!("Failed to refresh semantic tokens: {err}"); + } + }); + } + /// Schedules a debounced diagnostic update for the given URL. /// /// If a diagnostic task for `url` is already pending it is aborted first, @@ -558,7 +619,8 @@ impl Session { } } - /// Updates diagnostics for every [`Document`] in this [`Session`] + /// Updates diagnostics for every [`Document`] in this [`Session`], and asks the client to + /// request inlay hints and semantic tokens again. pub(crate) async fn update_all_diagnostics(&self) { let mut futures: FuturesUnordered<_> = self .documents @@ -573,6 +635,10 @@ impl Session { error!("Error while updating diagnostics: {}", e); } } + + // Whatever invalidated the diagnostics of every document may have changed the inlay + // hints and semantic tokens too. + self.refresh_editor_features(); } /// Get a [`Document`] matching the provided [`ls_types::Uri`] diff --git a/crates/pgls_lsp/tests/server.rs b/crates/pgls_lsp/tests/server.rs index 86a6ce597..5a2f15a0a 100644 --- a/crates/pgls_lsp/tests/server.rs +++ b/crates/pgls_lsp/tests/server.rs @@ -421,6 +421,8 @@ const CHANNEL_BUFFER_SIZE: usize = 8; #[derive(Debug, PartialEq, Eq)] enum ServerNotification { PublishDiagnostics(PublishDiagnosticsParams), + /// A `workspace/*/refresh` request, by its method. + Refresh(String), } /// Basic handler for requests and notifications coming from the server for tests @@ -451,6 +453,20 @@ where None => continue, }; + if matches!( + req.method(), + "workspace/inlayHint/refresh" | "workspace/semanticTokens/refresh" + ) { + sink.send(Response::from_ok(id.clone(), Value::Null)) + .await + .ok(); + let notification = ServerNotification::Refresh(req.method().to_string()); + match notify.send(notification).await { + Ok(_) => continue, + Err(_) => break, + } + } + let res = Response::from_error(id.clone(), jsonrpc::Error::method_not_found()); sink.send(res).await.ok(); @@ -699,6 +715,7 @@ async fn client_configuration_overrides_are_sticky_and_clearable(test_db: PgPool return true; } } + Some(ServerNotification::Refresh(_)) => {} None => return false, } } @@ -3327,6 +3344,176 @@ async fn test_crash_on_delete_character() -> Result<()> { Ok(()) } +/// Waits for the `workspace/*/refresh` requests of inlay hints and semantic tokens. +async fn wait_for_editor_feature_refresh( + receiver: &mut Receiver, +) -> Result<()> { + let mut pending = vec![ + "workspace/inlayHint/refresh", + "workspace/semanticTokens/refresh", + ]; + tokio::time::timeout(Duration::from_secs(5), async { + while !pending.is_empty() { + match receiver.next().await { + Some(ServerNotification::Refresh(method)) => { + pending.retain(|pending| *pending != method); + } + Some(ServerNotification::PublishDiagnostics(_)) => {} + None => bail!("the server closed the connection"), + } + } + Ok(()) + }) + .await + .context("timed out waiting for the inlay hint and semantic token refresh")? +} + +async fn inlay_hint_count(server: &mut Server) -> Result { + let hints: Option> = server + .request( + "textDocument/inlayHint", + "inlay_hints", + lsp::InlayHintParams { + work_done_progress_params: WorkDoneProgressParams::default(), + text_document: TextDocumentIdentifier { + uri: url!("document.sql"), + }, + range: Range { + start: Position::new(0, 0), + end: Position::new(1, 0), + }, + }, + ) + .await?; + Ok(hints.map_or(0, |hints| hints.len())) +} + +async fn semantic_token_count(server: &mut Server) -> Result { + let tokens: Option = server + .request( + "textDocument/semanticTokens/full", + "semantic_tokens", + lsp::SemanticTokensParams { + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: lsp::PartialResultParams::default(), + text_document: TextDocumentIdentifier { + uri: url!("document.sql"), + }, + }, + ) + .await?; + Ok(match tokens { + Some(lsp::SemanticTokensResult::Tokens(tokens)) => tokens.data.len(), + _ => 0, + }) +} + +/// Inlay hints and semantic tokens are refreshed when the schema cache is invalidated and when the +/// configuration changes, and the configuration can turn them off. +#[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")] +async fn test_editor_features_refresh_and_settings(test_db: PgPool) -> Result<()> { + let factory = ServerFactory::default(); + let mut fs = MemoryFileSystem::default(); + + test_db + .execute( + "create table people (id int, name text); + create function make_point(x int, y int) returns int language sql as 'select x + y';", + ) + .await + .expect("Failed to setup test database"); + + let mut conf = PartialConfiguration::init(); + conf.merge_with(PartialConfiguration { + db: Some(PartialDatabaseConfiguration { + database: Some( + test_db + .connect_options() + .get_database() + .unwrap() + .to_string(), + ), + ..Default::default() + }), + ..Default::default() + }); + fs.insert( + url!("postgres-language-server.jsonc") + .to_file_path() + .unwrap() + .into_owned(), + serde_json::to_string_pretty(&conf).unwrap(), + ); + + let (service, client) = factory + .create_with_fs(None, DynRef::Owned(Box::new(fs))) + .into_inner(); + let (stream, sink) = client.split(); + let mut server = Server::new(service); + let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE); + let reader = tokio::spawn(client_handler(stream, sink, sender)); + + server + .initialize_with_capabilities(ClientCapabilities { + workspace: Some(lsp::WorkspaceClientCapabilities { + inlay_hint: Some(lsp::InlayHintWorkspaceClientCapabilities { + refresh_support: Some(true), + }), + semantic_tokens: Some(lsp::SemanticTokensWorkspaceClientCapabilities { + refresh_support: Some(true), + }), + ..Default::default() + }), + ..Default::default() + }) + .await?; + server.initialized().await?; + server.load_configuration().await?; + wait_for_editor_feature_refresh(&mut receiver).await?; + + server + .open_document("select name, make_point(1, 2) from people;\n") + .await?; + + assert_eq!(inlay_hint_count(&mut server).await?, 2); + assert_eq!(semantic_token_count(&mut server).await?, 3); + + drain_notifications(&mut receiver); + server + .request::>( + "workspace/executeCommand", + "_invalidate", + ExecuteCommandParams { + command: "pgls.invalidateSchemaCache".to_string(), + arguments: vec![], + ..Default::default() + }, + ) + .await?; + wait_for_editor_feature_refresh(&mut receiver).await?; + + drain_notifications(&mut receiver); + server + .notify( + "workspace/didChangeConfiguration", + DidChangeConfigurationParams { + settings: json!({ + "inlayHints": { "enabled": false }, + "semanticTokens": { "enabled": false }, + }), + }, + ) + .await?; + wait_for_editor_feature_refresh(&mut receiver).await?; + + assert_eq!(inlay_hint_count(&mut server).await?, 0); + assert_eq!(semantic_token_count(&mut server).await?, 0); + + server.shutdown().await?; + reader.abort(); + Ok(()) +} + /// A document of another scheme with the path of an open file, like the `git:` side of a diff in /// VS Code, has a workspace document of its own. Closing it used to close the document of the /// file, and the next edit of the file was never analysed. diff --git a/crates/pgls_workspace/src/settings.rs b/crates/pgls_workspace/src/settings.rs index 454f65f5c..eb02aad7b 100644 --- a/crates/pgls_workspace/src/settings.rs +++ b/crates/pgls_workspace/src/settings.rs @@ -22,9 +22,11 @@ use pgls_configuration::{ CastStyle, ClauseBodyStyle, CommaStyle, FormatConfiguration, IndentStyle, KeywordCase, Layout, LogicalOperatorPlacement, }, + inlay_hints::InlayHintsConfiguration, migrations::{MigrationsConfiguration, PartialMigrationsConfiguration, SectionMarkers}, pglinter::PglinterConfiguration, plpgsql_check::PlPgSqlCheckConfiguration, + semantic_tokens::SemanticTokensConfiguration, splinter::SplinterConfiguration, }; use pgls_fs::PgLSPath; @@ -219,6 +221,12 @@ pub struct Settings { /// Migrations settings pub migrations: Option, + + /// Inlay hint settings for the workspace + pub inlay_hints: InlayHintsSettings, + + /// Semantic token settings for the workspace + pub semantic_tokens: SemanticTokensSettings, } impl Settings { @@ -281,6 +289,20 @@ impl Settings { to_plpgsql_check_settings(PlPgSqlCheckConfiguration::from(plpgsql_check)); } + // inlay hints part + if let Some(inlay_hints) = configuration.inlay_hints { + self.inlay_hints = InlayHintsSettings { + enabled: InlayHintsConfiguration::from(inlay_hints).enabled, + }; + } + + // semantic tokens part + if let Some(semantic_tokens) = configuration.semantic_tokens { + self.semantic_tokens = SemanticTokensSettings { + enabled: SemanticTokensConfiguration::from(semantic_tokens).enabled, + }; + } + // Migrations settings if let Some(migrations) = configuration.migrations { self.migrations = Some(to_migration_settings( @@ -655,6 +677,30 @@ impl Default for PglinterSettings { } } +/// Inlay hint settings for the entire workspace +#[derive(Debug, Clone)] +pub struct InlayHintsSettings { + pub enabled: bool, +} + +impl Default for InlayHintsSettings { + fn default() -> Self { + Self { enabled: true } + } +} + +/// Semantic token settings for the entire workspace +#[derive(Debug, Clone)] +pub struct SemanticTokensSettings { + pub enabled: bool, +} + +impl Default for SemanticTokensSettings { + fn default() -> Self { + Self { enabled: true } + } +} + /// plpgsql_check settings for the entire workspace #[derive(Debug, Clone)] pub struct PlPgSqlCheckSettings { diff --git a/crates/pgls_workspace/src/workspace/server.rs b/crates/pgls_workspace/src/workspace/server.rs index 5a5e62e7f..15513b20c 100644 --- a/crates/pgls_workspace/src/workspace/server.rs +++ b/crates/pgls_workspace/src/workspace/server.rs @@ -1838,6 +1838,9 @@ impl Workspace for WorkspaceServer { let settings = self.workspaces(); let settings = settings.settings_for(params.project_key, params.path.as_path()); + if !settings.is_none_or(|settings| settings.inlay_hints.enabled) { + return Ok(PullInlayHintsResult::default()); + } #[cfg(feature = "db")] let Some(snapshot) = settings @@ -1908,6 +1911,9 @@ impl Workspace for WorkspaceServer { let settings = self.workspaces(); let settings = settings.settings_for(params.project_key, params.path.as_path()); + if !settings.is_none_or(|settings| settings.semantic_tokens.enabled) { + return Ok(PullSemanticTokensResult::default()); + } #[cfg(feature = "db")] let Some(snapshot) = settings diff --git a/docs/features/editor_features.md b/docs/features/editor_features.md index c333bdd48..7873e8430 100644 --- a/docs/features/editor_features.md +++ b/docs/features/editor_features.md @@ -1,64 +1,77 @@ -# Autocompletion & Hover +# Editor Features -The language server provides autocompletion and hover information when connected to a database. +When connected to a database, the language server helps you write SQL with autocompletion, hover information, inlay hints, and semantic highlighting. -## Autocompletion +Hover, inlay hints, and semantic highlighting follow Postgres' name resolution. They see the database schema with the DDL of the current file applied on top of it, so a table created or altered earlier in the file is known, and the search path follows `set search_path` in the file. They also work in the bodies of `LANGUAGE sql` functions, where the function's parameters are in scope. -As you type SQL, the language server suggests relevant database objects based on your current context: +## Requirements -- **Tables**: Available tables from your database schema -- **Columns**: Columns from tables referenced in your query -- **Functions**: Database functions and procedures -- **Schemas**: Available schemas in your database -- **Keywords**: SQL keywords and syntax +All these features need: -The suggestions are context-aware - for example, when typing after `FROM`, you'll see table suggestions, and when typing after `SELECT`, you'll see column suggestions from relevant tables. +- A configured database connection. See the [database configuration guide](../guides/configure_database.md). +- A database user that can read the schema. -## Hover Information +Without a database connection, they are not available. The language server caches the schema when it connects. Run the `pgls.invalidateSchemaCache` command after changing the schema outside the editor. -Hovering over database objects in your SQL shows detailed information: +## Autocompletion -- **Tables**: Schema, column list with data types -- **Columns**: Data type, nullable status, table location -- **Functions**: Return type, parameter information +As you type SQL, the language server suggests relevant database objects based on your current context: -The hover information is pulled from your database schema. +- **Tables**: Available tables from your database schema +- **Columns**: Columns from tables referenced in your query +- **Functions**: Database functions and procedures +- **Schemas**: Available schemas in your database +- **Keywords**: SQL keywords and syntax -## Requirements +The suggestions are context-aware - for example, when typing after `FROM`, you'll see table suggestions, and when typing after `SELECT`, you'll see column suggestions from relevant tables. -Both features require: -- A configured database connection -- The language server must be able to read schema information from your database +## Hover -Without a database connection, these features are not available. +Hovering a name shows the object it refers to: -## Configuration +- **Tables and views**: schema, columns with their types, and defaults. +- **Columns**: type, nullability, and the table they belong to. Columns are followed through aliases and joins, so `o.name` shows the column of the table that `o` stands for. +- **Columns of CTEs and subqueries**: their name and inferred type. +- **Functions**: only the overload that the call resolves to, with its signature and return type. +- **Schemas, types, and roles.** -These features work automatically when you have a database connection configured. See the [database configuration guide](../guides/configure_database.md) for setup instructions. +```sql +create function pick(i integer) returns integer language sql as 'select i'; +create function pick(t text) returns text language sql as 'select t'; +select pick(1); -- hover on `pick`: only `public.pick(i integer) → integer` +``` -The language server caches schema information on startup. +When a name doesn't resolve to exactly one object, hover falls back to looking the name up in the schema and shows every match. ## Inlay Hints -Function calls show the names of the parameters their positional arguments are passed to, as in `make_point(x: 1, y: 2)`. Hints are only shown when the call resolves to exactly one function, including functions created earlier in the same file. Named arguments (`f(a => 1)`), unnamed parameters, and arguments that already carry the parameter's name (`f(t.x)` for the parameter `x`) get no hint. Of the elements passed to a `VARIADIC` parameter, only the first is labelled. +Function calls show the names of the parameters their positional arguments are passed to: -Inlay hints require a database connection. Enable them in your editor, e.g. with `editor.inlayHints.enabled` in VS Code. +```sql +select make_point(1, 2 + 3); -- make_point(x: 1, y: 2 + 3) +select make_point(1, y => 2); -- make_point(x: 1, y => 2) +select sum_all(1, 2, 3); -- sum_all(base: 1, nums: 2, 3) +``` + +Hints are only shown when the call resolves to exactly one function, including functions created earlier in the same file. Named arguments (`f(a => 1)`), unnamed parameters, and arguments that already carry the parameter's name (`f(t.x)` for the parameter `x`) get no hint. Of the elements passed to a `VARIADIC` parameter, only the first is labelled. ## Semantic Highlighting The language server provides semantic tokens for the names that resolve to exactly one database object, so your editor can color them by what they refer to rather than by how they look: -- **Schemas** (`namespace`), like `public` in `public.users` -- **Tables, views, and other relations** (`class`) -- **Columns** (`property`) -- **Functions** (`function`), the overload that the call resolves to -- **Types** (`type`), like `int8` in `x::int8` -- **Parameters** (`parameter`) of a SQL function, in its body, by name or as `$1` -- **Enum values** (`enumMember`): string literals that are coerced to an enum type, like `'active'` in `status = 'active'`, `'active'::status`, or `insert into t (status) values ('active')` +| Token | LSP token type | Example | +| --- | --- | --- | +| Schema | `namespace` | `public` in `public.users` | +| Table, view, or other relation | `class` | `users` in `from users`, `users.id` | +| Column | `property` | `name`, `u.name`, `public.users.id` | +| Function | `function` | the overload that a call resolves to | +| Type | `type` | `int8` in `x::int8`, `cast(x as text)`, `date 'today'` | +| Parameter of a SQL function, in its body | `parameter` | `id`, `fn.id`, `$1` | +| Enum value | `enumMember` | `'active'` in `status = 'active'` | -Objects in `pg_catalog` and `information_schema` carry the `defaultLibrary` modifier. Names follow Postgres' name resolution, including objects created earlier in the same file and statements in the bodies of SQL functions. Names that match no object or several get no token and keep the highlighting of your editor's grammar. +Objects in `pg_catalog` and `information_schema` carry the `defaultLibrary` modifier. Names that match no object or several get no token and keep the highlighting of your editor's grammar. -Semantic highlighting requires a database connection. In VS Code, it is controlled by `editor.semanticHighlighting.enabled`. +String literals are enum values only where the enum type is certain: casts (`'active'::status`), comparisons with an enum column or value (`=`, `<>`, `IN`, `BETWEEN`, `IS DISTINCT FROM`, `NULLIF`), arguments of function parameters with the enum type, and values assigned to enum columns in `INSERT ... VALUES`, `UPDATE ... SET`, and `ON CONFLICT DO UPDATE SET`. ## Expand `*` @@ -73,3 +86,27 @@ select u.id, u.name, p.id, p.user_id, p.title from users u join posts p on p.use Columns are qualified with the name or alias of their table when there are several in `FROM`, and quoted where needed. The columns of CTEs, subqueries, and tables created earlier in the file are expanded too. The action is only offered when every column is known and can be referenced unambiguously, so not for joins with `USING` or `NATURAL`, tables whose columns are unknown, or sources with two columns of the same name. Like hover, it requires a database connection. + +## Turning Features Off + +Inlay hints and semantic highlighting are on by default. Turn them off for a project in `postgres-language-server.jsonc`: + +```jsonc +{ + "inlayHints": { "enabled": false }, + "semanticTokens": { "enabled": false } +} +``` + +Without semantic tokens, your editor keeps the highlighting of its SQL grammar. Most editors can also turn these features off on their side, e.g. `editor.inlayHints.enabled` and `editor.semanticHighlighting.enabled` in VS Code. + +The language server asks the editor to request inlay hints and semantic tokens again when the configuration changes, when the schema cache is invalidated or a statement is executed, and when the database becomes reachable, if the editor supports these refreshes. + +## Limitations + +- Only names that resolve to exactly one object get a hover from the resolver, an inlay hint, or a token. Ambiguous calls, unknown objects, and constructs the resolver doesn't model are skipped. +- After a `DO` block, `CALL`, `EXECUTE`, or a statement that doesn't parse, the rest of the file gets no function hints, function tokens, or enum tokens, because their effects on the schema are unknown. Relations and columns still resolve. +- `CALL proc(...)` gets no hints, tokens, or resolved hover. +- Statements in `BEGIN ATOMIC` function bodies and in `'...'`-quoted bodies that contain `''` are not resolved. `$$`-quoted bodies are. PL/pgSQL bodies are not covered. +- No tokens for roles, for schemas outside qualified names, for DDL (column types in `create table`, function signatures), for CTE names, or for table aliases. Tokens that span several lines are skipped. +- Each request resolves the file up to the visible range. On very long files, like schema dumps with thousands of statements, hints and tokens take longer to appear. diff --git a/docs/index.md b/docs/index.md index a2b92cd43..2f31ae270 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,7 +14,7 @@ The language server is built on Postgres' own parser `libpg_query` to ensure 100 The following features are available today: -- [Autocompletion & Hover](features/editor_features.md) +- [Autocompletion, Hover, Inlay Hints & Semantic Highlighting](features/editor_features.md) - [Syntax Diagnostics](features/syntax_diagnostics.md) - [Type Checking](features/type_checking.md) - [Formatting](features/formatting.md) diff --git a/docs/schema.json b/docs/schema.json index ab91d5bd5..04d524540 100644 --- a/docs/schema.json +++ b/docs/schema.json @@ -55,6 +55,17 @@ } ] }, + "inlayHints": { + "description": "The configuration for inlay hints in editors", + "anyOf": [ + { + "$ref": "#/definitions/InlayHintsConfiguration" + }, + { + "type": "null" + } + ] + }, "linter": { "description": "The configuration for the linter", "anyOf": [ @@ -99,6 +110,17 @@ } ] }, + "semanticTokens": { + "description": "The configuration for semantic highlighting in editors", + "anyOf": [ + { + "$ref": "#/definitions/SemanticTokensConfiguration" + }, + { + "type": "null" + } + ] + }, "splinter": { "description": "The configuration for splinter", "anyOf": [ @@ -684,6 +706,20 @@ } ] }, + "InlayHintsConfiguration": { + "description": "The configuration for inlay hints.", + "type": "object", + "properties": { + "enabled": { + "description": "if `false`, the language server sends no inlay hints. `true` by default", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + }, "KeywordCase": { "description": "Keyword casing style for the formatter.", "oneOf": [ @@ -3047,6 +3083,20 @@ }, "additionalProperties": false }, + "SemanticTokensConfiguration": { + "description": "The configuration for semantic highlighting.", + "type": "object", + "properties": { + "enabled": { + "description": "if `false`, the language server sends no semantic tokens, and editors keep the highlighting of their grammar. `true` by default", + "type": [ + "boolean", + "null" + ] + } + }, + "additionalProperties": false + }, "SplinterConfiguration": { "type": "object", "properties": { diff --git a/mkdocs.yml b/mkdocs.yml index 989266397..0fe0afee9 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -44,7 +44,7 @@ nav: - Type Checking: features/type_checking.md - Formatting: features/formatting.md - PL/pgSQL Support: features/plpgsql.md - - Autocompletion & Hover: features/editor_features.md + - Editor Features: features/editor_features.md - Guides: - Use in your IDE: guides/ide_setup.md - Checking Migrations: guides/checking_migrations.md diff --git a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts index f2d9aaf81..0cb3f89d3 100644 --- a/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgres-language-server/backend-jsonrpc/src/workspace.ts @@ -491,6 +491,10 @@ export interface PartialConfiguration { * The configuration for the SQL formatter */ format?: PartialFormatConfiguration; + /** + * The configuration for inlay hints in editors + */ + inlayHints?: PartialInlayHintsConfiguration; /** * The configuration for the linter */ @@ -507,6 +511,10 @@ export interface PartialConfiguration { * The configuration for type checking */ plpgsqlCheck?: PartialPlPgSqlCheckConfiguration; + /** + * The configuration for semantic highlighting in editors + */ + semanticTokens?: PartialSemanticTokensConfiguration; /** * The configuration for splinter */ @@ -649,6 +657,15 @@ export interface PartialFormatConfiguration { */ valuesRowLayout?: Layout; } +/** + * The configuration for inlay hints. + */ +export interface PartialInlayHintsConfiguration { + /** + * if `false`, the language server sends no inlay hints. `true` by default + */ + enabled?: boolean; +} export interface PartialLinterConfiguration { /** * if `false`, it disables the feature and the linter won't be executed. `true` by default @@ -755,6 +772,15 @@ export interface PartialPlPgSqlCheckConfiguration { */ withoutWarnings?: boolean; } +/** + * The configuration for semantic highlighting. + */ +export interface PartialSemanticTokensConfiguration { + /** + * if `false`, the language server sends no semantic tokens, and editors keep the highlighting of their grammar. `true` by default + */ + enabled?: boolean; +} export interface PartialSplinterConfiguration { /** * if `false`, it disables the feature and the linter won't be executed. `true` by default diff --git a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts index f2d9aaf81..0cb3f89d3 100644 --- a/packages/@postgrestools/backend-jsonrpc/src/workspace.ts +++ b/packages/@postgrestools/backend-jsonrpc/src/workspace.ts @@ -491,6 +491,10 @@ export interface PartialConfiguration { * The configuration for the SQL formatter */ format?: PartialFormatConfiguration; + /** + * The configuration for inlay hints in editors + */ + inlayHints?: PartialInlayHintsConfiguration; /** * The configuration for the linter */ @@ -507,6 +511,10 @@ export interface PartialConfiguration { * The configuration for type checking */ plpgsqlCheck?: PartialPlPgSqlCheckConfiguration; + /** + * The configuration for semantic highlighting in editors + */ + semanticTokens?: PartialSemanticTokensConfiguration; /** * The configuration for splinter */ @@ -649,6 +657,15 @@ export interface PartialFormatConfiguration { */ valuesRowLayout?: Layout; } +/** + * The configuration for inlay hints. + */ +export interface PartialInlayHintsConfiguration { + /** + * if `false`, the language server sends no inlay hints. `true` by default + */ + enabled?: boolean; +} export interface PartialLinterConfiguration { /** * if `false`, it disables the feature and the linter won't be executed. `true` by default @@ -755,6 +772,15 @@ export interface PartialPlPgSqlCheckConfiguration { */ withoutWarnings?: boolean; } +/** + * The configuration for semantic highlighting. + */ +export interface PartialSemanticTokensConfiguration { + /** + * if `false`, the language server sends no semantic tokens, and editors keep the highlighting of their grammar. `true` by default + */ + enabled?: boolean; +} export interface PartialSplinterConfiguration { /** * if `false`, it disables the feature and the linter won't be executed. `true` by default