diff --git a/README.md b/README.md index ff6a643..728e4b6 100644 --- a/README.md +++ b/README.md @@ -10,27 +10,25 @@ Standalone `pgsql.tmLanguage.json` targeting Shiki, VS Code, GitHub Linguist, an ## Highlighting -Reference theme: **github-dark** +Actual colors depend on your theme. The table below documents the TextMate scope assigned to each token class — themes map scopes to colors. -| Token | Context | Color | Example | +| Token | Context | Scope | Example | |-------|---------|-------|---------| -| Keywords | everywhere | red | `SELECT`, `FROM`, `WHERE`, `AND`, `JOIN`, `CREATE TABLE`, `BEGIN`, `END` | -| Operators | everywhere | red | `::`, `=`, `<>`, `\|\|`, `@>`, `->>` | -| Built-in functions | before `(` | blue | `now()`, `count(*)`, `coalesce(a, b)` | -| User-defined functions | before `(` | blue | `get_active_users(100)`, `app.my_func()` | -| Numbers | everywhere | blue | `42`, `3.14` | -| Built-in types | after `::` | green | `x::DATE`, `y::INTEGER` | -| Built-in types | inside `CAST` | green | `CAST(y AS NUMERIC)` | -| Built-in types | before literal | green | `INTERVAL '1 day'`, `DATE '2024-01-01'` | -| Built-in types | DDL columns | green | `CREATE TABLE t (id SERIAL, name TEXT)` | -| Built-in types | function signatures | green | `CREATE FUNCTION f(p_id BIGINT)` | -| Built-in types | PL/pgSQL `DECLARE` | green | `DECLARE v_count INTEGER;` | +| Keywords | everywhere | `keyword` | `SELECT`, `FROM`, `WHERE`, `AND`, `JOIN`, `CREATE TABLE`, `BEGIN`, `END` | +| Operators | everywhere | `keyword.operator` | `::`, `=`, `<>`, `\|\|`, `@>`, `->>` | +| Built-in functions | before `(` | `support.function` | `now()`, `count(*)`, `coalesce(a, b)` | +| User-defined functions | before `(` | `entity.name.function` | `get_active_users(100)`, `app.my_func()` | +| Numbers | everywhere | `constant.numeric` | `42`, `3.14` | +| Built-in types | after `::`, inside `CAST`, before literal, DDL/signatures/`DECLARE` | `entity.name.tag` | `x::DATE`, `CAST(y AS NUMERIC)`, `INTERVAL '1 day'`, `id SERIAL` | | Built-in types | DML (bare word) | unstyled | `SELECT date, name, text FROM t` | -| Constants | everywhere | purple | `NULL`, `TRUE`, `FALSE` | -| `EXTRACT` fields | inside `EXTRACT()` | purple | `EXTRACT(EPOCH FROM now())` | -| Single-quoted strings | everywhere | light blue | `'hello'`, `E'\n'` | -| Double-quoted identifiers | everywhere | light blue | `"my_table"."column"` | -| Comments | everywhere | grey | `-- line`, `/* block */` | +| Constants | everywhere | `constant.language` | `NULL`, `TRUE`, `FALSE` | +| `EXTRACT` fields | inside `EXTRACT()` | `constant.language` | `EXTRACT(EPOCH FROM now())` | +| Single-quoted strings | everywhere | `string.quoted.single` | `'hello'`, `E'\n'` | +| Dollar-quoted literal | `COMMENT ON … IS`, `SELECT`, `INSERT … VALUES`, `CALL` | `string.unquoted` | `$$ plain text $$`, `$body$ text $body$` | +| Dollar-quoted body | `CREATE FUNCTION/PROCEDURE … AS`, `DO` | `meta.dollar-quote` (full SQL/PL inside) | `$$ BEGIN … END; $$` | +| Dollar-quoted nested | inside any dollar-quoted body (e.g. `EXECUTE $$…$$`) | `meta.dollar-quote` (recursive) | `EXECUTE $q$ SELECT 1 $q$;` | +| Double-quoted identifiers | everywhere | `variable.other` | `"my_table"."column"` | +| Comments | everywhere | `comment` | `-- line`, `/* block */` | | Identifiers | DML | unstyled | `u.name`, `created_at`, `users` | | Table after `INTO`/`COPY` | before `(columns)` | unstyled | `INSERT INTO users (name)`, `COPY t (col)` | | Table after `ON`/`REFERENCES` | before `(columns)` | unstyled | `ON orders (user_id)`, `REFERENCES t(id)` | diff --git a/pgsql.tmLanguage.json b/pgsql.tmLanguage.json index 35f13f4..273c8de 100644 --- a/pgsql.tmLanguage.json +++ b/pgsql.tmLanguage.json @@ -22,9 +22,15 @@ { "include": "#create_other" }, + { + "include": "#do_block" + }, { "include": "#dml_statement" }, + { + "include": "#comment_on" + }, { "include": "#general_statement" }, @@ -142,7 +148,7 @@ "include": "#create_table_columns" }, { - "include": "#dollar_quotes" + "include": "#dollar_quote_literal" }, { "include": "#comments" @@ -269,7 +275,7 @@ "name": "meta.statement.pgsql.create", "patterns": [ { - "include": "#dollar_quotes" + "include": "#dollar_quote_literal" }, { "include": "#comments" @@ -320,7 +326,7 @@ }, "dml_statement": { "comment": "DML statements — excludes keyword_ddl to avoid false positives on column names like data, value, mode, level", - "begin": "(?i)^\\s*(select|insert|update|delete|merge|with|truncate|copy|values|explain|vacuum|analyze|do|call)\\b", + "begin": "(?i)^\\s*(select|insert|update|delete|merge|with|truncate|copy|values|explain|vacuum|analyze|call)\\b", "beginCaptures": { "1": { "name": "keyword.other.pgsql" @@ -330,7 +336,7 @@ "name": "meta.statement.pgsql", "patterns": [ { - "include": "#dollar_quotes" + "include": "#dollar_quote_literal" }, { "include": "#comments" @@ -385,6 +391,48 @@ } ] }, + "comment_on": { + "comment": "COMMENT ON ... IS $$ ... $$ — value is a plain string literal, not code", + "begin": "(?i)^\\s*(comment)\\s+(on)\\b", + "beginCaptures": { + "1": { + "name": "keyword.ddl.pgsql" + }, + "2": { + "name": "keyword.ddl.pgsql" + } + }, + "end": ";\\s*", + "name": "meta.statement.pgsql.comment-on", + "patterns": [ + { + "comment": "IS keyword preceding the comment text", + "match": "(?i)\\b(is)\\b", + "name": "keyword.other.pgsql" + }, + { + "include": "#paren_group_typed" + }, + { + "include": "#dollar_quote_literal" + }, + { + "include": "#comments" + }, + { + "include": "#strings" + }, + { + "include": "#keywords_all" + }, + { + "include": "#schema_qualified" + }, + { + "include": "#misc" + } + ] + }, "general_statement": { "begin": "(^\\s*[a-zA-Z]+)", "beginCaptures": { @@ -396,7 +444,7 @@ "name": "meta.statement.pgsql", "patterns": [ { - "include": "#dollar_quotes" + "include": "#dollar_quote_literal" }, { "include": "#comments" @@ -404,6 +452,9 @@ { "include": "#strings" }, + { + "include": "#paren_group_typed" + }, { "include": "#cast_function" }, @@ -455,6 +506,50 @@ "end": "\\n", "name": "meta.statement.pgsql.psql" }, + "dollar_quote_literal": { + "comment": "Dollar-quoted string literal — plain string, no SQL highlighting inside", + "begin": "(\\$[\\w_0-9]*\\$)", + "beginCaptures": { + "1": { + "name": "string.unquoted.dollar.pgsql" + } + }, + "end": "(\\1)", + "endCaptures": { + "1": { + "name": "string.unquoted.dollar.pgsql" + } + }, + "name": "string.unquoted.dollar.pgsql" + }, + "do_block": { + "comment": "DO anonymous block — dollar-quoted body is executable PL/pgSQL", + "begin": "(?i)^\\s*(do)\\b", + "beginCaptures": { + "1": { + "name": "keyword.other.pgsql" + } + }, + "end": ";\\s*", + "name": "meta.statement.pgsql", + "patterns": [ + { + "include": "#dollar_quotes" + }, + { + "include": "#comments" + }, + { + "include": "#strings" + }, + { + "include": "#language_constants" + }, + { + "include": "#keywords_all" + } + ] + }, "dollar_quotes": { "comment": "Dollar-quoted body — treated as SQL/PL body with syntax highlighting", "begin": "(\\$[\\w_0-9]*\\$)", @@ -934,6 +1029,40 @@ } ] }, + "paren_group_typed": { + "comment": "Parentheses in function/procedure/aggregate signatures — includes storage_types for parameter type lists (used in COMMENT ON, GRANT, ALTER, DROP, etc.)", + "begin": "\\(", + "end": "\\)", + "patterns": [ + { + "include": "#paren_group_typed" + }, + { + "include": "#comments" + }, + { + "include": "#strings" + }, + { + "include": "#storage_types" + }, + { + "include": "#cast_type" + }, + { + "include": "#keywords_all" + }, + { + "include": "#operators" + }, + { + "include": "#schema_qualified" + }, + { + "include": "#misc" + } + ] + }, "support_functions": { "match": "(?xi)\\b(any_value|array_agg|avg|bit_and|bit_or|bit_xor|bool_and|bool_or|count|every|json_agg|jsonb_agg|json_agg_strict|jsonb_agg_strict|json_object_agg|jsonb_object_agg|json_object_agg_strict|jsonb_object_agg_strict|max|min|range_agg|range_intersect_agg|string_agg|sum|xmlagg|corr|covar_pop|covar_samp|regr_avgx|regr_avgy|regr_count|regr_intercept|regr_r2|regr_slope|regr_sxx|regr_sxy|regr_syy|stddev|stddev_pop|stddev_samp|variance|var_pop|var_samp|mode|percentile_cont|percentile_disc|grouping|row_number|rank|dense_rank|percent_rank|cume_dist|ntile|lag|lead|first_value|last_value|nth_value|ascii|btrim|char_length|character_length|chr|concat|concat_ws|format|initcap|casefold|left|length|lower|lpad|ltrim|md5|normalize|octet_length|overlay|parse_ident|position|quote_ident|quote_literal|quote_nullable|regexp_count|regexp_instr|regexp_like|regexp_match|regexp_matches|regexp_replace|regexp_split_to_array|regexp_split_to_table|regexp_substr|repeat|replace|reverse|right|rpad|rtrim|split_part|starts_with|string_to_array|string_to_table|strpos|substr|substring|to_ascii|to_hex|to_oct|to_bin|translate|trim|unicode_assigned|unistr|upper|abs|cbrt|ceil|ceiling|degrees|div|erf|erfc|exp|factorial|floor|gamma|gcd|lcm|lgamma|ln|log|log10|min_scale|mod|pi|power|radians|random|random_normal|round|scale|sign|sqrt|setseed|trim_scale|trunc|width_bucket|acos|acosd|asin|asind|atan|atand|atan2|atan2d|cos|cosd|cot|cotd|sin|sind|tan|tand|sinh|cosh|tanh|asinh|acosh|atanh|age|clock_timestamp|date_add|date_bin|date_part|date_subtract|date_trunc|extract|isfinite|justify_days|justify_hours|justify_interval|make_date|make_interval|make_time|make_timestamp|make_timestamptz|now|statement_timestamp|timeofday|to_timestamp|transaction_timestamp|pg_sleep|pg_sleep_for|pg_sleep_until|timezone|to_json|to_jsonb|array_to_json|row_to_json|json_build_array|jsonb_build_array|json_build_object|jsonb_build_object|json_object|jsonb_object|json_array|json_scalar|json_serialize|json_array_elements|jsonb_array_elements|json_array_elements_text|jsonb_array_elements_text|json_array_length|jsonb_array_length|json_each|jsonb_each|json_each_text|jsonb_each_text|json_extract_path|jsonb_extract_path|json_extract_path_text|jsonb_extract_path_text|json_object_keys|jsonb_object_keys|json_populate_record|jsonb_populate_record|jsonb_populate_record_valid|json_populate_recordset|jsonb_populate_recordset|json_to_record|jsonb_to_record|json_to_recordset|jsonb_to_recordset|jsonb_set|jsonb_set_lax|jsonb_insert|json_strip_nulls|jsonb_strip_nulls|jsonb_path_exists|jsonb_path_match|jsonb_path_query|jsonb_path_query_array|jsonb_path_query_first|jsonb_path_exists_tz|jsonb_path_match_tz|jsonb_path_query_tz|jsonb_path_query_array_tz|jsonb_path_query_first_tz|jsonb_pretty|json_typeof|jsonb_typeof|array_append|array_cat|array_dims|array_fill|array_length|array_lower|array_ndims|array_position|array_positions|array_prepend|array_remove|array_replace|array_reverse|array_sample|array_shuffle|array_sort|array_to_string|array_upper|cardinality|trim_array|unnest|generate_series|generate_subscripts|nextval|currval|setval|lastval|coalesce|nullif|greatest|least|to_char|to_date|to_number|abbrev|broadcast|family|host|hostmask|inet_merge|inet_same_family|masklen|netmask|network|set_masklen|macaddr8_set7bit|array_to_tsvector|get_current_ts_config|numnode|plainto_tsquery|phraseto_tsquery|websearch_to_tsquery|querytree|setweight|strip|to_tsquery|to_tsvector|json_to_tsvector|jsonb_to_tsvector|ts_delete|ts_filter|ts_headline|ts_rank|ts_rank_cd|ts_rewrite|tsquery_phrase|tsvector_to_array|ts_debug|ts_lexize|ts_parse|ts_token_type|ts_stat|gen_random_uuid|uuidv4|uuidv7|uuid_extract_timestamp|uuid_extract_version|enum_first|enum_last|enum_range|lower|upper|isempty|lower_inc|upper_inc|lower_inf|upper_inf|range_merge|multirange|bit_count|bit_length|crc32|crc32c|get_bit|get_byte|set_bit|set_byte|sha224|sha256|sha384|sha512|convert|convert_from|convert_to|encode|decode|xmlattributes|xmlcomment|xmlconcat|xmlelement|xmlforest|xmlpi|xmlroot|xmlexists|xmltext|xml_is_well_formed|xml_is_well_formed_document|xml_is_well_formed_content|xpath|xpath_exists|xmltable|table_to_xml|query_to_xml|num_nonnulls|num_nulls|current_setting|set_config|pg_cancel_backend|pg_terminate_backend|pg_reload_conf|pg_typeof|pg_notify|pg_column_size|pg_column_compression|pg_database_size|pg_relation_size|pg_size_pretty|pg_size_bytes|pg_table_size|pg_tablespace_size|pg_total_relation_size|pg_indexes_size|pg_backend_pid|pg_blocking_pids|pg_advisory_lock|pg_advisory_lock_shared|pg_advisory_unlock|pg_advisory_unlock_all|pg_advisory_unlock_shared|pg_advisory_xact_lock|pg_advisory_xact_lock_shared|pg_try_advisory_lock|pg_try_advisory_lock_shared|pg_try_advisory_xact_lock|pg_try_advisory_xact_lock_shared|pg_export_snapshot|pg_partition_tree|pg_partition_ancestors|pg_partition_root|format_type|pg_get_constraintdef|pg_get_expr|pg_get_functiondef|pg_get_function_arguments|pg_get_function_result|pg_get_indexdef|pg_get_serial_sequence|pg_get_viewdef|pg_get_keywords|pg_input_is_valid|pg_input_error_info|col_description|obj_description|shobj_description|version|pg_is_in_recovery|pg_current_wal_lsn|pg_wal_lsn_diff|pg_relation_filenode|pg_relation_filepath|area|center|diagonal|diameter|height|isclosed|isopen|npoints|pclose|popen|radius|slope|width|bound_box|suppress_redundant_updates_trigger|tsvector_update_trigger|tsvector_update_trigger_column|bernoulli|system|pg_restore_relation_stats|pg_restore_attribute_stats|pg_clear_relation_stats|pg_clear_attribute_stats|pg_get_acl|has_largeobject_privilege|pg_stat_get_backend_io|pg_stat_reset_backend_stats|pg_stat_get_backend_wal|pg_ls_summariesdir|pg_numa_available|pg_get_loaded_modules)\\s*(?=\\()", "name": "support.function.pgsql" diff --git a/samples/02-ddl.sql b/samples/02-ddl.sql index 6a71a18..9b62113 100644 --- a/samples/02-ddl.sql +++ b/samples/02-ddl.sql @@ -63,6 +63,16 @@ CREATE TABLE app.orders ( COMMENT ON TABLE app.orders IS 'Customer orders'; COMMENT ON COLUMN app.orders.metadata IS 'Arbitrary JSON metadata'; +COMMENT ON TYPE order_status IS $$ +Governs the lifecycle of an order. + + pending: payment not yet confirmed. + confirmed: payment received, awaiting fulfilment. + shipped: dispatched to carrier. + delivered: confirmed receipt by customer. + cancelled: voided before delivery. +$$; +COMMENT ON COLUMN app.orders.notes IS $body$Free-form notes attached to the order.$body$; -- Unlogged table CREATE UNLOGGED TABLE session_cache ( diff --git a/tests/comment-on-dollar-quote.test.sql b/tests/comment-on-dollar-quote.test.sql new file mode 100644 index 0000000..3164361 --- /dev/null +++ b/tests/comment-on-dollar-quote.test.sql @@ -0,0 +1,47 @@ +-- COMMENT ON with single-quoted string +COMMENT ON TABLE app.orders IS 'Customer orders'; +-- ^^^^^^^ keyword.ddl +-- ^^ keyword.ddl +-- ^^^^^ keyword.ddl +-- ^^ keyword.other +-- ^^^^^^^^^^^^^^^^ string.quoted.single + +-- COMMENT ON with $$ dollar-quoted string literal +COMMENT ON TYPE wf.reuse_policy IS $$ +-- ^^^^^^^ keyword.ddl +-- ^^ keyword.ddl +-- ^^^^ keyword.ddl +-- ^^ keyword.other +-- ^^ string.unquoted.dollar +Governs resubmission when prior closed runs exist. +-- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ string.unquoted.dollar +$$; + +-- COMMENT ON with labelled dollar-quote $body$ ... $body$ +COMMENT ON COLUMN app.orders.notes IS $body$ +-- ^^^^^^^ keyword.ddl +-- ^^ keyword.ddl +-- ^^^^^^ keyword.ddl +-- ^^ keyword.other +-- ^^^^^^ string.unquoted.dollar +Free-form text annotation. +-- ^^^^^^^^^^^^^^^^^^^^^^^^ string.unquoted.dollar +$body$; +--^^^^^^ string.unquoted.dollar + +-- Content inside COMMENT ON $$ should NOT be highlighted as SQL keywords +COMMENT ON TABLE t IS $$ +SELECT is just text here, not a keyword. +-- ^^^^ !keyword +$$; + +-- COMMENT ON FUNCTION with type list in signature +COMMENT ON FUNCTION wf.register_activity(TEXT, TEXT, INTERVAL, INT) IS 'test'; +-- ^^^^ entity.name.tag +-- ^^^^ entity.name.tag +-- ^^^^^^^^ entity.name.tag +-- ^^^ entity.name.tag + +-- Types inside IS '...' should NOT be highlighted (consumed as string) +COMMENT ON TABLE t IS 'TEXT and INT are not types here'; +-- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ string.quoted.single diff --git a/tests/do-block.test.sql b/tests/do-block.test.sql new file mode 100644 index 0000000..27b0ba5 --- /dev/null +++ b/tests/do-block.test.sql @@ -0,0 +1,28 @@ +-- BEGIN / END inside DO body are highlighted +DO $$ BEGIN NULL; END; $$; +-- ^^^^^ keyword +-- ^^^ keyword + +-- IF / THEN / ELSE inside DO body +DO $$ BEGIN IF true THEN NULL; ELSE NULL; END IF; END; $$; +-- ^^ keyword +-- ^^^^ keyword +-- ^^^^ keyword + +-- RAISE with severity inside DO body +DO $$ BEGIN RAISE NOTICE 'msg'; END; $$; +-- ^^^^^ keyword +-- ^^^^^^ keyword + +-- PERFORM inside DO body +DO $$ BEGIN PERFORM pg_sleep(0); END; $$; +-- ^^^^^^^ keyword + +-- WHILE loop inside DO body +DO $$ BEGIN WHILE true LOOP NULL; END LOOP; END; $$; +-- ^^^^^ keyword + +-- EXECUTE with nested dollar-quote — inner $$ body is also highlighted as SQL +DO $$ BEGIN EXECUTE $q$ SELECT 1 $q$; END; $$; +-- ^^^^^^^ keyword +-- ^^^^^^ keyword diff --git a/tests/dollar-quote-literal.test.sql b/tests/dollar-quote-literal.test.sql new file mode 100644 index 0000000..06919b0 --- /dev/null +++ b/tests/dollar-quote-literal.test.sql @@ -0,0 +1,15 @@ +-- SELECT with $$ dollar-quoted string literal — opening delimiter is string.unquoted.dollar +SELECT $$ hello world $$; +-- ^^ string.unquoted.dollar + +-- INSERT with $$ dollar-quoted string literal +INSERT INTO t (x) VALUES ($$some text$$); +-- ^^ string.unquoted.dollar + +-- Body text inside $$ in SELECT is part of the string +SELECT $$ hello world $$; +-- ^^^^^^^^^^^ string.unquoted.dollar + +-- Keyword inside $$ in SELECT does NOT get keyword scope +SELECT $$ SELECT is just text $$; +-- ^^^^^^ !keyword diff --git a/tests/grant-function-signature.test.sql b/tests/grant-function-signature.test.sql new file mode 100644 index 0000000..fe31d25 --- /dev/null +++ b/tests/grant-function-signature.test.sql @@ -0,0 +1,21 @@ +-- GRANT with function type signature +GRANT EXECUTE ON FUNCTION wf.register_activity(TEXT, TEXT, INTERVAL, INT) TO app_role; +-- ^^^^ entity.name.tag +-- ^^^^ entity.name.tag +-- ^^^^^^^^ entity.name.tag +-- ^^^ entity.name.tag + +-- REVOKE with function type signature +REVOKE EXECUTE ON FUNCTION wf.register_activity(TEXT, INT) FROM app_role; +-- ^^^^ entity.name.tag +-- ^^^ entity.name.tag + +-- ALTER FUNCTION with type signature +ALTER FUNCTION wf.register_activity(TEXT, INT) OWNER TO app_role; +-- ^^^^ entity.name.tag +-- ^^^ entity.name.tag + +-- DROP FUNCTION with type signature +DROP FUNCTION wf.register_activity(TEXT, INT); +-- ^^^^ entity.name.tag +-- ^^^ entity.name.tag