diff --git a/Cargo.lock b/Cargo.lock index 1a82d000..c5544b4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -966,6 +966,12 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "la-arena" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3752f229dcc5a481d60f385fa479ff46818033d881d2d801aa27dffcfb5e8306" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1875,9 +1881,11 @@ version = "2.32.0" dependencies = [ "annotate-snippets", "insta", + "la-arena", "line-index", "log", "rowan", + "smol_str", "squawk-linter", "squawk-syntax", ] diff --git a/Cargo.toml b/Cargo.toml index 367b7a69..134897aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ jsonwebtoken = "9.3.1" lazy_static = "1.5.0" log = "0.4.25" reqwest = { version = "0.11.27", features = ["native-tls-vendored", "blocking", "json"] } +la-arena = "0.3.1" serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0" serde_plain = "1.0" diff --git a/PLAN.md b/PLAN.md index 03b21720..1b63411e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -581,6 +581,26 @@ FROM ( WHERE total_amount > 1000; ``` +### Rule: aggregate free having condition + +```sql +select a from t group by a having a > 10; +-- ^^^^^^ + +-- quick fix to: +select a from t where a > 10 group by a; +``` + +### Rule: order direction is redundent + +```sql +select * from t order by a asc; +-- ^^^ order direction is redundent. asc is the default. + +-- quick fix to: +select * from t order by a; +``` + ### Rule: sum(boolean) to case stmt ```sql @@ -1076,6 +1096,9 @@ also show lex command ### Snippets - [datagrip live templates](https://blog.jetbrains.com/datagrip/2019/03/11/top-9-sql-features-of-datagrip-you-have-to-know/#live_templates) + - insert + - select + - create table - [postgresql-snippets](https://github.com/Manuel7806/postgresql-snippets/blob/main/snippets/snippets.code-snippets) ### Quick Fix: alias query @@ -1083,11 +1106,9 @@ also show lex command ```sql select * from bar -- ^$ action:rename-alias -``` -becomes after filling in alias name with `b` +-- becomes after filling in alias name with `b` -```sql select b.* from bar b ``` @@ -1096,11 +1117,9 @@ another example: ```sql select name, email from bar -- ^$ action:rename-alias -``` -becomes after filling in alias name with `b` +-- becomes after filling in alias name with `b` -```sql select b.name, b.email from bar ``` diff --git a/crates/squawk_ide/Cargo.toml b/crates/squawk_ide/Cargo.toml index 00afa7f1..52bc7950 100644 --- a/crates/squawk_ide/Cargo.toml +++ b/crates/squawk_ide/Cargo.toml @@ -18,6 +18,8 @@ rowan.workspace = true line-index.workspace = true annotate-snippets.workspace = true log.workspace = true +smol_str.workspace = true +la-arena.workspace = true [dev-dependencies] insta.workspace = true diff --git a/crates/squawk_ide/src/binder.rs b/crates/squawk_ide/src/binder.rs new file mode 100644 index 00000000..8c0e8e96 --- /dev/null +++ b/crates/squawk_ide/src/binder.rs @@ -0,0 +1,115 @@ +/// Loosely based on TypeScript's binder +/// see: typescript-go/internal/binder/binder.go +use la_arena::Arena; +use squawk_syntax::{SyntaxNodePtr, ast, ast::AstNode}; + +use crate::scope::{Scope, ScopeId}; +use crate::symbols::{Name, Schema, Symbol, SymbolKind}; + +pub(crate) struct Binder { + pub(crate) scopes: Arena, + pub(crate) symbols: Arena, +} + +impl Binder { + fn new() -> Self { + let mut scopes = Arena::new(); + let _root_scope = scopes.alloc(Scope::with_parent(None)); + Binder { + scopes, + symbols: Arena::new(), + } + } + + pub(crate) fn root_scope(&self) -> ScopeId { + self.scopes + .iter() + .next() + .map(|(id, _)| id) + .expect("root scope must exist") + } +} + +pub(crate) fn bind(file: &ast::SourceFile) -> Binder { + let mut binder = Binder::new(); + + bind_file(&mut binder, file); + + binder +} + +fn bind_file(b: &mut Binder, file: &ast::SourceFile) { + for stmt in file.stmts() { + bind_stmt(b, stmt); + } +} + +fn bind_stmt(b: &mut Binder, stmt: ast::Stmt) { + if let ast::Stmt::CreateTable(create_table) = stmt { + bind_create_table(b, create_table) + } +} + +fn bind_create_table(b: &mut Binder, create_table: ast::CreateTable) { + let Some(path) = create_table.path() else { + return; + }; + let Some(table_name) = item_name(&path) else { + return; + }; + let name_ptr = path_to_ptr(&path); + let schema = schema_name(&path); + + let table_id = b.symbols.alloc(Symbol { + kind: SymbolKind::Table, + ptr: name_ptr, + schema, + }); + + let root = b.root_scope(); + b.scopes[root].insert(table_name, table_id); +} + +fn item_name(path: &ast::Path) -> Option { + let segment = path.segment()?; + + if let Some(name) = segment.name() { + return Some(Name::new(name.syntax().text().to_string())); + } + if let Some(name) = segment.name_ref() { + return Some(Name::new(name.syntax().text().to_string())); + } + + None +} + +fn path_to_ptr(path: &ast::Path) -> SyntaxNodePtr { + if let Some(segment) = path.segment() { + if let Some(name) = segment.name() { + return SyntaxNodePtr::new(name.syntax()); + } + if let Some(name_ref) = segment.name_ref() { + return SyntaxNodePtr::new(name_ref.syntax()); + } + } + SyntaxNodePtr::new(path.syntax()) +} + +fn schema_name(path: &ast::Path) -> Schema { + let Some(qualifier) = path.qualifier() else { + return Schema::Public; + }; + let Some(segment) = qualifier.segment() else { + return Schema::Public; + }; + + let schema_name = if let Some(name) = segment.name() { + Name::new(name.syntax().text().to_string()) + } else if let Some(name_ref) = segment.name_ref() { + Name::new(name_ref.syntax().text().to_string()) + } else { + return Schema::Public; + }; + + Schema::from_name(schema_name) +} diff --git a/crates/squawk_ide/src/goto_definition.rs b/crates/squawk_ide/src/goto_definition.rs index 5a25f42f..d5b2ae00 100644 --- a/crates/squawk_ide/src/goto_definition.rs +++ b/crates/squawk_ide/src/goto_definition.rs @@ -1,4 +1,6 @@ +use crate::binder; use crate::offsets::token_from_offset; +use crate::resolve; use rowan::{TextRange, TextSize}; use squawk_syntax::{ SyntaxKind, @@ -45,6 +47,14 @@ pub fn goto_definition(file: ast::SourceFile, offset: TextSize) -> Option Option { + let context = classify_name_ref_context(name_ref)?; + + match context { + NameRefContext::DropTable => { + let path = find_containing_path(name_ref)?; + let table_name = extract_table_name(&path)?; + let schema = extract_schema_name(&path); + resolve_table(binder, &table_name, &schema) + } + } +} + +fn classify_name_ref_context(name_ref: &ast::NameRef) -> Option { + for ancestor in name_ref.syntax().ancestors() { + if ast::DropTable::can_cast(ancestor.kind()) { + return Some(NameRefContext::DropTable); + } + } + + None +} + +fn resolve_table(binder: &Binder, table_name: &Name, schema: &Schema) -> Option { + let symbol_id = binder.scopes[binder.root_scope()] + .get(table_name)? + .iter() + .copied() + .find(|id| { + let symbol = &binder.symbols[*id]; + symbol.kind == SymbolKind::Table && &symbol.schema == schema + })?; + Some(binder.symbols[symbol_id].ptr) +} + +fn find_containing_path(name_ref: &ast::NameRef) -> Option { + for ancestor in name_ref.syntax().ancestors() { + if let Some(path) = ast::Path::cast(ancestor) { + return Some(path); + } + } + None +} + +fn extract_table_name(path: &ast::Path) -> Option { + let segment = path.segment()?; + let name_ref = segment.name_ref()?; + Some(Name::new(name_ref.syntax().text().to_string())) +} + +fn extract_schema_name(path: &ast::Path) -> Schema { + let Some(qualifier) = path.qualifier() else { + return Schema::Public; + }; + let Some(segment) = qualifier.segment() else { + return Schema::Public; + }; + let Some(name_ref) = segment.name_ref() else { + return Schema::Public; + }; + Schema::from_name(Name::new(name_ref.syntax().text().to_string())) +} diff --git a/crates/squawk_ide/src/scope.rs b/crates/squawk_ide/src/scope.rs new file mode 100644 index 00000000..bb27b469 --- /dev/null +++ b/crates/squawk_ide/src/scope.rs @@ -0,0 +1,30 @@ +use la_arena::Idx; +use std::collections::HashMap; + +use crate::symbols::{Name, SymbolId}; + +pub(crate) type ScopeId = Idx; + +#[derive(Default, Debug)] +pub(crate) struct Scope { + #[allow(dead_code)] + pub(crate) parent: Option, + pub(crate) entries: HashMap>, +} + +impl Scope { + pub(crate) fn with_parent(parent: Option) -> Self { + Scope { + parent, + entries: HashMap::new(), + } + } + + pub(crate) fn insert(&mut self, name: Name, id: SymbolId) { + self.entries.entry(name).or_default().push(id); + } + + pub(crate) fn get(&self, name: &Name) -> Option<&[SymbolId]> { + self.entries.get(name).map(|ids| ids.as_slice()) + } +} diff --git a/crates/squawk_ide/src/symbols.rs b/crates/squawk_ide/src/symbols.rs new file mode 100644 index 00000000..ac408556 --- /dev/null +++ b/crates/squawk_ide/src/symbols.rs @@ -0,0 +1,52 @@ +use la_arena::Idx; +use smol_str::SmolStr; +use squawk_syntax::SyntaxNodePtr; + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct Name(pub(crate) SmolStr); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Schema { + Public, + Custom(Name), +} + +impl Schema { + pub(crate) fn from_name(name: Name) -> Self { + if name == Name::new("public") { + Schema::Public + } else { + Schema::Custom(name) + } + } +} + +impl Name { + pub(crate) fn new(text: impl Into) -> Self { + let text = text.into(); + let normalized = normalize_identifier(&text); + Name(normalized) + } +} + +fn normalize_identifier(text: &str) -> SmolStr { + if text.starts_with('"') && text.ends_with('"') && text.len() >= 2 { + text[1..text.len() - 1].into() + } else { + text.to_lowercase().into() + } +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) enum SymbolKind { + Table, +} + +#[derive(Clone, Debug)] +pub(crate) struct Symbol { + pub(crate) kind: SymbolKind, + pub(crate) ptr: SyntaxNodePtr, + pub(crate) schema: Schema, +} + +pub(crate) type SymbolId = Idx;