Skip to content

Commit d4e89ae

Browse files
k
1 parent 8a33dd8 commit d4e89ae

10 files changed

Lines changed: 367 additions & 15 deletions

crates/pgls_hover/src/hoverables/table.rs

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ impl ToHoverMarkdown for Table {
6868
write!(writer, "typeid:{}", column.type_id)?;
6969
}
7070

71+
if column.is_nullable {
72+
write!(writer, " - nullable")?;
73+
} else {
74+
write!(writer, " - not null")?;
75+
}
76+
77+
if let Some(default_expr) = column
78+
.default_expr
79+
.as_deref()
80+
.and_then(extract_basic_default_literal)
81+
{
82+
write!(writer, " - default: {default_expr}")?;
83+
}
84+
7185
writeln!(writer)?;
7286
}
7387

@@ -99,6 +113,258 @@ impl ToHoverMarkdown for Table {
99113
}
100114
}
101115

116+
fn extract_basic_default_literal(default_expr: &str) -> Option<String> {
117+
let mut candidate = default_expr.trim();
118+
119+
loop {
120+
let mut changed = false;
121+
122+
if let Some(unwrapped) = strip_outer_parentheses(candidate) {
123+
candidate = unwrapped.trim();
124+
changed = true;
125+
}
126+
127+
if let Some(without_cast) = strip_trailing_top_level_casts(candidate) {
128+
candidate = without_cast.trim();
129+
changed = true;
130+
}
131+
132+
if !changed {
133+
break;
134+
}
135+
}
136+
137+
if is_basic_literal(candidate) {
138+
Some(candidate.to_string())
139+
} else {
140+
None
141+
}
142+
}
143+
144+
fn strip_outer_parentheses(value: &str) -> Option<&str> {
145+
let value = value.trim();
146+
147+
if !value.starts_with('(') || !value.ends_with(')') {
148+
return None;
149+
}
150+
151+
let mut depth = 0_i32;
152+
let mut in_single_quote = false;
153+
let mut in_double_quote = false;
154+
let bytes = value.as_bytes();
155+
let mut idx = 0_usize;
156+
157+
while idx < bytes.len() {
158+
let ch = bytes[idx] as char;
159+
160+
if in_single_quote {
161+
if ch == '\'' {
162+
if idx + 1 < bytes.len() && bytes[idx + 1] as char == '\'' {
163+
idx += 2;
164+
continue;
165+
}
166+
167+
in_single_quote = false;
168+
}
169+
170+
idx += 1;
171+
continue;
172+
}
173+
174+
if in_double_quote {
175+
if ch == '"' {
176+
in_double_quote = false;
177+
}
178+
179+
idx += 1;
180+
continue;
181+
}
182+
183+
match ch {
184+
'\'' => in_single_quote = true,
185+
'"' => in_double_quote = true,
186+
'(' => depth += 1,
187+
')' => {
188+
depth -= 1;
189+
if depth == 0 && idx != bytes.len() - 1 {
190+
return None;
191+
}
192+
}
193+
_ => {}
194+
}
195+
196+
idx += 1;
197+
}
198+
199+
if depth != 0 || in_single_quote || in_double_quote {
200+
return None;
201+
}
202+
203+
Some(&value[1..value.len() - 1])
204+
}
205+
206+
fn strip_trailing_top_level_casts(value: &str) -> Option<&str> {
207+
let bytes = value.as_bytes();
208+
let mut depth = 0_i32;
209+
let mut in_single_quote = false;
210+
let mut in_double_quote = false;
211+
let mut idx = 0_usize;
212+
213+
while idx + 1 < bytes.len() {
214+
let ch = bytes[idx] as char;
215+
216+
if in_single_quote {
217+
if ch == '\'' {
218+
if idx + 1 < bytes.len() && bytes[idx + 1] as char == '\'' {
219+
idx += 2;
220+
continue;
221+
}
222+
in_single_quote = false;
223+
}
224+
idx += 1;
225+
continue;
226+
}
227+
228+
if in_double_quote {
229+
if ch == '"' {
230+
in_double_quote = false;
231+
}
232+
idx += 1;
233+
continue;
234+
}
235+
236+
match ch {
237+
'\'' => {
238+
in_single_quote = true;
239+
idx += 1;
240+
continue;
241+
}
242+
'"' => {
243+
in_double_quote = true;
244+
idx += 1;
245+
continue;
246+
}
247+
'(' => {
248+
depth += 1;
249+
idx += 1;
250+
continue;
251+
}
252+
')' => {
253+
depth -= 1;
254+
idx += 1;
255+
continue;
256+
}
257+
':' if depth == 0 && bytes[idx + 1] as char == ':' => {
258+
let suffix = &value[idx..];
259+
if suffix
260+
.chars()
261+
.all(|c| c.is_ascii_alphanumeric() || ":_\".()[] ,\t".contains(c))
262+
{
263+
return Some(value[..idx].trim_end());
264+
}
265+
return None;
266+
}
267+
_ => {
268+
idx += 1;
269+
continue;
270+
}
271+
}
272+
}
273+
274+
None
275+
}
276+
277+
fn is_basic_literal(value: &str) -> bool {
278+
let value = value.trim();
279+
280+
if value.eq_ignore_ascii_case("true")
281+
|| value.eq_ignore_ascii_case("false")
282+
|| value.eq_ignore_ascii_case("null")
283+
{
284+
return true;
285+
}
286+
287+
is_numeric_literal(value) || is_single_quoted_literal(value)
288+
}
289+
290+
fn is_single_quoted_literal(value: &str) -> bool {
291+
let bytes = value.as_bytes();
292+
293+
if bytes.len() < 2 || bytes.first() != Some(&b'\'') || bytes.last() != Some(&b'\'') {
294+
return false;
295+
}
296+
297+
let mut idx = 1_usize;
298+
let end = bytes.len() - 1;
299+
300+
while idx < end {
301+
if bytes[idx] == b'\'' {
302+
if idx + 1 < end && bytes[idx + 1] == b'\'' {
303+
idx += 2;
304+
} else {
305+
return false;
306+
}
307+
} else {
308+
idx += 1;
309+
}
310+
}
311+
312+
true
313+
}
314+
315+
fn is_numeric_literal(value: &str) -> bool {
316+
let bytes = value.as_bytes();
317+
if bytes.is_empty() {
318+
return false;
319+
}
320+
321+
let mut idx = 0_usize;
322+
323+
if matches!(bytes[idx], b'+' | b'-') {
324+
idx += 1;
325+
if idx >= bytes.len() {
326+
return false;
327+
}
328+
}
329+
330+
let integer_start = idx;
331+
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
332+
idx += 1;
333+
}
334+
let has_integer_digits = idx > integer_start;
335+
336+
if idx < bytes.len() && bytes[idx] == b'.' {
337+
idx += 1;
338+
let fractional_start = idx;
339+
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
340+
idx += 1;
341+
}
342+
if !has_integer_digits && idx == fractional_start {
343+
return false;
344+
}
345+
} else if !has_integer_digits {
346+
return false;
347+
}
348+
349+
if idx < bytes.len() && matches!(bytes[idx], b'e' | b'E') {
350+
idx += 1;
351+
if idx < bytes.len() && matches!(bytes[idx], b'+' | b'-') {
352+
idx += 1;
353+
}
354+
355+
let exponent_start = idx;
356+
while idx < bytes.len() && bytes[idx].is_ascii_digit() {
357+
idx += 1;
358+
}
359+
360+
if idx == exponent_start {
361+
return false;
362+
}
363+
}
364+
365+
idx == bytes.len()
366+
}
367+
102368
impl ContextualPriority for Table {
103369
fn relevance_score(&self, ctx: &TreesitterContext) -> f32 {
104370
let mut score = 0.0;
@@ -127,3 +393,38 @@ impl ContextualPriority for Table {
127393
score
128394
}
129395
}
396+
397+
#[cfg(test)]
398+
mod tests {
399+
use super::extract_basic_default_literal;
400+
401+
#[test]
402+
fn extracts_basic_defaults_with_optional_casts() {
403+
assert_eq!(
404+
extract_basic_default_literal("'anonymous'::text"),
405+
Some("'anonymous'".to_string())
406+
);
407+
assert_eq!(extract_basic_default_literal("(42)::int8"), Some("42".to_string()));
408+
assert_eq!(
409+
extract_basic_default_literal("NULL::character varying"),
410+
Some("NULL".to_string())
411+
);
412+
assert_eq!(
413+
extract_basic_default_literal("false::boolean"),
414+
Some("false".to_string())
415+
);
416+
}
417+
418+
#[test]
419+
fn ignores_non_basic_defaults() {
420+
assert_eq!(
421+
extract_basic_default_literal("nextval('users_id_seq'::regclass)"),
422+
None
423+
);
424+
assert_eq!(extract_basic_default_literal("now()"), None);
425+
assert_eq!(
426+
extract_basic_default_literal("'a'::text || 'b'::text"),
427+
None
428+
);
429+
}
430+
}

crates/pgls_hover/tests/hover_integration_tests.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,32 @@ async fn test_table_hover_select_star(test_db: PgPool) {
217217
test_hover_at_cursor("table_hover_select_star", query, Some(setup), &test_db).await;
218218
}
219219

220+
#[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")]
221+
async fn test_table_hover_shows_nullable_and_basic_defaults(test_db: PgPool) {
222+
let setup = r#"
223+
create table users (
224+
id serial primary key,
225+
name text default 'anonymous',
226+
score int default 0,
227+
enabled bool default false,
228+
created_at timestamptz default now()
229+
);
230+
"#;
231+
232+
let query = format!(
233+
"select * from use{}rs",
234+
QueryWithCursorPosition::cursor_marker()
235+
);
236+
237+
test_hover_at_cursor(
238+
"table_hover_nullable_and_basic_defaults",
239+
query,
240+
Some(setup),
241+
&test_db,
242+
)
243+
.await;
244+
}
245+
220246
#[sqlx::test(migrator = "pgls_test_utils::MIGRATIONS")]
221247
async fn test_no_hover_on_keyword(test_db: PgPool) {
222248
let setup = r#"

crates/pgls_hover/tests/snapshots/create_policy.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ create policy "my cool pol" on users for all to public with check (true);
1212
### `public.users` - 🔓 RLS disabled
1313
```plain
1414
Columns:
15-
- id: int4
16-
- name: text
15+
- id: int4 - not null
16+
- name: text - nullable
1717
1818
```
1919
---

crates/pgls_hover/tests/snapshots/grant_select.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ grant select on users to public;
1212
### `public.users` - 🔓 RLS disabled
1313
```plain
1414
Columns:
15-
- id: int4
16-
- name: text
15+
- id: int4 - not null
16+
- name: text - nullable
1717
1818
```
1919
---

crates/pgls_hover/tests/snapshots/revoke_select.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ revoke select on users from public;
1212
### `public.users` - 🔓 RLS disabled
1313
```plain
1414
Columns:
15-
- id: int4
16-
- name: text
15+
- id: int4 - not null
16+
- name: text - nullable
1717
1818
```
1919
---

crates/pgls_hover/tests/snapshots/table_hover.snap

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ select id from users
1212
### `public.users` - 🔓 RLS disabled
1313
```plain
1414
Columns:
15-
- id: int4
16-
- email: varchar(255)
15+
- id: int4 - not null
16+
- email: varchar(255) - not null
1717
1818
```
1919
---

0 commit comments

Comments
 (0)