From e345f7f4c3629578b3bbe535cad1af6020935627 Mon Sep 17 00:00:00 2001 From: vabatta <2137077+vabatta@users.noreply.github.com> Date: Sun, 5 Apr 2026 01:43:59 +0200 Subject: [PATCH] fix: constants scope, CASCADE in TRUNCATE, keyword_ddl in PL/pgSQL bodies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change NULL/TRUE/FALSE/UNKNOWN and EXTRACT field names from entity.name.label to constant.language so themes highlight them - Add CASCADE and RESTRICT to keyword_dml so they highlight in TRUNCATE ... CASCADE/RESTRICT (previously only in keyword_ddl, which dml_statement intentionally excludes) - Remove keyword_ddl from dollar_quotes context (PL/pgSQL function bodies); replace keywords_all with explicit keyword_dml + keyword_control + keyword_other — prevents column names like output, value, data from being highlighted as DDL keywords inside function bodies --- README.md | 3 +-- pgsql.tmLanguage.json | 14 +++++++++---- samples/01-dml.sql | 16 ++++++++++++++- samples/05-plpgsql.sql | 35 +++++++++++++++++++++++++++++++ tests/constants.test.sql | 40 ++++++++++++++++++++++++++++++------ tests/dml-keywords.test.sql | 10 +++++++++ tests/functions.test.sql | 10 ++++++++- tests/is-constructs.test.sql | 12 +++++------ 8 files changed, 120 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 728e4b6..9097363 100644 --- a/README.md +++ b/README.md @@ -39,12 +39,11 @@ Actual colors depend on your theme. The table below documents the TextMate scope - 100+ PostgreSQL type names with multi-word support - 200+ built-in functions and support constants - Standalone keyword fallbacks for multiline resilience -- 415 test assertions across 20 test files ## Usage ```bash -npm test # run 415 grammar tests +npm test # run grammar tests npm run validate # 0 unscoped tokens across all samples npm run validate-sql # all statements parse via libpg-query npm run preview # http://localhost:3117 — live preview with theme picker diff --git a/pgsql.tmLanguage.json b/pgsql.tmLanguage.json index cbee8f4..9dd6d41 100644 --- a/pgsql.tmLanguage.json +++ b/pgsql.tmLanguage.json @@ -600,7 +600,13 @@ "include": "#support_constants" }, { - "include": "#keywords_all" + "include": "#keyword_dml" + }, + { + "include": "#keyword_control" + }, + { + "include": "#keyword_other" }, { "include": "#language_constants" @@ -874,7 +880,7 @@ "patterns": [ { "match": "(?xi)\\b(century|day|decade|dow|doy|epoch|hour|isodow|isoyear|julian|microseconds|millennium|milliseconds|minute|month|quarter|second|timezone_hour|timezone_minute|timezone|week|year)\\b", - "name": "entity.name.label.pgsql" + "name": "constant.language.pgsql" }, { "include": "#paren_group" @@ -1088,7 +1094,7 @@ ] }, "keyword_dml": { - "match": "(?xi)\\b(default\\s+values|on\\s+conflict|do\\s+nothing|do\\s+update|select|insert|update|delete|merge|returning|truncate|copy|values|set|matched|conflict)\\b", + "match": "(?xi)\\b(default\\s+values|on\\s+conflict|do\\s+nothing|do\\s+update|select|insert|update|delete|merge|returning|truncate|copy|values|set|matched|conflict|cascade|restrict)\\b", "name": "keyword.dml.pgsql" }, "keyword_ddl": { @@ -1105,7 +1111,7 @@ }, "language_constants": { "match": "(?xi)\\b(null|true|false|unknown)\\b", - "name": "entity.name.label.pgsql" + "name": "constant.language.pgsql" }, "function_call": { "comment": "Any identifier followed by ( — catches user-defined function calls", diff --git a/samples/01-dml.sql b/samples/01-dml.sql index d8c84c8..3e82985 100644 --- a/samples/01-dml.sql +++ b/samples/01-dml.sql @@ -138,9 +138,10 @@ WHEN NOT MATCHED BY TARGET THEN WHEN NOT MATCHED BY SOURCE THEN DELETE; --- TRUNCATE with RESTART IDENTITY +-- TRUNCATE with CASCADE / RESTRICT TRUNCATE TABLE old_logs, archived_sessions; TRUNCATE TABLE counters RESTART IDENTITY CASCADE; +TRUNCATE TABLE staging_data RESTRICT; -- COPY variants COPY users (name, email) TO '/tmp/users.csv' WITH (FORMAT csv, HEADER true, DELIMITER ',', QUOTE '"', ESCAPE '\'); @@ -289,6 +290,19 @@ SELECT * FROM accounts WHERE id = 1 FOR UPDATE OF accounts; SELECT * FROM users WHERE email ISNULL; SELECT * FROM users WHERE email NOTNULL; +-- NULL / TRUE / FALSE as language constants +UPDATE accounts SET worker_id = NULL, claimed_at = NULL WHERE id = 1; +SELECT * FROM users WHERE is_active = TRUE AND is_deleted = FALSE; + +-- Keyword-named functions (is, similar etc.) used as function calls +-- 'is' before '(' is a function call (e.g. pgTAP), not the IS keyword +SELECT is(get_count(), 0, 'should be zero'); +SELECT is( + (SELECT was_cancelled FROM cancel_run(42)), + FALSE, + 'cancel of completed run returns false' +); + -- Quoted identifiers SELECT "user"."first_name", "user"."last_name" FROM "public"."user" diff --git a/samples/05-plpgsql.sql b/samples/05-plpgsql.sql index a2b2ef0..fa8ff38 100644 --- a/samples/05-plpgsql.sql +++ b/samples/05-plpgsql.sql @@ -320,6 +320,41 @@ BEGIN END; $$; +-- NULL/TRUE/FALSE as language constants inside PL/pgSQL body +CREATE OR REPLACE FUNCTION reset_run(p_run_id BIGINT) +RETURNS VOID LANGUAGE plpgsql AS $$ +BEGIN + UPDATE wf.runs + SET status = 'queued', + worker_id = NULL, + claimed_at = NULL, + timeout_at = NULL, + finished_at = NULL + WHERE id = p_run_id; + + IF NOT FOUND THEN + RAISE WARNING 'run % not found', p_run_id; + END IF; +END; +$$; + +-- Column named 'output' in INSERT inside a function body +-- (should NOT be highlighted as a DDL keyword) +CREATE OR REPLACE FUNCTION complete_run(p_run_id BIGINT, p_output JSONB) +RETURNS VOID LANGUAGE plpgsql AS $$ +BEGIN + UPDATE wf.runs + SET status = 'completed', + output = p_output, + worker_id = NULL, + finished_at = now() + WHERE id = p_run_id; + + INSERT INTO wf.ev_run_completed (run_id, output) + VALUES (p_run_id, p_output); +END; +$$; + -- Trigger function with NEW/OLD references CREATE OR REPLACE FUNCTION audit_trigger() RETURNS TRIGGER diff --git a/tests/constants.test.sql b/tests/constants.test.sql index 83d7d8f..b237199 100644 --- a/tests/constants.test.sql +++ b/tests/constants.test.sql @@ -1,13 +1,41 @@ --- Boolean and null constants +-- Boolean and null constants in a SELECT SELECT NULL, TRUE, FALSE; --- ^^^^ entity.name.label --- ^^^^ entity.name.label --- ^^^^^ entity.name.label +-- ^^^^ constant.language +-- ^^^^ constant.language +-- ^^^^^ constant.language -- UNKNOWN constant (three-valued logic) SELECT UNKNOWN; --- ^^^^^^^ entity.name.label +-- ^^^^^^^ constant.language + +-- NULL in UPDATE SET (DML context) +UPDATE t SET x = NULL, y = TRUE, z = FALSE WHERE id = 1; +-- ^^^^ constant.language +-- ^^^^ constant.language +-- ^^^^^ constant.language + +-- NULL should NOT be highlighted as a keyword +UPDATE t SET x = NULL WHERE id = 1; +-- ^^^^ !keyword + +-- NULL in PL/pgSQL body (dollar_quotes context) +DO $$ BEGIN UPDATE t SET x = NULL WHERE id = 1; END; $$; +-- ^^^^ constant.language + +-- TRUE/FALSE in IF condition (dollar_quotes context) +DO $$ BEGIN IF TRUE THEN RETURN NULL; END IF; END; $$; +-- ^^^^ constant.language +-- ^^^^ constant.language + +-- FALSE/TRUE as function arguments in DML +SELECT coalesce(FALSE, TRUE); +-- ^^^^^ constant.language +-- ^^^^ constant.language -- EXTRACT field names SELECT extract(EPOCH FROM now()); --- ^^^^^ entity.name.label +-- ^^^^^ constant.language + +SELECT extract(year FROM hire_date), extract(month FROM hire_date); +-- ^^^^ constant.language +-- ^^^^^ constant.language diff --git a/tests/dml-keywords.test.sql b/tests/dml-keywords.test.sql index 885bcbc..a08dac1 100644 --- a/tests/dml-keywords.test.sql +++ b/tests/dml-keywords.test.sql @@ -62,3 +62,13 @@ COPY t FROM STDIN WITH (ON_ERROR stop, LOG_VERBOSITY verbose); -- BINARY option COPY t TO STDOUT WITH (FORMAT BINARY); -- ^^^^^^ keyword + +-- CASCADE and RESTRICT in TRUNCATE (DML context) +TRUNCATE TABLE t CASCADE; +-- ^^^^^^^ keyword.dml +TRUNCATE TABLE t RESTRICT; +-- ^^^^^^^^ keyword.dml + +-- CASCADE should NOT be unstyled in TRUNCATE +TRUNCATE TABLE t CASCADE; +-- ^^^^^^^ !keyword.ddl diff --git a/tests/functions.test.sql b/tests/functions.test.sql index 6b35d77..975b42f 100644 --- a/tests/functions.test.sql +++ b/tests/functions.test.sql @@ -16,5 +16,13 @@ INSERT INTO users (name) VALUES (1); -- ^^^^^ !support.function -- Table names after REFERENCES should NOT be functions -REFERENCES users(id) +REFERENCES users(id); -- ^^^^^ !support.function + +-- 'is' as SQL keyword (not followed by '(') should remain a keyword +SELECT x FROM t WHERE x IS NULL; +-- ^^ keyword + +-- column named 'output' in INSERT inside dollar-quote should NOT be keyword.ddl +DO $$ BEGIN INSERT INTO t (id, output) VALUES (1, 'x'); END; $$; +-- ^^^^^^ !keyword.ddl diff --git a/tests/is-constructs.test.sql b/tests/is-constructs.test.sql index e4211c8..722cb30 100644 --- a/tests/is-constructs.test.sql +++ b/tests/is-constructs.test.sql @@ -7,29 +7,29 @@ SELECT x FROM t WHERE x IS NOT NULL; -- IS TRUE / IS NOT TRUE SELECT x FROM t WHERE b IS TRUE; -- ^^ keyword --- ^^^^ entity.name.label +-- ^^^^ constant.language SELECT x FROM t WHERE b IS NOT TRUE; -- ^^ keyword -- ^^^ keyword --- ^^^^ entity.name.label +-- ^^^^ constant.language -- IS FALSE / IS NOT FALSE SELECT x FROM t WHERE b IS FALSE; -- ^^ keyword --- ^^^^^ entity.name.label +-- ^^^^^ constant.language SELECT x FROM t WHERE b IS NOT FALSE; -- ^^ keyword -- ^^^ keyword --- ^^^^^ entity.name.label +-- ^^^^^ constant.language -- IS UNKNOWN / IS NOT UNKNOWN SELECT x FROM t WHERE b IS UNKNOWN; -- ^^ keyword --- ^^^^^^^ entity.name.label +-- ^^^^^^^ constant.language SELECT x FROM t WHERE b IS NOT UNKNOWN; -- ^^ keyword -- ^^^ keyword --- ^^^^^^^ entity.name.label +-- ^^^^^^^ constant.language -- IS DISTINCT FROM / IS NOT DISTINCT FROM SELECT x FROM t WHERE a IS DISTINCT FROM b;