diff --git a/NEWS.md b/NEWS.md index 3585168ca..f4fd40c46 100644 --- a/NEWS.md +++ b/NEWS.md @@ -2,6 +2,8 @@ ## Improvements and bug fixes +* `input_code_editor()` now supports `language = "ggsql"` for syntax highlighting of ggsql queries. (#1291) + * Fixed `toolbar_input_button()` alignment and spacing issues. (#1290) * The brand.yml example app (`shiny::runExample("brand.yml", package = "bslib")`) now uses `brand_pluck()` and `brand_has()` from `{brand.yml}`. (#1288) diff --git a/R/sysdata.rda b/R/sysdata.rda index 70283fe7c..f0028de22 100644 Binary files a/R/sysdata.rda and b/R/sysdata.rda differ diff --git a/R/versions.R b/R/versions.R index dda38decc..12c707625 100644 --- a/R/versions.R +++ b/R/versions.R @@ -4,4 +4,4 @@ version_bs4 <- "4.6.0" version_bs3 <- "3.4.1" version_accessibility <- "1.0.6" version_prism_code_editor <- "4.2.0" -code_editor_bundled_languages <- c("r", "python", "julia", "sql", "javascript", "typescript", "markup", "css", "scss", "sass", "json", "markdown", "yaml", "xml", "toml", "ini", "bash", "docker", "latex", "cpp", "rust", "diff") +code_editor_bundled_languages <- c("r", "python", "julia", "sql", "ggsql", "javascript", "typescript", "markup", "css", "scss", "sass", "json", "markdown", "yaml", "xml", "toml", "ini", "bash", "docker", "latex", "cpp", "rust", "diff") diff --git a/inst/lib/prism-code-editor/prism/languages/ggsql.js b/inst/lib/prism-code-editor/prism/languages/ggsql.js new file mode 100644 index 000000000..6e222da06 --- /dev/null +++ b/inst/lib/prism-code-editor/prism/languages/ggsql.js @@ -0,0 +1,79 @@ +// ggsql.js — ggsql language for prism-code-editor +// +// AUTO-GENERATED by tools/build_ggsql_grammar.R from the TextMate grammar at: +// https://github.com/posit-dev/ggsql/blob/main/ggsql-vscode/syntaxes/ggsql.tmLanguage.json +// Do not edit by hand. +// +// NOTE: The import path below is a content-hashed internal module of +// prism-code-editor. If bslib updates prism-code-editor, this hash may change. +// Re-run this script to regenerate. + +import "./sql.js"; +import { l as languages } from "../../index-C1_GGQ8y.js"; + +var sql = languages.sql; +var ggsql = {}; + +// Copy SQL tokens +Object.keys(sql).forEach(function(k) { ggsql[k] = sql[k]; }); + +// ggsql clause keywords +ggsql["ggsql-keyword"] = { + pattern: /\b(?:VISUALISE|VISUALIZE|DRAW|SCALE|FACET|PROJECT|LABEL|THEME|MAPPING|REMAPPING|SETTING|FILTER|FROM|ORDER|BY|PARTITION|RENAMING|AS|TO|VIA)\b/i, + alias: "keyword", +}; + +// Geom types +ggsql["ggsql-geom"] = { + pattern: /\b(?:point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|linear|errorbar)\b/, + alias: "builtin", +}; + +// Scale type modifiers +ggsql["ggsql-scale-type"] = { + pattern: /\b(?:CONTINUOUS|DISCRETE|BINNED|ORDINAL|IDENTITY)\b/i, + alias: "builtin", +}; + +// Aesthetic names +ggsql["ggsql-aesthetic"] = { + pattern: /\b(?:x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|family|fontface|hjust|vjust|panel|row|column)\b/, + alias: "attr-name", +}; + +// Theme names +ggsql["ggsql-theme"] = { + pattern: /\b(?:minimal|classic|gray|grey|bw|dark|light|void)\b/, + alias: "class-name", +}; + +// Project types +ggsql["ggsql-project"] = { + pattern: /\b(?:cartesian|polar|flip|fixed|trans|map|quickmap)\b/, + alias: "class-name", +}; + +// Fat arrow operator +ggsql["ggsql-arrow"] = { + pattern: /=>/, + alias: "operator", +}; + +// SQL functions (aggregate, window, datetime, string, math, conversion, conditional, JSON, list) +ggsql["function"] = /\b(?:count|sum|avg|min|max|stddev|variance|array_agg|string_agg|group_concat|row_number|rank|dense_rank|ntile|lag|lead|first_value|last_value|nth_value|cume_dist|percent_rank|date_trunc|date_part|datepart|datename|dateadd|datediff|extract|now|current_date|current_time|current_timestamp|getdate|getutcdate|strftime|strptime|make_date|make_time|make_timestamp|concat|substring|substr|left|right|length|len|char_length|lower|upper|trim|ltrim|rtrim|replace|reverse|repeat|lpad|rpad|split_part|string_split|format|printf|regexp_replace|regexp_extract|regexp_matches|abs|ceil|ceiling|floor|round|trunc|truncate|mod|power|sqrt|exp|ln|log|log10|log2|sign|sin|cos|tan|asin|acos|atan|atan2|pi|degrees|radians|random|rand|cast|convert|coalesce|nullif|ifnull|isnull|nvl|try_cast|typeof|if|iff|iif|greatest|least|decode|json|json_extract|json_extract_path|json_extract_string|json_value|json_query|json_object|json_array|json_array_length|to_json|from_json|list|list_value|list_aggregate|array_length|unnest|generate_series|range)(?=\s*\()/i; + +// Reorder: ggsql tokens before generic SQL keyword/boolean +var ordered = {}; +["comment", "variable", "string", "identifier"].forEach(function(k) { + if (k in ggsql) ordered[k] = ggsql[k]; +}); +["ggsql-keyword", "ggsql-geom", "ggsql-scale-type", "ggsql-aesthetic", + "ggsql-theme", "ggsql-project", "ggsql-arrow"].forEach(function(k) { + if (k in ggsql) ordered[k] = ggsql[k]; +}); +Object.keys(ggsql).forEach(function(k) { + if (!(k in ordered)) ordered[k] = ggsql[k]; +}); + +languages.ggsql = ordered; + diff --git a/tests/testthat/_snaps/input-code-editor.md b/tests/testthat/_snaps/input-code-editor.md index 7ff48315f..680ce96dc 100644 --- a/tests/testthat/_snaps/input-code-editor.md +++ b/tests/testthat/_snaps/input-code-editor.md @@ -12,7 +12,7 @@ input_code_editor("test", language = "fortran") Condition Error in `input_code_editor()`: - ! `language` must be one of "r", "python", "julia", "sql", "javascript", "typescript", "markup", "css", "scss", "sass", "json", "markdown", "yaml", "xml", "toml", "ini", "bash", "docker", "latex", "cpp", "rust", "diff", "md", "html", "plain", "plaintext", "text", or "txt", not "fortran". + ! `language` must be one of "r", "python", "julia", "sql", "ggsql", "javascript", "typescript", "markup", "css", "scss", "sass", "json", "markdown", "yaml", "xml", "toml", "ini", "bash", "docker", "latex", "cpp", "rust", "diff", "md", "html", "plain", "plaintext", "text", or "txt", not "fortran". # input_code_editor validates theme names @@ -33,7 +33,7 @@ input_code_editor("test", language = "fortran") Condition Error in `input_code_editor()`: - ! `language` must be one of "r", "python", "julia", "sql", "javascript", "typescript", "markup", "css", "scss", "sass", "json", "markdown", "yaml", "xml", "toml", "ini", "bash", "docker", "latex", "cpp", "rust", "diff", "md", "html", "plain", "plaintext", "text", or "txt", not "fortran". + ! `language` must be one of "r", "python", "julia", "sql", "ggsql", "javascript", "typescript", "markup", "css", "scss", "sass", "json", "markdown", "yaml", "xml", "toml", "ini", "bash", "docker", "latex", "cpp", "rust", "diff", "md", "html", "plain", "plaintext", "text", or "txt", not "fortran". # update_code_editor validates inputs @@ -41,7 +41,7 @@ update_code_editor("test", language = "fortran", session = NULL) Condition Error in `update_code_editor()`: - ! `language` must be one of "r", "python", "julia", "sql", "javascript", "typescript", "markup", "css", "scss", "sass", "json", "markdown", "yaml", "xml", "toml", "ini", "bash", "docker", "latex", "cpp", "rust", "diff", "md", "html", "plain", "plaintext", "text", or "txt", not "fortran". + ! `language` must be one of "r", "python", "julia", "sql", "ggsql", "javascript", "typescript", "markup", "css", "scss", "sass", "json", "markdown", "yaml", "xml", "toml", "ini", "bash", "docker", "latex", "cpp", "rust", "diff", "md", "html", "plain", "plaintext", "text", or "txt", not "fortran". --- diff --git a/tools/build_ggsql_grammar.R b/tools/build_ggsql_grammar.R new file mode 100644 index 000000000..983bdffc3 --- /dev/null +++ b/tools/build_ggsql_grammar.R @@ -0,0 +1,258 @@ +#!/usr/bin/env Rscript +# +# Build the ggsql Prism grammar file from the TextMate grammar source. +# +# Usage: +# Rscript tools/build_ggsql_grammar.R +# +# This fetches the ggsql TextMate grammar from posit-dev/ggsql on GitHub, +# extracts token patterns, and generates a Prism-compatible grammar file +# that extends the built-in SQL grammar. +# +# The output is written to: +# inst/lib/prism-code-editor/prism/languages/ggsql.js + +TMGRAMMAR_URL <- "https://raw.githubusercontent.com/posit-dev/ggsql/main/ggsql-vscode/syntaxes/ggsql.tmLanguage.json" + +# Paths relative to the bslib package root +LANGUAGES_DIR <- "inst/lib/prism-code-editor/prism/languages" +SQL_JS <- file.path(LANGUAGES_DIR, "sql.js") +OUTPUT_JS <- file.path(LANGUAGES_DIR, "ggsql.js") + +# ---- Helpers ---- + +# Extract word alternatives from a TextMate match pattern like +# "\\b(word1|word2|word3)\\b" or "\\b(?i:word1|word2)\\b" +extract_words <- function(match_pattern) { + # Try non-capture group with flags first: \b(?i:word1|word2)\b + m <- regmatches( + match_pattern, + regexec("\\\\b\\(\\?[a-z]*:([^)]+)\\)\\\\b", match_pattern) + )[[1]] + if (length(m) == 2) { + return(strsplit(m[2], "\\|")[[1]]) + } + + # Try capture group: \b(word1|word2)\b + m <- regmatches( + match_pattern, + regexec("\\\\b\\(([^)]+)\\)\\\\b", match_pattern) + )[[1]] + if (length(m) == 2) { + return(strsplit(m[2], "\\|")[[1]]) + } + + NULL +} + +# Extract word alternatives from a function pattern like +# "(?i)\\b(func1|func2)\\b\\s*\\(" +extract_function_words <- function(match_pattern) { + m <- regmatches( + match_pattern, + regexec("\\\\b\\(([^)]+)\\)\\\\b\\\\s\\*\\\\\\(", match_pattern) + )[[1]] + if (length(m) == 2) { + return(strsplit(m[2], "\\|")[[1]]) + } + NULL +} + +# Format a word list as a JS regex alternation: /\b(?:word1|word2)\b/flags +format_word_regex <- function(words, flags = "") { + alts <- paste(words, collapse = "|") + sprintf("/\\b(?:%s)\\b/%s", alts, flags) +} + +# Format function words with lookahead: /\b(?:func1|func2)(?=\s*\()/flags +format_function_regex <- function(words, flags = "i") { + alts <- paste(words, collapse = "|") + sprintf("/\\b(?:%s)(?=\\s*\\()/%s", alts, flags) +} + +# Extract the content-hashed import path from sql.js +extract_import_path <- function(sql_js_path) { + lines <- readLines(sql_js_path, n = 1) + m <- regmatches(lines, regexec('"([^"]+index-[^"]+\\.js)"', lines))[[1]] + if (length(m) < 2) { + stop("Could not extract import path from ", sql_js_path) + } + m[2] +} + +# ---- Main ---- + +main <- function() { + if (!file.exists(SQL_JS)) { + stop("sql.js not found at ", SQL_JS, ". Run from the bslib package root.") + } + + message("Fetching TextMate grammar from: ", TMGRAMMAR_URL) + grammar <- jsonlite::fromJSON(TMGRAMMAR_URL, simplifyDataFrame = FALSE) + repo <- grammar$repository + + import_path <- extract_import_path(SQL_JS) + message("Using import path: ", import_path) + + # -- Extract ggsql clause keywords -- + # Collect from: begin captures of clause patterns + keyword.other matches + clause_keywords <- character(0) + + # Clause names come from the `begin` regex of each *-clause pattern + clause_names <- grep("-clause$", names(repo), value = TRUE) + for (clause_name in clause_names) { + clause <- repo[[clause_name]] + if (!is.null(clause$begin)) { + words <- extract_words(clause$begin) + if (!is.null(words)) clause_keywords <- c(clause_keywords, words) + } + } + + # Also collect keyword.other.ggsql from all clause patterns and common-clause-patterns + keyword_sources <- c("common-clause-patterns", clause_names) + for (src in keyword_sources) { + for (p in repo[[src]]$patterns) { + if (identical(p$name, "keyword.other.ggsql")) { + words <- extract_words(p$match) + if (!is.null(words)) clause_keywords <- c(clause_keywords, words) + } + } + } + clause_keywords <- unique(clause_keywords) + + # -- Extract geom types -- + geoms <- NULL + for (p in repo[["draw-clause"]]$patterns) { + if (identical(p$name, "support.type.geom.ggsql")) { + geoms <- extract_words(p$match) + } + } + + # -- Extract scale type modifiers -- + scale_types <- NULL + for (p in repo[["scale-clause"]]$patterns) { + if (identical(p$name, "keyword.control.scale-modifier.ggsql")) { + scale_types <- extract_words(p$match) + } + } + + # -- Extract aesthetic names -- + aesthetics <- NULL + for (p in repo[["aesthetics"]]$patterns) { + if (identical(p$name, "support.type.aesthetic.ggsql")) { + aesthetics <- extract_words(p$match) + } + } + + # -- Extract theme names -- + themes <- NULL + for (p in repo[["theme-clause"]]$patterns) { + if (identical(p$name, "support.type.theme.ggsql")) { + themes <- extract_words(p$match) + } + } + + # -- Extract project types -- + projects <- NULL + for (p in repo[["project-clause"]]$patterns) { + if (identical(p$name, "support.type.project.ggsql")) { + projects <- extract_words(p$match) + } + } + + # -- Extract SQL function names -- + all_functions <- character(0) + for (p in repo[["sql-functions"]]$patterns) { + words <- extract_function_words(p$match) + if (!is.null(words)) all_functions <- c(all_functions, words) + } + all_functions <- unique(all_functions) + + # -- Generate the JS file -- + js <- c( + "// ggsql.js — ggsql language for prism-code-editor", + "//", + "// AUTO-GENERATED by tools/build_ggsql_grammar.R from the TextMate grammar at:", + "// https://github.com/posit-dev/ggsql/blob/main/ggsql-vscode/syntaxes/ggsql.tmLanguage.json", + "// Do not edit by hand.", + "//", + "// NOTE: The import path below is a content-hashed internal module of", + "// prism-code-editor. If bslib updates prism-code-editor, this hash may change.", + "// Re-run this script to regenerate.", + "", + 'import "./sql.js";', + sprintf('import { l as languages } from "%s";', import_path), + "", + "var sql = languages.sql;", + "var ggsql = {};", + "", + "// Copy SQL tokens", + 'Object.keys(sql).forEach(function(k) { ggsql[k] = sql[k]; });', + "", + "// ggsql clause keywords", + sprintf('ggsql["ggsql-keyword"] = {'), + sprintf(" pattern: %s,", format_word_regex(clause_keywords, "i")), + ' alias: "keyword",', + "};", + "", + "// Geom types", + sprintf('ggsql["ggsql-geom"] = {'), + sprintf(" pattern: %s,", format_word_regex(geoms)), + ' alias: "builtin",', + "};", + "", + "// Scale type modifiers", + sprintf('ggsql["ggsql-scale-type"] = {'), + sprintf(" pattern: %s,", format_word_regex(scale_types, "i")), + ' alias: "builtin",', + "};", + "", + "// Aesthetic names", + sprintf('ggsql["ggsql-aesthetic"] = {'), + sprintf(" pattern: %s,", format_word_regex(aesthetics)), + ' alias: "attr-name",', + "};", + "", + "// Theme names", + sprintf('ggsql["ggsql-theme"] = {'), + sprintf(" pattern: %s,", format_word_regex(themes)), + ' alias: "class-name",', + "};", + "", + "// Project types", + sprintf('ggsql["ggsql-project"] = {'), + sprintf(" pattern: %s,", format_word_regex(projects)), + ' alias: "class-name",', + "};", + "", + "// Fat arrow operator", + 'ggsql["ggsql-arrow"] = {', + " pattern: /=>/,", + ' alias: "operator",', + "};", + "", + "// SQL functions (aggregate, window, datetime, string, math, conversion, conditional, JSON, list)", + sprintf('ggsql["function"] = %s;', format_function_regex(all_functions)), + "", + "// Reorder: ggsql tokens before generic SQL keyword/boolean", + "var ordered = {};", + '["comment", "variable", "string", "identifier"].forEach(function(k) {', + " if (k in ggsql) ordered[k] = ggsql[k];", + "});", + '["ggsql-keyword", "ggsql-geom", "ggsql-scale-type", "ggsql-aesthetic",', + ' "ggsql-theme", "ggsql-project", "ggsql-arrow"].forEach(function(k) {', + " if (k in ggsql) ordered[k] = ggsql[k];", + "});", + "Object.keys(ggsql).forEach(function(k) {", + " if (!(k in ordered)) ordered[k] = ggsql[k];", + "});", + "", + "languages.ggsql = ordered;", + "" + ) + + writeLines(js, OUTPUT_JS) + message("Generated: ", OUTPUT_JS) +} + +main() diff --git a/tools/main.R b/tools/main.R index ed685ff1f..d158e4c77 100644 --- a/tools/main.R +++ b/tools/main.R @@ -10,7 +10,8 @@ lapply( "download_preset_fonts.R", "update_gfont_info.R", "expand_variables_article_template.R", - "compile_component_sass.R" + "compile_component_sass.R", + "build_ggsql_grammar.R" ), function(file) { message("Updating: ", file) diff --git a/tools/yarn_install.R b/tools/yarn_install.R index da6c8226d..1bae79e1f 100755 --- a/tools/yarn_install.R +++ b/tools/yarn_install.R @@ -340,6 +340,7 @@ with_dir("inst/lib", { "python", "julia", "sql", + "ggsql", # Web/Frontend "javascript", "typescript",