Skip to content

Commit e3fd6b4

Browse files
authored
feat: plpgsql syntax errors (#452)
<img width="1196" height="308" alt="Screenshot 2025-07-14 at 20 38 14" src="https://github.com/user-attachments/assets/9cb87800-e5f4-483a-81da-02a758db8b38" /> ToDo: - [x] tests - [x] fix range of returned diagnostic
1 parent 549248d commit e3fd6b4

File tree

9 files changed

+680
-66
lines changed

9 files changed

+680
-66
lines changed

crates/pgt_lsp/tests/server.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1678,3 +1678,84 @@ ALTER TABLE ONLY "public"."campaign_contact_list"
16781678

16791679
Ok(())
16801680
}
1681+
1682+
#[sqlx::test(migrator = "pgt_test_utils::MIGRATIONS")]
1683+
async fn test_plpgsql(test_db: PgPool) -> Result<()> {
1684+
let factory = ServerFactory::default();
1685+
let mut fs = MemoryFileSystem::default();
1686+
1687+
let mut conf = PartialConfiguration::init();
1688+
conf.merge_with(PartialConfiguration {
1689+
db: Some(PartialDatabaseConfiguration {
1690+
database: Some(
1691+
test_db
1692+
.connect_options()
1693+
.get_database()
1694+
.unwrap()
1695+
.to_string(),
1696+
),
1697+
..Default::default()
1698+
}),
1699+
..Default::default()
1700+
});
1701+
fs.insert(
1702+
url!("postgrestools.jsonc").to_file_path().unwrap(),
1703+
serde_json::to_string_pretty(&conf).unwrap(),
1704+
);
1705+
1706+
let (service, client) = factory
1707+
.create_with_fs(None, DynRef::Owned(Box::new(fs)))
1708+
.into_inner();
1709+
1710+
let (stream, sink) = client.split();
1711+
let mut server = Server::new(service);
1712+
1713+
let (sender, mut receiver) = channel(CHANNEL_BUFFER_SIZE);
1714+
let reader = tokio::spawn(client_handler(stream, sink, sender));
1715+
1716+
server.initialize().await?;
1717+
server.initialized().await?;
1718+
1719+
server.load_configuration().await?;
1720+
1721+
let initial_content = r#"
1722+
create function test_organisation_id ()
1723+
returns setof text
1724+
language plpgsql
1725+
security invoker
1726+
as $$
1727+
declre
1728+
v_organisation_id uuid;
1729+
begin
1730+
return next is(private.organisation_id(), v_organisation_id, 'should return organisation_id of token');
1731+
end
1732+
$$;
1733+
"#;
1734+
1735+
server.open_document(initial_content).await?;
1736+
1737+
let notification = tokio::time::timeout(Duration::from_secs(5), async {
1738+
loop {
1739+
match receiver.next().await {
1740+
Some(ServerNotification::PublishDiagnostics(msg)) => {
1741+
if msg.diagnostics.iter().any(|d| {
1742+
d.message
1743+
.contains("Invalid statement: syntax error at or near \"declre\"")
1744+
}) {
1745+
return true;
1746+
}
1747+
}
1748+
_ => continue,
1749+
}
1750+
}
1751+
})
1752+
.await
1753+
.is_ok();
1754+
1755+
assert!(notification, "expected diagnostics for unknown column");
1756+
1757+
server.shutdown().await?;
1758+
reader.abort();
1759+
1760+
Ok(())
1761+
}

crates/pgt_query_ext/src/diagnostics.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,16 @@ pub struct SyntaxDiagnostic {
1515
pub message: MessageAndDescription,
1616
}
1717

18+
impl SyntaxDiagnostic {
19+
/// Create a new syntax diagnostic with the given message and optional span.
20+
pub fn new(message: impl Into<String>, span: Option<TextRange>) -> Self {
21+
SyntaxDiagnostic {
22+
span,
23+
message: MessageAndDescription::from(message.into()),
24+
}
25+
}
26+
}
27+
1828
impl From<pg_query::Error> for SyntaxDiagnostic {
1929
fn from(err: pg_query::Error) -> Self {
2030
SyntaxDiagnostic {

crates/pgt_query_ext/src/lib.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,38 @@ pub fn parse(sql: &str) -> Result<NodeEnum> {
2525
.ok_or_else(|| Error::Parse("Unable to find root node".to_string()))
2626
})?
2727
}
28+
29+
/// This function parses a PL/pgSQL function.
30+
///
31+
/// It expects the entire `CREATE FUNCTION` statement.
32+
pub fn parse_plpgsql(sql: &str) -> Result<()> {
33+
// we swallow the error until we have a proper binding
34+
let _ = pg_query::parse_plpgsql(sql)?;
35+
36+
Ok(())
37+
}
38+
39+
#[cfg(test)]
40+
mod tests {
41+
use super::*;
42+
43+
#[test]
44+
fn test_parse_plpgsql_err() {
45+
let input = "
46+
create function test_organisation_id ()
47+
returns setof text
48+
language plpgsql
49+
security invoker
50+
as $$
51+
-- syntax error here
52+
decare
53+
v_organisation_id uuid;
54+
begin
55+
select 1;
56+
end
57+
$$;
58+
";
59+
60+
assert!(parse_plpgsql(input).is_err());
61+
}
62+
}

crates/pgt_workspace/src/workspace/server.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ mod async_helper;
5353
mod connection_key;
5454
mod connection_manager;
5555
pub(crate) mod document;
56+
mod function_utils;
5657
mod migration;
5758
mod pg_query;
5859
mod schema_cache_manager;
@@ -528,7 +529,7 @@ impl Workspace for WorkspaceServer {
528529

529530
diagnostics.extend(
530531
doc.iter(SyncDiagnosticsMapper)
531-
.flat_map(|(_id, range, ast, diag)| {
532+
.flat_map(|(range, ast, diag)| {
532533
let mut errors: Vec<Error> = vec![];
533534

534535
if let Some(diag) = diag {
@@ -560,9 +561,12 @@ impl Workspace for WorkspaceServer {
560561
},
561562
);
562563

564+
// adjust the span of the diagnostics to the statement (if it has one)
565+
let span = d.location().span.map(|s| s + range.start());
566+
563567
SDiagnostic::new(
564568
d.with_file_path(params.path.as_path().display().to_string())
565-
.with_file_span(range)
569+
.with_file_span(span.unwrap_or(range))
566570
.with_severity(severity),
567571
)
568572
})

0 commit comments

Comments
 (0)