From e88a167e24fd984c2187d2aef201bb9cc947e2ce Mon Sep 17 00:00:00 2001 From: Steve Dignam Date: Fri, 13 Feb 2026 22:30:13 -0500 Subject: [PATCH] ide: goto def for builtins --- CHANGELOG.md | 61 +++++++- Cargo.lock | 28 +++- Cargo.toml | 1 + crates/squawk_ide/src/builtins.rs | 14 ++ crates/squawk_ide/src/goto_definition.rs | 178 +++++++++++++++++------ crates/squawk_ide/src/inlay_hints.rs | 107 ++++++++++---- crates/squawk_ide/src/lib.rs | 1 + crates/squawk_ide/src/resolve.rs | 17 --- crates/squawk_server/Cargo.toml | 1 + crates/squawk_server/src/lib.rs | 70 +++++++-- crates/squawk_syntax/src/test.rs | 63 ++++++++ crates/squawk_wasm/src/lib.rs | 19 ++- playground/src/App.tsx | 146 +++++++++++++++++-- playground/src/builtins.sql | 1 + playground/src/providers.tsx | 25 ++-- playground/src/squawk.tsx | 1 + playground/src/vite-env.d.ts | 5 + 17 files changed, 600 insertions(+), 138 deletions(-) create mode 100644 crates/squawk_ide/src/builtins.rs create mode 120000 playground/src/builtins.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 5780818b..0d686aad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## v2.40.1 - 2026-02-12 +### Fixed + +- github: fix commenting via rust_crypto features in jsonwebtoken (#929). Thanks @lokiwins! + +### Added + - parser: parse leading from clauses but warn they're not supported (#927) + + ```sql + from t select c; + ``` + + now gives: + + ``` + error[syntax-error]: Leading from clauses are not supported in Postgres + ╭▸ stdin:1:1 + │ + 1 │ from t select c; + ╰╴━━━━━━ + ``` + + We also check for solo from clauses: + + ```sql + from t; + ``` + + gives: + + ``` + error[syntax-error]: Missing select clause + ╭▸ stdin:1:1 + │ + 1 │ from t + ╰╴━ + ``` + - parser: fix parsing any/all/some in exprs (#926) + + ```sql + select * from t order by all + ``` + + now properly errors: + + ```sql + error[syntax-error]: expected expression in atom_expr + ╭▸ stdin:1:26 + │ + 1 │ select * from t order by all + ╰╴ ━ + ``` + + Before it parsed `all` as a name reference. + - ide: goto def func call in on conflict (#925) -- ide: include language in sql builtins (#924) -- build(deps): bump the npm_and_yarn group across 4 directories with 8 updates (#923) -- build(deps): bump @isaacs/brace-expansion from 5.0.0 to 5.0.1 in /squawk-vscode in the npm_and_yarn group across 1 directory (#921) -- build(deps): bump lodash from 4.17.15 to 4.17.23 in the npm_and_yarn group across 1 directory (#919) -- build(deps): bump webpack from 5.101.3 to 5.105.0 in /docs in the npm_and_yarn group across 1 directory (#922) -- build(deps): bump diff from 5.2.0 to 5.2.2 in /squawk-vscode in the npm_and_yarn group across 1 directory (#920) -- build(deps): bump tar from 7.4.3 to 7.5.7 in /playground in the npm_and_yarn group across 1 directory (#918) ## v2.40.0 - 2026-02-06 diff --git a/Cargo.lock b/Cargo.lock index e96ea843..fa9de4c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -680,6 +680,16 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2449,6 +2459,7 @@ name = "squawk-server" version = "2.40.1" dependencies = [ "anyhow", + "etcetera", "insta", "line-index", "log", @@ -3089,6 +3100,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.48.0" @@ -3125,6 +3142,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -3162,7 +3188,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", diff --git a/Cargo.toml b/Cargo.toml index b42e5262..1b956f47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ proc-macro2 = "1.0.95" snapbox = { version = "0.6.0", features = ["diff", "term-svg", "cmd"] } smallvec = "1.13.2" tabled = "0.17.0" +etcetera = "0.11.0" # local # we have to make the versions explicit otherwise `cargo publish` won't work diff --git a/crates/squawk_ide/src/builtins.rs b/crates/squawk_ide/src/builtins.rs new file mode 100644 index 00000000..df52c53a --- /dev/null +++ b/crates/squawk_ide/src/builtins.rs @@ -0,0 +1,14 @@ +pub const BUILTINS_SQL: &str = include_str!("builtins.sql"); + +#[cfg(test)] +mod test { + use squawk_syntax::ast; + + use crate::builtins::BUILTINS_SQL; + + #[test] + fn no_errors() { + let parse = ast::SourceFile::parse(BUILTINS_SQL); + assert_eq!(parse.errors(), vec![]); + } +} diff --git a/crates/squawk_ide/src/goto_definition.rs b/crates/squawk_ide/src/goto_definition.rs index 9a9ca0b7..4b92eb87 100644 --- a/crates/squawk_ide/src/goto_definition.rs +++ b/crates/squawk_ide/src/goto_definition.rs @@ -1,6 +1,6 @@ -use crate::binder; use crate::offsets::token_from_offset; use crate::resolve; +use crate::{binder, builtins::BUILTINS_SQL}; use rowan::{TextRange, TextSize}; use smallvec::{SmallVec, smallvec}; use squawk_syntax::{ @@ -8,8 +8,8 @@ use squawk_syntax::{ ast::{self, AstNode}, }; -pub fn goto_definition(file: ast::SourceFile, offset: TextSize) -> SmallVec<[TextRange; 1]> { - let Some(token) = token_from_offset(&file, offset) else { +pub fn goto_definition(file: &ast::SourceFile, offset: TextSize) -> SmallVec<[Location; 1]> { + let Some(token) = token_from_offset(file, offset) else { return smallvec![]; }; let Some(parent) = token.parent() else { @@ -25,45 +25,55 @@ pub fn goto_definition(file: ast::SourceFile, offset: TextSize) -> SmallVec<[Tex if let Some(case_expr) = ast::CaseExpr::cast(parent) && let Some(case_token) = case_expr.case_token() { - return smallvec![case_token.text_range()]; + return smallvec![Location::range(case_token.text_range())]; } } } // goto def on COMMIT -> BEGIN/START TRANSACTION - if ast::Commit::can_cast(parent.kind()) { - if let Some(begin_range) = find_preceding_begin(&file, token.text_range().start()) { - return smallvec![begin_range]; - } + if ast::Commit::can_cast(parent.kind()) + && let Some(begin_range) = find_preceding_begin(file, token.text_range().start()) + { + return smallvec![Location::range(begin_range)]; } // goto def on ROLLBACK -> BEGIN/START TRANSACTION - if ast::Rollback::can_cast(parent.kind()) { - if let Some(begin_range) = find_preceding_begin(&file, token.text_range().start()) { - return smallvec![begin_range]; - } + if ast::Rollback::can_cast(parent.kind()) + && let Some(begin_range) = find_preceding_begin(file, token.text_range().start()) + { + return smallvec![Location::range(begin_range)]; } // goto def on BEGIN/START TRANSACTION -> COMMIT or ROLLBACK - if ast::Begin::can_cast(parent.kind()) { - if let Some(end_range) = find_following_commit_or_rollback(&file, token.text_range().end()) - { - return smallvec![end_range]; - } + if ast::Begin::can_cast(parent.kind()) + && let Some(end_range) = find_following_commit_or_rollback(file, token.text_range().end()) + { + return smallvec![Location::range(end_range)]; } if let Some(name) = ast::Name::cast(parent.clone()) { - return smallvec![name.syntax().text_range()]; + return smallvec![Location::range(name.syntax().text_range())]; } if let Some(name_ref) = ast::NameRef::cast(parent.clone()) { - let binder_output = binder::bind(&file); - let root = file.syntax(); - if let Some(ptrs) = resolve::resolve_name_ref_ptrs(&binder_output, root, &name_ref) { - return ptrs - .iter() - .map(|ptr| ptr.to_node(file.syntax()).text_range()) - .collect(); + for file_id in [FileId::Current, FileId::Builtins] { + let file = match file_id { + FileId::Current => file, + FileId::Builtins => &ast::SourceFile::parse(BUILTINS_SQL).tree(), + }; + let binder_output = binder::bind(file); + let root = file.syntax(); + if let Some(ptrs) = resolve::resolve_name_ref_ptrs(&binder_output, root, &name_ref) { + let ranges = ptrs + .iter() + .map(|ptr| ptr.to_node(file.syntax()).text_range()) + .map(|range| Location { + file: file_id, + range, + }) + .collect(); + return ranges; + } } } @@ -76,16 +86,46 @@ pub fn goto_definition(file: ast::SourceFile, offset: TextSize) -> SmallVec<[Tex } }); if let Some(ty) = type_node { - let binder_output = binder::bind(&file); - let position = token.text_range().start(); - if let Some(ptr) = resolve::resolve_type_ptr_from_type(&binder_output, &ty, position) { - return smallvec![ptr.to_node(file.syntax()).text_range()]; + for file_id in [FileId::Current, FileId::Builtins] { + let file = match file_id { + FileId::Current => file, + FileId::Builtins => &ast::SourceFile::parse(BUILTINS_SQL).tree(), + }; + let binder_output = binder::bind(file); + let position = token.text_range().start(); + if let Some(ptr) = resolve::resolve_type_ptr_from_type(&binder_output, &ty, position) { + return smallvec![Location { + file: file_id, + range: ptr.to_node(file.syntax()).text_range(), + }]; + } } } smallvec![] } +#[derive(Debug, PartialEq, Clone, Copy, Eq)] +pub enum FileId { + Current, + Builtins, +} + +#[derive(Debug)] +pub struct Location { + pub file: FileId, + pub range: TextRange, +} + +impl Location { + fn range(range: TextRange) -> Location { + Location { + file: FileId::Current, + range, + } + } +} + fn find_preceding_begin(file: &ast::SourceFile, before: TextSize) -> Option { let mut last_begin: Option = None; for stmt in file.stmts() { @@ -115,11 +155,14 @@ fn find_following_commit_or_rollback(file: &ast::SourceFile, after: TextSize) -> #[cfg(test)] mod test { - use crate::goto_definition::goto_definition; + use crate::builtins::BUILTINS_SQL; + use crate::goto_definition::{FileId, goto_definition}; use crate::test_utils::fixture; use annotate_snippets::{AnnotationKind, Level, Renderer, Snippet, renderer::DecorStyle}; use insta::assert_snapshot; use log::info; + use rowan::TextRange; + use squawk_syntax::ast; #[track_caller] @@ -137,30 +180,50 @@ mod test { let parse = ast::SourceFile::parse(&sql); assert_eq!(parse.errors(), vec![]); let file: ast::SourceFile = parse.tree(); - let results = goto_definition(file, offset); + let results = goto_definition(&file, offset); if !results.is_empty() { let offset: usize = offset.into(); - let mut snippet = Snippet::source(&sql).fold(true); - - for (i, result) in results.iter().enumerate() { - snippet = snippet.annotation( - AnnotationKind::Context - .span((*result).into()) - .label(format!("{}. destination", i + 2)), - ); + let mut current_dests = vec![]; + let mut builtin_dests = vec![]; + for (i, location) in results.iter().enumerate() { + let label_index = i + 2; + match location.file { + FileId::Current => current_dests.push((label_index, location.range)), + FileId::Builtins => builtin_dests.push((label_index, location.range)), + } } + let has_builtins = !builtin_dests.is_empty(); + + let mut snippet = Snippet::source(&sql).fold(true); + if has_builtins { + // only show the current file when we have two file types, aka current and builtins + snippet = snippet.path("current.sql"); + } else { + snippet = annotate_destinations(snippet, current_dests); + } snippet = snippet.annotation( AnnotationKind::Context .span(offset..offset + 1) .label("1. source"), ); - let group = Level::INFO.primary_title("definition").element(snippet); + let mut groups = vec![Level::INFO.primary_title("definition").element(snippet)]; + + if has_builtins { + let builtins_snippet = Snippet::source(BUILTINS_SQL).path("builtin.sql").fold(true); + let builtins_snippet = annotate_destinations(builtins_snippet, builtin_dests); + groups.push( + Level::INFO + .primary_title("definition") + .element(builtins_snippet), + ); + } + let renderer = Renderer::plain().decor_style(DecorStyle::Unicode); return Some( renderer - .render(&[group]) + .render(&groups) .to_string() // hacky cleanup to make the text shorter .replace("info: definition", ""), @@ -173,6 +236,21 @@ mod test { assert!(goto_(sql).is_none(), "Should not find a definition"); } + fn annotate_destinations<'a>( + mut snippet: Snippet<'a, annotate_snippets::Annotation<'a>>, + destinations: Vec<(usize, TextRange)>, + ) -> Snippet<'a, annotate_snippets::Annotation<'a>> { + for (label_index, range) in destinations { + snippet = snippet.annotation( + AnnotationKind::Context + .span(range.into()) + .label(format!("{}. destination", label_index)), + ); + } + + snippet + } + #[test] fn goto_case_when() { assert_snapshot!(goto(" @@ -666,6 +744,24 @@ alter policy p on t "); } + #[test] + fn goto_builtin_now() { + assert_snapshot!(goto(" +select now$0(); +"), @r" + ╭▸ current.sql:2:10 + │ + 2 │ select now(); + │ ─ 1. source + ╰╴ + + ╭▸ builtin.sql:10798:28 + │ + 10798 │ create function pg_catalog.now() returns timestamp with time zone + ╰╴ ─── 2. destination + "); + } + #[test] fn goto_create_policy_schema_qualified_table() { assert_snapshot!(goto(" diff --git a/crates/squawk_ide/src/inlay_hints.rs b/crates/squawk_ide/src/inlay_hints.rs index 50bc69b1..e482840b 100644 --- a/crates/squawk_ide/src/inlay_hints.rs +++ b/crates/squawk_ide/src/inlay_hints.rs @@ -1,12 +1,10 @@ -use crate::binder; -use crate::binder::Binder; +use crate::builtins::BUILTINS_SQL; +use crate::goto_definition::FileId; use crate::resolve; use crate::symbols::Name; +use crate::{binder, goto_definition}; use rowan::{TextRange, TextSize}; -use squawk_syntax::{ - SyntaxNode, - ast::{self, AstNode}, -}; +use squawk_syntax::ast::{self, AstNode}; /// `VSCode` has some theming options based on these types. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -20,29 +18,29 @@ pub struct InlayHint { pub position: TextSize, pub label: String, pub kind: InlayHintKind, + // Need this to be an Option because we can still inlay hints when we don't + // have the destination. + // For example: `insert into t(a, b) values (1, 2)` pub target: Option, + // TODO: combine with the target range above + pub file: Option, } pub fn inlay_hints(file: &ast::SourceFile) -> Vec { let mut hints = vec![]; - let binder = binder::bind(file); - let root = file.syntax(); - - for node in root.descendants() { + for node in file.syntax().descendants() { if let Some(call_expr) = ast::CallExpr::cast(node.clone()) { - inlay_hint_call_expr(&mut hints, root, &binder, call_expr); + inlay_hint_call_expr(&mut hints, file, call_expr); } else if let Some(insert) = ast::Insert::cast(node) { - inlay_hint_insert(&mut hints, root, &binder, insert); + inlay_hint_insert(&mut hints, file, insert); } } - hints } fn inlay_hint_call_expr( hints: &mut Vec, - root: &SyntaxNode, - binder: &Binder, + file: &ast::SourceFile, call_expr: ast::CallExpr, ) -> Option<()> { let arg_list = call_expr.arg_list()?; @@ -54,11 +52,16 @@ fn inlay_hint_call_expr( ast::FieldExpr::cast(expr.syntax().clone())?.field()? }; - let function_ptr = resolve::resolve_name_ref_ptrs(binder, root, &name_ref)? + let location = goto_definition::goto_definition(file, name_ref.syntax().text_range().start()) .into_iter() .next()?; - let function_name_node = function_ptr.to_node(root); + let file = match location.file { + goto_definition::FileId::Current => file, + goto_definition::FileId::Builtins => &ast::SourceFile::parse(BUILTINS_SQL).tree(), + }; + + let function_name_node = file.syntax().covering_element(location.range); if let Some(create_function) = function_name_node .ancestors() @@ -74,6 +77,7 @@ fn inlay_hint_call_expr( label: format!("{}: ", param_name.syntax().text()), kind: InlayHintKind::Parameter, target, + file: Some(location.file), }); } } @@ -84,11 +88,39 @@ fn inlay_hint_call_expr( fn inlay_hint_insert( hints: &mut Vec, - root: &SyntaxNode, - binder: &Binder, + file: &ast::SourceFile, insert: ast::Insert, ) -> Option<()> { - let create_table = resolve::resolve_insert_create_table(root, binder, &insert); + let name_start = insert + .path()? + .segment()? + .name_ref()? + .syntax() + .text_range() + .start(); + // We need to support the table definition not being found since we can + // still provide inlay hints when a column list is provided + let location = goto_definition::goto_definition(file, name_start) + .into_iter() + .next(); + + let file = match location.as_ref().map(|x| x.file) { + Some(goto_definition::FileId::Current) | None => file, + Some(goto_definition::FileId::Builtins) => &ast::SourceFile::parse(BUILTINS_SQL).tree(), + }; + + let create_table = { + let range = location.as_ref().map(|x| x.range); + + range.and_then(|range| { + file.syntax() + .covering_element(range) + .ancestors() + .find_map(ast::CreateTableLike::cast) + }) + }; + + let binder = binder::bind(file); let columns = if let Some(column_list) = insert.column_list() { // `insert into t(a, b, c) values (1, 2, 3)` @@ -98,12 +130,15 @@ fn inlay_hint_insert( let col_name = resolve::extract_column_name(&col)?; let target = create_table .as_ref() - .and_then(|x| resolve::find_column_in_create_table(binder, root, x, &col_name)) + .and_then(|x| { + resolve::find_column_in_create_table(&binder, file.syntax(), x, &col_name) + }) .map(|x| x.text_range()); - Some((col_name, target)) + Some((col_name, target, location.as_ref().map(|x| x.file))) }) .collect() } else { + // TODO: this doesn't work when there's `inherit`/`like` involved in the table def // `insert into t values (1, 2, 3)` create_table? .table_arg_list()? @@ -114,7 +149,7 @@ fn inlay_hint_insert( { let col_name = Name::from_node(&name); let target = Some(name.syntax().text_range()); - Some((col_name, target)) + Some((col_name, target, location.as_ref().map(|x| x.file))) } else { None } @@ -123,18 +158,19 @@ fn inlay_hint_insert( }; let Some(values) = insert.values() else { + // `insert into t select 1, 2;` return inlay_hint_insert_select(hints, columns, insert.stmt()?); }; - let row_list = values.row_list()?; - - for row in row_list.rows() { - for ((column_name, target), expr) in columns.iter().zip(row.exprs()) { + // `insert into t values (1, 2);` + for row in values.row_list()?.rows() { + for ((column_name, target, file_id), expr) in columns.iter().zip(row.exprs()) { let expr_start = expr.syntax().text_range().start(); hints.push(InlayHint { position: expr_start, label: format!("{}: ", column_name), kind: InlayHintKind::Parameter, target: *target, + file: *file_id, }); } } @@ -144,7 +180,7 @@ fn inlay_hint_insert( fn inlay_hint_insert_select( hints: &mut Vec, - columns: Vec<(Name, Option)>, + columns: Vec<(Name, Option, Option)>, stmt: ast::Stmt, ) -> Option<()> { let target_list = match stmt { @@ -156,7 +192,7 @@ fn inlay_hint_insert_select( _ => None, }?; - for ((column_name, target), target_expr) in columns.iter().zip(target_list.targets()) { + for ((column_name, target, file_id), target_expr) in columns.iter().zip(target_list.targets()) { let expr = target_expr.expr()?; let expr_start = expr.syntax().text_range().start(); hints.push(InlayHint { @@ -164,6 +200,7 @@ fn inlay_hint_insert_select( label: format!("{}: ", column_name), kind: InlayHintKind::Parameter, target: *target, + file: *file_id, }); } @@ -335,6 +372,18 @@ select foo(1, 2); "); } + #[test] + fn builtin_function() { + assert_snapshot!(check_inlay_hints(" +select json_strip_nulls('[1, null]', true); +"), @r" + inlay hints: + ╭▸ + 2 │ select json_strip_nulls(target: '[1, null]', strip_in_arrays: true); + ╰╴ ──────── ───────────────── + "); + } + #[test] fn insert_with_column_list() { assert_snapshot!(check_inlay_hints(" diff --git a/crates/squawk_ide/src/lib.rs b/crates/squawk_ide/src/lib.rs index 22103535..9c2a1df8 100644 --- a/crates/squawk_ide/src/lib.rs +++ b/crates/squawk_ide/src/lib.rs @@ -1,4 +1,5 @@ mod binder; +pub mod builtins; mod classify; pub mod code_actions; pub mod column_name; diff --git a/crates/squawk_ide/src/resolve.rs b/crates/squawk_ide/src/resolve.rs index e10a14e6..c8b82386 100644 --- a/crates/squawk_ide/src/resolve.rs +++ b/crates/squawk_ide/src/resolve.rs @@ -2880,23 +2880,6 @@ fn resolve_column_from_paren_expr( None } -pub(crate) fn resolve_insert_create_table( - root: &SyntaxNode, - binder: &Binder, - insert: &ast::Insert, -) -> Option { - let path = insert.path()?; - let (table_name, schema) = extract_table_schema_from_path(&path)?; - let position = insert.syntax().text_range().start(); - - let table_name_ptr = resolve_table_name_ptr(binder, &table_name, &schema, position)?; - let table_name_node = table_name_ptr.to_node(root); - - table_name_node - .ancestors() - .find_map(ast::CreateTableLike::cast) -} - pub(crate) fn resolve_table_info(binder: &Binder, path: &ast::Path) -> Option<(Schema, String)> { resolve_symbol_info(binder, path, SymbolKind::Table) } diff --git a/crates/squawk_server/Cargo.toml b/crates/squawk_server/Cargo.toml index 4fbd5bf6..40890291 100644 --- a/crates/squawk_server/Cargo.toml +++ b/crates/squawk_server/Cargo.toml @@ -28,6 +28,7 @@ squawk-linter.workspace = true squawk-syntax.workspace = true line-index.workspace = true insta.workspace = true +etcetera.workspace = true [lints] workspace = true diff --git a/crates/squawk_server/src/lib.rs b/crates/squawk_server/src/lib.rs index 8fc483f1..048760ad 100644 --- a/crates/squawk_server/src/lib.rs +++ b/crates/squawk_server/src/lib.rs @@ -1,4 +1,5 @@ use anyhow::{Context, Result}; +use etcetera::BaseStrategy; use line_index::LineIndex; use log::info; use lsp_server::{Connection, Message, Notification, Response}; @@ -23,15 +24,15 @@ use lsp_types::{ }, }; use rowan::TextRange; -use squawk_ide::code_actions::code_actions; use squawk_ide::completion::completion; use squawk_ide::document_symbols::{DocumentSymbolKind, document_symbols}; use squawk_ide::find_references::find_references; use squawk_ide::goto_definition::goto_definition; use squawk_ide::hover::hover; use squawk_ide::inlay_hints::inlay_hints; +use squawk_ide::{builtins::BUILTINS_SQL, code_actions::code_actions}; use squawk_syntax::SourceFile; -use std::collections::HashMap; +use std::{collections::HashMap, fs, sync::OnceLock}; use diagnostic::DIAGNOSTIC_NAME; @@ -41,6 +42,22 @@ mod ignore; mod lint; mod lsp_utils; +fn builtins_url() -> Option { + // TODO: once we get salsa setup, we can migrate this over + static BUILTINS_URL: OnceLock> = OnceLock::new(); + BUILTINS_URL + .get_or_init(|| { + let strategy = etcetera::base_strategy::choose_base_strategy().ok()?; + let config_dir = strategy.config_dir(); + let cache_dir = config_dir.join("squawk/stubs"); + let path = cache_dir.join("builtins.sql"); + fs::create_dir_all(cache_dir).ok()?; + fs::write(&path, BUILTINS_SQL).ok()?; + Url::from_file_path(&path).ok() + }) + .clone() +} + struct DocumentState { content: String, version: i32, @@ -192,17 +209,29 @@ fn handle_goto_definition( let line_index = LineIndex::new(content); let offset = lsp_utils::offset(&line_index, position).unwrap(); - let ranges = goto_definition(file, offset) + let ranges = goto_definition(&file, offset) .into_iter() - .map(|target_range| { + .filter_map(|location| { debug_assert!( - !target_range.contains(offset), + !location.range.contains(offset), "Our target destination range must not include the source range otherwise go to def won't work in vscode." ); - Location { - uri: uri.clone(), - range: lsp_utils::range(&line_index, target_range), - } + + let uri = match location.file { + squawk_ide::goto_definition::FileId::Current => uri.clone(), + squawk_ide::goto_definition::FileId::Builtins => builtins_url()?, + }; + + let line_index = match location.file { + squawk_ide::goto_definition::FileId::Current => &line_index, + squawk_ide::goto_definition::FileId::Builtins => &LineIndex::new(BUILTINS_SQL), + }; + let range = lsp_utils::range(line_index, location.range); + + Some(Location { + uri, + range, + }) }) .collect(); @@ -269,10 +298,23 @@ fn handle_inlay_hints( let lsp_hints: Vec = hints .into_iter() - .map(|hint| { + .flat_map(|hint| { let line_col = line_index.line_col(hint.position); let position = lsp_types::Position::new(line_col.line, line_col.col); - let kind = match hint.kind { + + let uri = match hint.file { + Some(squawk_ide::goto_definition::FileId::Current) | None => uri.clone(), + Some(squawk_ide::goto_definition::FileId::Builtins) => builtins_url()?, + }; + + let line_index = match hint.file { + Some(squawk_ide::goto_definition::FileId::Current) | None => &line_index, + Some(squawk_ide::goto_definition::FileId::Builtins) => { + &LineIndex::new(BUILTINS_SQL) + } + }; + + let kind: InlayHintKind = match hint.kind { squawk_ide::inlay_hints::InlayHintKind::Type => InlayHintKind::TYPE, squawk_ide::inlay_hints::InlayHintKind::Parameter => InlayHintKind::PARAMETER, }; @@ -282,7 +324,7 @@ fn handle_inlay_hints( value: hint.label, location: Some(Location { uri: uri.clone(), - range: lsp_utils::range(&line_index, target_range), + range: lsp_utils::range(line_index, target_range), }), tooltip: None, command: None, @@ -291,7 +333,7 @@ fn handle_inlay_hints( InlayHintLabel::String(hint.label) }; - InlayHint { + Some(InlayHint { position, label, kind: Some(kind), @@ -300,7 +342,7 @@ fn handle_inlay_hints( padding_left: None, padding_right: None, data: None, - } + }) }) .collect(); diff --git a/crates/squawk_syntax/src/test.rs b/crates/squawk_syntax/src/test.rs index 16432607..aaded5b4 100644 --- a/crates/squawk_syntax/src/test.rs +++ b/crates/squawk_syntax/src/test.rs @@ -34,6 +34,69 @@ fn render_errors(sql: &str, errors: &[SyntaxError]) -> String { rendered } +#[dir_test( + dir: "$CARGO_MANIFEST_DIR/../squawk_parser/tests/data/ok", + glob: "*.sql", +)] +fn parser_ok_validation(fixture: Fixture<&str>) { + let content = fixture.content(); + let absolute_fixture_path = Utf8Path::new(fixture.path()); + let test_name = absolute_fixture_path + .file_name() + .and_then(|x| x.strip_suffix(".sql")) + .unwrap(); + + let parse = SourceFile::parse(content); + let errors = parse.errors(); + + assert!( + errors.is_empty(), + "parser ok test `{test_name}` has syntax validation errors:\n{}", + render_errors(content, &errors) + ); +} + +#[dir_test( + dir: "$CARGO_MANIFEST_DIR/../../postgres/regression_suite", + glob: "*.sql", +)] +fn regression_suite_validation(fixture: Fixture<&str>) { + let content = fixture.content(); + let absolute_fixture_path = Utf8Path::new(fixture.path()); + let test_name = absolute_fixture_path + .file_name() + .and_then(|x| x.strip_suffix(".sql")) + .unwrap(); + + if absolute_fixture_path.to_string().contains("psql") { + return; + } + + let parse = SourceFile::parse(content); + let errors = parse.errors(); + + let mut errors = errors; + + if test_name == "errors" { + assert!( + !errors.is_empty(), + "the errors.sql regression test must have validation errors" + ); + return; + } + + // strings.sql intentionally has a comment between string continuation literals + if test_name == "strings" { + errors.retain(|e| e.message() != "Comments between string literals are not allowed."); + } + + assert!( + errors.is_empty(), + "regression test `{test_name}` has syntax validation errors:\n{}", + render_errors(content, &errors) + ); +} + #[dir_test( dir: "$CARGO_MANIFEST_DIR/test_data", glob: "**/*.sql", diff --git a/crates/squawk_wasm/src/lib.rs b/crates/squawk_wasm/src/lib.rs index a1b7d241..7b2d4b2d 100644 --- a/crates/squawk_wasm/src/lib.rs +++ b/crates/squawk_wasm/src/lib.rs @@ -2,6 +2,8 @@ use line_index::LineIndex; use log::info; use rowan::TextRange; use serde::{Deserialize, Serialize}; +use squawk_ide::builtins::BUILTINS_SQL; +use squawk_ide::goto_definition::FileId; use squawk_syntax::ast::AstNode; use wasm_bindgen::prelude::*; use web_sys::js_sys::Error; @@ -191,13 +193,19 @@ fn into_error(err: E) -> Error { #[wasm_bindgen] pub fn goto_definition(content: String, line: u32, col: u32) -> Result { let parse = squawk_syntax::SourceFile::parse(&content); - let line_index = LineIndex::new(&content); - let offset = position_to_offset(&line_index, line, col)?; - let result = squawk_ide::goto_definition::goto_definition(parse.tree(), offset); + let current_line_index = LineIndex::new(&content); + let builtins_line_index = LineIndex::new(BUILTINS_SQL); + let offset = position_to_offset(¤t_line_index, line, col)?; + let result = squawk_ide::goto_definition::goto_definition(&parse.tree(), offset); let response: Vec = result .into_iter() - .map(|range| { + .map(|location| { + let range = location.range; + let (file, line_index) = match location.file { + FileId::Current => ("current", ¤t_line_index), + FileId::Builtins => ("builtins", &builtins_line_index), + }; let start = line_index.line_col(range.start()); let end = line_index.line_col(range.end()); let start_wide = line_index @@ -208,6 +216,7 @@ pub fn goto_definition(content: String, line: u32, col: u32) -> Result Result initialValue() ?? SETTINGS.value) + const [file, setFile] = useState<"current" | "builtins">("current") + const editorRef = useRef(null) const markers = useMarkers(text) @@ -163,16 +166,36 @@ export function App() { mode != null && "md:grid-cols-2", )} > - { - setState(text) - }} - autoFocus - markers={markers} - settings={{ ...SETTINGS, value: text }} - onSave={handleSave} - /> +
+ {file == "builtins" ? ( + { + // TODO: Might want to use an imperative ref so we can move this into the Editor + editorRef.current?.setModel(getCurrentModel()) + editorRef.current?.updateOptions({ readOnly: false }) + }} + /> + ) : null} + { + setState(text) + }} + autoFocus + markers={markers} + settings={{ ...SETTINGS, value: text }} + onSave={handleSave} + onModelChange={(model) => { + if (model?.path === builtinsUri.path) { + setFile("builtins") + } else if (model?.path === currentUri.path) { + setFile("current") + } + }} + ref={editorRef} + /> +
{mode === "Syntax Tree" ? ( + // TODO: it might be better to have an editor and switch the underlying monaco models ) : mode === "Tokens" ? ( @@ -188,6 +211,20 @@ export function App() { ) } +function BuiltinsBanner({ onBack }: { onBack: () => void }) { + return ( +
+
Viewing postgres stubs (read-only)
+ +
+ ) +} + function TokenPanel({ text }: { text: string }) { const value = useDumpTokens(text) return ( @@ -243,7 +280,7 @@ function assertNever(x: never): never { let monacoGlobalProvidersRegistered = false // Only want to register these once, otherwise we'll end up with multiple // providers running and get dupe results for things like hover -function registerMonacoProviders() { +function registerMonacoProvidersOnce() { if (monacoGlobalProvidersRegistered) { return } @@ -440,6 +477,33 @@ function registerMonacoProviders() { } } +const builtinsUri = monaco.Uri.parse("file:///builtins.sql") +const currentUri = monaco.Uri.parse("file:///current.sql") + +function getBuiltinsModel() { + let builtinsModel = monaco.editor.getModel(builtinsUri) + if (!builtinsModel) { + builtinsModel = monaco.editor.createModel( + BUILTINS_SQL, + "pgsql", + builtinsUri, + ) + } + return builtinsModel +} + +function getCurrentModel(defaultText?: string | undefined) { + let currentModel = monaco.editor.getModel(currentUri) + if (!currentModel) { + currentModel = monaco.editor.createModel( + defaultText ?? "", + "pgsql", + currentUri, + ) + } + return currentModel +} + // TODO: this is hacky let fixesRef: Map = new Map() @@ -450,6 +514,8 @@ function Editor({ value, markers, onSave, + onModelChange, + ref, }: { value?: string autoFocus?: boolean @@ -457,6 +523,8 @@ function Editor({ onSave?: (_: string) => void settings: monaco.editor.IStandaloneEditorConstructionOptions markers?: Marker[] + onModelChange?: (uri: monaco.Uri | null) => void + ref?: React.RefObject }) { const onChangeText = useEffectEvent((text: string) => { onChange?.(text) @@ -464,6 +532,9 @@ function Editor({ const onSaveText = useEffectEvent((text: string) => { onSave?.(text) }) + const onModelChange_ = useEffectEvent((uri: monaco.Uri | null) => { + onModelChange?.(uri) + }) const divRef = useRef(null) const autoFocusRef = useRef(autoFocus) const settingsInitial = useRef(settings) @@ -490,16 +561,58 @@ function Editor({ }, [markers]) useLayoutEffect(() => { - registerMonacoProviders() + registerMonacoProvidersOnce() const editor = monaco.editor.create( divRef.current!, settingsInitial.current, ) + if (ref) { + ref.current = editor + } + if (!editor.getOption(monaco.editor.EditorOption.readOnly)) { + editor.setModel(getCurrentModel(settingsInitial.current?.value)) + } editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => onSaveText(editor.getValue()), ) - editor.onDidBlurEditorText(() => { - onSaveText(editor.getValue()) + + editor.onDidChangeModel((e) => { + onModelChange_(e.newModelUrl) + }) + + const opener = monaco.editor.registerEditorOpener({ + openCodeEditor: ( + editor: monaco.editor.ICodeEditor, + resource: monaco.Uri, + selectionOrPosition?: monaco.IRange | monaco.IPosition, + ): boolean | Promise => { + if (editor.getOption(monaco.editor.EditorOption.readOnly)) { + return false + } + let switched = false + if (resource.path === builtinsUri.path) { + editor.setModel(getBuiltinsModel()) + editor.updateOptions({ readOnly: true }) + switched = true + } else if (resource.path === currentUri.path) { + // we're already in the "current" file + switched = true + } + + if (switched && selectionOrPosition) { + if ("startLineNumber" in selectionOrPosition) { + editor.setSelection(selectionOrPosition) + editor.revealRangeInCenter(selectionOrPosition) + } else { + editor.setPosition(selectionOrPosition) + editor.revealPositionInCenter(selectionOrPosition) + } + editor.focus() + return true + } + + return false + }, }) editor.onDidChangeModelContent(() => { @@ -513,8 +626,9 @@ function Editor({ editorRef.current = null editor?.dispose() + opener.dispose() } - }, []) + }, [ref]) useEffect(() => { if (value != null) { editorRef.current?.setValue(value) diff --git a/playground/src/builtins.sql b/playground/src/builtins.sql new file mode 120000 index 00000000..bcfb4e5b --- /dev/null +++ b/playground/src/builtins.sql @@ -0,0 +1 @@ +../../crates/squawk_ide/src/builtins.sql \ No newline at end of file diff --git a/playground/src/providers.tsx b/playground/src/providers.tsx index 79ed9b68..f846f120 100644 --- a/playground/src/providers.tsx +++ b/playground/src/providers.tsx @@ -154,6 +154,9 @@ export async function provideHover( } } +const builtinsUri = monaco.Uri.parse("file:///builtins.sql") +const currentUri = monaco.Uri.parse("file:///current.sql") + export async function provideDefinition( model: monaco.editor.ITextModel, position: monaco.Position, @@ -170,15 +173,19 @@ export async function provideDefinition( if (!results) return null - return results.map((results) => ({ - uri: model.uri, - range: { - startLineNumber: results.start_line + 1, - startColumn: results.start_column + 1, - endLineNumber: results.end_line + 1, - endColumn: results.end_column + 1, - }, - })) + return results.map((result) => { + const uri = result.file === "current" ? currentUri : builtinsUri + return { + uri, + file: result.file, + range: { + startLineNumber: result.start_line + 1, + startColumn: result.start_column + 1, + endLineNumber: result.end_line + 1, + endColumn: result.end_column + 1, + }, + } + }) } catch (e) { console.error("Error in provideDefinition:", e) return null diff --git a/playground/src/squawk.tsx b/playground/src/squawk.tsx index 44ad0277..d38c6a70 100644 --- a/playground/src/squawk.tsx +++ b/playground/src/squawk.tsx @@ -148,6 +148,7 @@ function useWasmStatus() { } interface LocationRange { + file: "current" | "builtins" start_line: number start_column: number end_line: number diff --git a/playground/src/vite-env.d.ts b/playground/src/vite-env.d.ts index 11f02fe2..1838c17f 100644 --- a/playground/src/vite-env.d.ts +++ b/playground/src/vite-env.d.ts @@ -1 +1,6 @@ /// + +declare module "*?raw" { + const content: string + export default content +}