From 79b80cac67f8c07ad9bde00063419c0d80e87152 Mon Sep 17 00:00:00 2001 From: muk Date: Tue, 17 Feb 2026 21:32:53 +0000 Subject: [PATCH] feat: advanced SQL syntax highlighting and expanded language support - Greatly expanded SQL keyword coverage (200+): LATERAL joins, GROUPING SETS, CUBE, ROLLUP, MERGE, MATERIALIZED, transaction isolation levels, DDL for functions/triggers/policies/extensions, PL/pgSQL control flow - Added SQL built-in function recognition (250+) with distinct color: aggregate functions, window functions, string/numeric/date functions, JSON/JSONB functions, array functions, full-text search, system info - Expanded SQL type coverage (90+): range types, multirange types, OID types (regclass, regtype, etc.), full-text search types, pseudo-types - Block comment (/* */) syntax highlighting with multi-line support - PostgreSQL-specific operator highlighting: ::, ->, ->>, #>, #>>, @>, <@, ?|, ?&, ||, and comparison operators - Escaped single-quote ('') handling inside string literals - Dot operator styled as muted for schema.table notation clarity - Functions highlighted in distinct color (blue) vs keywords (purple) Closes #4 Co-Authored-By: Claude Opus 4.6 --- src/ui/components.rs | 137 ++++++++++-- src/ui/theme.rs | 480 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 580 insertions(+), 37 deletions(-) diff --git a/src/ui/components.rs b/src/ui/components.rs index da943dd..3d46560 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -8,7 +8,8 @@ use ratatui::{ use crate::db::SslMode; use crate::ui::{ - is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, SPINNER_FRAMES, + is_sql_function, is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, + SPINNER_FRAMES, }; pub fn draw(frame: &mut Frame, app: &App) { @@ -333,6 +334,27 @@ fn draw_editor(frame: &mut Frame, app: &App, area: Rect) { } } +/// Determine if a line starts inside a block comment by scanning all previous lines. +fn is_in_block_comment(lines: &[String], current_line: usize) -> bool { + let mut depth = 0i32; + for line in lines.iter().take(current_line) { + let chars: Vec = line.chars().collect(); + let mut i = 0; + while i < chars.len() { + if i + 1 < chars.len() && chars[i] == '/' && chars[i + 1] == '*' { + depth += 1; + i += 2; + } else if i + 1 < chars.len() && chars[i] == '*' && chars[i + 1] == '/' { + depth = (depth - 1).max(0); + i += 2; + } else { + i += 1; + } + } + } + depth > 0 +} + fn highlight_sql_line<'a>( line: &'a str, theme: &Theme, @@ -343,18 +365,25 @@ fn highlight_sql_line<'a>( let mut current_word = String::new(); let mut in_string = false; let mut string_char = '"'; - let in_comment = false; + let mut in_block_comment = is_in_block_comment(&editor.lines, line_number); + + let chars: Vec = line.chars().collect(); + let len = chars.len(); + let mut i = 0; + + while i < len { + let c = chars[i]; + // Compute byte index for selection check + let byte_idx: usize = chars[..i].iter().map(|ch| ch.len_utf8()).sum(); - for (i, c) in line.char_indices() { - // Check for selection let is_selected = if let Some(((start_x, start_y), (end_x, end_y))) = editor.get_selection() { if start_y == end_y && line_number == start_y { - i >= start_x && i < end_x + byte_idx >= start_x && byte_idx < end_x } else if line_number == start_y { - i >= start_x + byte_idx >= start_x } else if line_number == end_y { - i < end_x + byte_idx < end_x } else { line_number > start_y && line_number < end_y } @@ -368,22 +397,61 @@ fn highlight_sql_line<'a>( Style::default() }; - // Handle comments - if !in_string && line[i..].starts_with("--") { + // Handle block comments + if in_block_comment { + if i + 1 < len && c == '*' && chars[i + 1] == '/' { + spans.push(Span::styled( + "*/".to_string(), + base_style.fg(theme.syntax_comment), + )); + in_block_comment = false; + i += 2; + } else { + spans.push(Span::styled( + c.to_string(), + base_style.fg(theme.syntax_comment), + )); + i += 1; + } + continue; + } + + // Start block comment + if !in_string && i + 1 < len && c == '/' && chars[i + 1] == '*' { if !current_word.is_empty() { spans.push(create_word_span(¤t_word, theme, base_style)); current_word.clear(); } spans.push(Span::styled( - line[i..].to_string(), + "/*".to_string(), base_style.fg(theme.syntax_comment), )); + in_block_comment = true; + i += 2; + continue; + } + + // Handle line comments + if !in_string && i + 1 < len && c == '-' && chars[i + 1] == '-' { + if !current_word.is_empty() { + spans.push(create_word_span(¤t_word, theme, base_style)); + current_word.clear(); + } + let rest: String = chars[i..].iter().collect(); + spans.push(Span::styled(rest, base_style.fg(theme.syntax_comment))); break; } // Handle strings - if (c == '\'' || c == '"') && !in_comment { + if (c == '\'' || c == '"') && !in_block_comment { if in_string && c == string_char { + // Check for escaped quotes ('') + if c == '\'' && i + 1 < len && chars[i + 1] == '\'' { + current_word.push(c); + current_word.push(c); + i += 2; + continue; + } current_word.push(c); spans.push(Span::styled( current_word.clone(), @@ -402,14 +470,46 @@ fn highlight_sql_line<'a>( } else { current_word.push(c); } + i += 1; continue; } if in_string { current_word.push(c); + i += 1; continue; } + // Handle PostgreSQL operators: ::, ->, ->>, #>, #>>, @>, <@, ?|, ?&, || + if i + 1 < len { + let two_char: String = chars[i..i + 2].iter().collect(); + let is_pg_operator = matches!( + two_char.as_str(), + "::" | "->" | "#>" | "@>" | "<@" | "?|" | "?&" | "||" | "!=" | "<>" | ">=" | "<=" + ); + if is_pg_operator { + if !current_word.is_empty() { + spans.push(create_word_span(¤t_word, theme, base_style)); + current_word.clear(); + } + // Check for 3-char operators: ->>, #>> + if i + 2 < len { + let three_char: String = chars[i..i + 3].iter().collect(); + if matches!(three_char.as_str(), "->>" | "#>>") { + spans.push(Span::styled( + three_char, + base_style.fg(theme.syntax_operator), + )); + i += 3; + continue; + } + } + spans.push(Span::styled(two_char, base_style.fg(theme.syntax_operator))); + i += 2; + continue; + } + } + // Handle word boundaries if c.is_alphanumeric() || c == '_' { current_word.push(c); @@ -423,19 +523,24 @@ fn highlight_sql_line<'a>( let style = match c { '(' | ')' | '[' | ']' | '{' | '}' => base_style.fg(theme.text_primary), ',' | ';' => base_style.fg(theme.text_secondary), - '=' | '>' | '<' | '!' | '+' | '-' | '*' | '/' | '%' => { - base_style.fg(theme.syntax_operator) - } + '=' | '>' | '<' | '!' | '+' | '-' | '*' | '/' | '%' | '~' | '&' | '|' | '^' + | '#' | '@' | '?' => base_style.fg(theme.syntax_operator), + ':' => base_style.fg(theme.syntax_operator), + '.' => base_style.fg(theme.text_muted), _ => base_style.fg(theme.text_primary), }; spans.push(Span::styled(c.to_string(), style)); } + + i += 1; } // Handle remaining word if !current_word.is_empty() { let style = if in_string { Style::default().fg(theme.syntax_string) + } else if in_block_comment { + Style::default().fg(theme.syntax_comment) } else { Style::default() }; @@ -450,9 +555,11 @@ fn create_word_span<'a>(word: &str, theme: &Theme, base_style: Style) -> Span<'a base_style .fg(theme.syntax_keyword) .add_modifier(Modifier::BOLD) + } else if is_sql_function(word) { + base_style.fg(theme.syntax_function) } else if is_sql_type(word) { base_style.fg(theme.syntax_type) - } else if word.chars().all(|c| c.is_ascii_digit() || c == '.') { + } else if word.chars().all(|c| c.is_ascii_digit() || c == '.') && !word.is_empty() { base_style.fg(theme.syntax_number) } else { base_style.fg(theme.text_primary) diff --git a/src/ui/theme.rs b/src/ui/theme.rs index fb51dfe..b093d90 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -159,6 +159,7 @@ impl Theme { // SQL Keywords for syntax highlighting pub const SQL_KEYWORDS: &[&str] = &[ + // DML "SELECT", "FROM", "WHERE", @@ -170,13 +171,18 @@ pub const SQL_KEYWORDS: &[&str] = &[ "NULL", "LIKE", "ILIKE", + "SIMILAR", "BETWEEN", "EXISTS", + "ANY", + "SOME", + // CASE expressions "CASE", "WHEN", "THEN", "ELSE", "END", + // Joins "JOIN", "INNER", "LEFT", @@ -184,7 +190,11 @@ pub const SQL_KEYWORDS: &[&str] = &[ "FULL", "OUTER", "CROSS", + "NATURAL", + "LATERAL", "ON", + "USING", + // Ordering and grouping "GROUP", "BY", "HAVING", @@ -200,19 +210,37 @@ pub const SQL_KEYWORDS: &[&str] = &[ "NEXT", "ROWS", "ONLY", + "PERCENT", + "TIES", + // Modification "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "MERGE", + "UPSERT", + "RETURNING", + "ON CONFLICT", + "DO", + "NOTHING", + // DDL "CREATE", "ALTER", "DROP", "TRUNCATE", + "RENAME", + "REPLACE", "TABLE", "INDEX", "VIEW", + "MATERIALIZED", + "TEMPORARY", + "TEMP", + "UNLOGGED", + "CONCURRENTLY", + // Constraints "PRIMARY", "KEY", "FOREIGN", @@ -225,17 +253,33 @@ pub const SQL_KEYWORDS: &[&str] = &[ "RESTRICT", "NO", "ACTION", + "DEFERRABLE", + "INITIALLY", + "DEFERRED", + "IMMEDIATE", + // Privileges "GRANT", "REVOKE", "ALL", "PRIVILEGES", "TO", "PUBLIC", + "OWNER", + // Transactions "BEGIN", "COMMIT", "ROLLBACK", "TRANSACTION", "SAVEPOINT", + "RELEASE", + "ISOLATION", + "LEVEL", + "SERIALIZABLE", + "REPEATABLE", + "READ", + "COMMITTED", + "UNCOMMITTED", + // CTEs & set operations "WITH", "AS", "RECURSIVE", @@ -243,46 +287,372 @@ pub const SQL_KEYWORDS: &[&str] = &[ "INTERSECT", "EXCEPT", "DISTINCT", + // Grouping sets (OLAP) + "GROUPING", + "SETS", + "CUBE", + "ROLLUP", + // Window functions + "OVER", + "PARTITION", + "WINDOW", + "RANGE", + "UNBOUNDED", + "PRECEDING", + "FOLLOWING", + "CURRENT", + "ROW", + "EXCLUDE", + // Expressions + "CAST", + "EXTRACT", + "COALESCE", + "NULLIF", + "GREATEST", + "LEAST", + // Literals + "TRUE", + "FALSE", + // Schema objects + "SCHEMA", + "DATABASE", + "SEQUENCE", + "TRIGGER", + "FUNCTION", + "PROCEDURE", + "TYPE", + "DOMAIN", + "EXTENSION", + "RULE", + "POLICY", + "ROLE", + "USER", + "TABLESPACE", + "COMMENT", + // Control flow + "IF", + "ELSIF", + "LOOP", + "WHILE", + "FOR", + "FOREACH", + "EXIT", + "CONTINUE", + "RETURN", + "RAISE", + "EXCEPTION", + "PERFORM", + "EXECUTE", + "DECLARE", + "LANGUAGE", + "RETURNS", + "SETOF", + "VOLATILE", + "STABLE", + "IMMUTABLE", + "SECURITY", + "DEFINER", + "INVOKER", + // Explain & maintenance + "EXPLAIN", + "ANALYZE", + "VERBOSE", + "VACUUM", + "REINDEX", + "CLUSTER", + "REFRESH", + // Misc + "COPY", + "LISTEN", + "NOTIFY", + "UNLISTEN", + "LOCK", + "SHARE", + "EXCLUSIVE", + "ACCESS", + "NOWAIT", + "SKIP", + "LOCKED", + "INHERITS", + "INHERIT", + "NOINHERIT", + "OF", + "ONLY", + "SHOW", + "RESET", + "DISCARD", + "PREPARE", + "DEALLOCATE", +]; + +// SQL built-in functions (highlighted differently from keywords) +pub const SQL_FUNCTIONS: &[&str] = &[ + // Aggregate functions "COUNT", "SUM", "AVG", "MIN", "MAX", - "COALESCE", - "NULLIF", - "CAST", - "EXTRACT", - "DATE", - "TIME", - "TIMESTAMP", - "INTERVAL", - "TRUE", - "FALSE", - "RETURNING", - "OVER", - "PARTITION", - "WINDOW", + "ARRAY_AGG", + "STRING_AGG", + "BOOL_AND", + "BOOL_OR", + "BIT_AND", + "BIT_OR", + "EVERY", + "JSON_AGG", + "JSONB_AGG", + "JSON_OBJECT_AGG", + "JSONB_OBJECT_AGG", + "XMLAGG", + "PERCENTILE_CONT", + "PERCENTILE_DISC", + "MODE", + "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", + // Window functions "ROW_NUMBER", "RANK", "DENSE_RANK", + "NTILE", "LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE", - "SCHEMA", - "DATABASE", - "IF", - "EXPLAIN", - "ANALYZE", - "VERBOSE", + "NTH_VALUE", + "CUME_DIST", + "PERCENT_RANK", + // String functions + "LENGTH", + "UPPER", + "LOWER", + "TRIM", + "LTRIM", + "RTRIM", + "BTRIM", + "SUBSTRING", + "SUBSTR", + "POSITION", + "STRPOS", + "REPLACE", + "TRANSLATE", + "CONCAT", + "CONCAT_WS", + "REPEAT", + "REVERSE", + "LEFT", + "RIGHT", + "LPAD", + "RPAD", + "INITCAP", + "CHR", + "ASCII", + "MD5", + "ENCODE", + "DECODE", + "REGEXP_MATCH", + "REGEXP_MATCHES", + "REGEXP_REPLACE", + "REGEXP_SPLIT_TO_TABLE", + "REGEXP_SPLIT_TO_ARRAY", + "SPLIT_PART", + "FORMAT", + "QUOTE_IDENT", + "QUOTE_LITERAL", + "QUOTE_NULLABLE", + "TO_HEX", + "TO_ASCII", + // Numeric functions + "ABS", + "CEIL", + "CEILING", + "FLOOR", + "ROUND", + "TRUNC", + "MOD", + "POWER", + "SQRT", + "CBRT", + "EXP", + "LN", + "LOG", + "LOG10", + "SIGN", + "PI", + "RANDOM", + "SETSEED", + "GREATEST", + "LEAST", + "WIDTH_BUCKET", + "SCALE", + "DEGREES", + "RADIANS", + "SIN", + "COS", + "TAN", + "ASIN", + "ACOS", + "ATAN", + "ATAN2", + // Date/time functions + "NOW", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "LOCALTIME", + "LOCALTIMESTAMP", + "CLOCK_TIMESTAMP", + "STATEMENT_TIMESTAMP", + "TRANSACTION_TIMESTAMP", + "TIMEOFDAY", + "AGE", + "DATE_PART", + "DATE_TRUNC", + "EXTRACT", + "ISFINITE", + "MAKE_DATE", + "MAKE_TIME", + "MAKE_TIMESTAMP", + "MAKE_TIMESTAMPTZ", + "MAKE_INTERVAL", + "TO_TIMESTAMP", + "TO_DATE", + "TO_CHAR", + "TO_NUMBER", + "JUSTIFY_DAYS", + "JUSTIFY_HOURS", + "JUSTIFY_INTERVAL", + "GENERATE_SERIES", + // JSON/JSONB functions + "JSON_BUILD_OBJECT", + "JSONB_BUILD_OBJECT", + "JSON_BUILD_ARRAY", + "JSONB_BUILD_ARRAY", + "JSON_EXTRACT_PATH", + "JSONB_EXTRACT_PATH", + "JSON_EXTRACT_PATH_TEXT", + "JSONB_EXTRACT_PATH_TEXT", + "JSON_ARRAY_LENGTH", + "JSONB_ARRAY_LENGTH", + "JSON_EACH", + "JSONB_EACH", + "JSON_EACH_TEXT", + "JSONB_EACH_TEXT", + "JSON_OBJECT_KEYS", + "JSONB_OBJECT_KEYS", + "JSON_POPULATE_RECORD", + "JSONB_POPULATE_RECORD", + "JSON_TO_RECORD", + "JSONB_TO_RECORD", + "JSON_STRIP_NULLS", + "JSONB_STRIP_NULLS", + "JSONB_SET", + "JSONB_INSERT", + "JSONB_PRETTY", + "JSON_TYPEOF", + "JSONB_TYPEOF", + "JSONB_PATH_EXISTS", + "JSONB_PATH_MATCH", + "JSONB_PATH_QUERY", + "JSONB_PATH_QUERY_ARRAY", + "JSONB_PATH_QUERY_FIRST", + "ROW_TO_JSON", + "TO_JSON", + "TO_JSONB", + // Array functions + "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_TO_STRING", + "ARRAY_UPPER", + "CARDINALITY", + "STRING_TO_ARRAY", + "UNNEST", + // Conditional expressions + "COALESCE", + "NULLIF", + "GREATEST", + "LEAST", + // Type casting + "CAST", + // System info functions + "CURRENT_USER", + "CURRENT_SCHEMA", + "CURRENT_DATABASE", + "CURRENT_CATALOG", + "SESSION_USER", + "PG_TYPEOF", + "VERSION", + "HAS_TABLE_PRIVILEGE", + "HAS_SCHEMA_PRIVILEGE", + "HAS_DATABASE_PRIVILEGE", + // Full-text search + "TO_TSVECTOR", + "TO_TSQUERY", + "PLAINTO_TSQUERY", + "PHRASETO_TSQUERY", + "WEBSEARCH_TO_TSQUERY", + "TS_RANK", + "TS_RANK_CD", + "TS_HEADLINE", + "TSVECTOR_TO_ARRAY", + "SETWEIGHT", + // Sequence functions + "NEXTVAL", + "CURRVAL", + "SETVAL", + "LASTVAL", + // Misc + "GENERATE_SERIES", + "GENERATE_SUBSCRIPTS", + "PG_SLEEP", + "PG_NOTIFY", + "PG_CANCEL_BACKEND", + "PG_TERMINATE_BACKEND", + "PG_TABLE_SIZE", + "PG_TOTAL_RELATION_SIZE", + "PG_RELATION_SIZE", + "PG_SIZE_PRETTY", + "PG_COLUMN_SIZE", + "EXISTS", ]; pub const SQL_TYPES: &[&str] = &[ + // Numeric "INTEGER", "INT", + "INT2", + "INT4", + "INT8", "SMALLINT", "BIGINT", "SERIAL", + "SMALLSERIAL", "BIGSERIAL", "REAL", "DOUBLE", @@ -290,23 +660,40 @@ pub const SQL_TYPES: &[&str] = &[ "NUMERIC", "DECIMAL", "FLOAT", + "FLOAT4", + "FLOAT8", + // Text "VARCHAR", "CHAR", "TEXT", "CHARACTER", "VARYING", + "NAME", + "BPCHAR", + "CITEXT", + // Boolean "BOOLEAN", "BOOL", + // Date/time "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", + "TIMETZ", "INTERVAL", + // Binary + "BYTEA", + // UUID "UUID", + // JSON "JSON", "JSONB", - "BYTEA", + "JSONPATH", + // XML + "XML", + // Array "ARRAY", + // Geometric "POINT", "LINE", "LSEG", @@ -314,13 +701,58 @@ pub const SQL_TYPES: &[&str] = &[ "PATH", "POLYGON", "CIRCLE", + // Network "CIDR", "INET", "MACADDR", + "MACADDR8", + // Bit string "BIT", "VARBIT", - "XML", + // Money "MONEY", + // Full-text search + "TSVECTOR", + "TSQUERY", + // Range types + "INT4RANGE", + "INT8RANGE", + "NUMRANGE", + "TSRANGE", + "TSTZRANGE", + "DATERANGE", + "INT4MULTIRANGE", + "INT8MULTIRANGE", + "NUMMULTIRANGE", + "TSMULTIRANGE", + "TSTZMULTIRANGE", + "DATEMULTIRANGE", + // OID types + "OID", + "REGCLASS", + "REGTYPE", + "REGPROC", + "REGPROCEDURE", + "REGOPER", + "REGOPERATOR", + "REGNAMESPACE", + "REGROLE", + "REGCONFIG", + "REGDICTIONARY", + // Pseudo-types + "VOID", + "RECORD", + "TRIGGER", + "EVENT_TRIGGER", + "ANYELEMENT", + "ANYARRAY", + "ANYNONARRAY", + "ANYENUM", + "ANYRANGE", + "ANYMULTIRANGE", + "ANYCOMPATIBLE", + "CSTRING", + "INTERNAL", ]; pub fn is_sql_keyword(word: &str) -> bool { @@ -330,3 +762,7 @@ pub fn is_sql_keyword(word: &str) -> bool { pub fn is_sql_type(word: &str) -> bool { SQL_TYPES.contains(&word.to_uppercase().as_str()) } + +pub fn is_sql_function(word: &str) -> bool { + SQL_FUNCTIONS.contains(&word.to_uppercase().as_str()) +}