diff --git a/.github/labeler.yml b/.github/labeler.yml index 96b9269290f8..ce44b4e07db7 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -339,6 +339,7 @@ - modules/programs/emacs.nix - modules/programs/formiko.nix - modules/programs/fresh-editor.nix + - modules/programs/gram.nix - modules/programs/helix.nix - modules/programs/kakoune.nix - modules/programs/lapce.nix diff --git a/modules/misc/news/2026/07/2026-07-29_03-00-05.nix b/modules/misc/news/2026/07/2026-07-29_03-00-05.nix new file mode 100644 index 000000000000..31da5ca8dca4 --- /dev/null +++ b/modules/misc/news/2026/07/2026-07-29_03-00-05.nix @@ -0,0 +1,12 @@ +{ + time = "2026-07-29T02:00:05+00:00"; + condition = true; + message = '' + A new module is available: `programs.gram`. + + Gram is a powerful and modern source code editor. It features solid performance + and is highly configurable, yet comes with batteries included out of the box. + + Find out more here: + ''; +} diff --git a/modules/programs/gram.nix b/modules/programs/gram.nix new file mode 100644 index 000000000000..b43d8afc11f6 --- /dev/null +++ b/modules/programs/gram.nix @@ -0,0 +1,326 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) + escapeShellArgs + filterAttrs + getName + getVersion + hasAttr + hasSuffix + isString + literalExpression + maintainers + mapAttrs' + mkEnableOption + mkIf + mkOption + mkPackageOption + nameValuePair + optional + types + ; + inherit (pkgs) + formats + runCommand + stdenv + symlinkJoin + ; + + filterNullAttrs = filterAttrs (_: value: value != null); + + jsonFormat = formats.json { }; + + cfg = config.programs.gram; + + dataDir = + if stdenv.isDarwin then "Library/Application Support/Gram" else (config.xdg.dataHome + "/gram"); +in + +{ + meta.maintainers = with maintainers; [ mikaeladev ]; + + options.programs.gram = { + enable = mkEnableOption "Gram"; + + # packages # + + package = mkPackageOption pkgs "gram" { nullable = true; }; + + extraPackages = mkOption { + type = with types; listOf package; + default = [ ]; + example = literalExpression "with pkgs; [ nil ]"; + description = '' + List of extra packages available to Gram. + ''; + }; + + extensionPackages = mkOption { + type = with types; listOf package; + default = [ ]; + example = literalExpression "with gram-extensions; [ catppuccin ]"; + description = '' + List of extension packages to install. + + See also: + - + - + ''; + }; + + # single-file configs # + + settings = mkOption { + type = with types; either (attrsOf jsonFormat.type) lines; + default = { }; + example = { + buffer_font_family = "JetBrains Mono"; + buffer_font_weight = 400; + buffer_font_size = 14; + }; + description = '' + Set of [settings] to write to {file}`settings.jsonc`. + + [settings]: https://gram-editor.com/docs/configuring-gram/ + ''; + }; + + debugger = mkOption { + type = with types; either (listOf jsonFormat.type) lines; + default = [ ]; + example = [ + { + label = "Example Start debugger config"; + adapter = "Example adapter name"; + request = "launch"; + program = "path_to_program"; + cwd = "$GRAM_WORKTREE_ROOT"; + } + ]; + description = '' + List of [debug tasks] to write to {file}`debug.jsonc`. + + [debug tasks]: https://gram-editor.com/docs/debugger/ + ''; + }; + + keymaps = mkOption { + type = with types; either (listOf jsonFormat.type) lines; + default = [ ]; + example = [ + { + bindings = { + ctrl-right = "editor::SelectLargerSyntaxNode"; + ctrl-left = "editor::SelectSmallerSyntaxNode"; + }; + } + { + context = "ProjectPanel && not_editing"; + bindings.o = "project_panel::Open"; + } + ]; + description = '' + List of [key bindings] to write to {file}`keymap.jsonc`. + + [key bindings]: https://gram-editor.com/docs/key-bindings/ + ''; + }; + + tasks = mkOption { + type = with types; either (listOf jsonFormat.type) lines; + default = [ ]; + example = [ + { + label = "Example task"; + command = ''for i in {1..5}; do echo "Hello $i/5"; sleep 1; done''; + env.foo = "bar"; + use_new_terminal = false; + allow_concurrent_runs = false; + reveal = "always"; + hide = "never"; + shell = "system"; + show_summary = true; + show_command = true; + save = "none"; + } + ]; + description = '' + List of [tasks] to write to {file}`tasks.jsonc`. + + [tasks]: https://gram-editor.com/docs/tasks/ + ''; + }; + + # multi-file configs # + + snippets = mkOption { + type = with types; attrsOf (either (attrsOf jsonFormat.type) lines); + default = { }; + example = { + html = { + "Define doctype" = { + prefix = "doctype"; + description = "Defines the document type"; + body = [ + "" + "$1" + ]; + }; + }; + javascript = { + "Log to console" = { + prefix = "log"; + description = "Logs to console"; + body = [ + ''console.info("Hello, ''${1:World}!")'' + "$0" + ]; + }; + }; + }; + description = '' + Set of [snippets] to write to {file}`snippets/‹name›.json`. + + [snippets]: https://gram-editor.com/docs/snippets/ + ''; + }; + + themes = mkOption { + type = with types; attrsOf (either (attrsOf jsonFormat.type) lines); + default = { }; + example = literalExpression '' + { + my-cool-theme = { + name = "My Cool Theme"; + author = "You!"; + themes = [ + { + name = "My Cool Dark Theme"; + appearance = "dark"; + style = { + "editor.background" = "#000"; + # ... + }; + } + { + name = "My Cool Light Theme"; + appearance = "light"; + style = { + "editor.background" = "#fff"; + # ... + }; + } + ]; + }; + } + ''; + description = '' + Set of [local themes] to write to {file}`themes/‹name›.json`. + + [local themes]: https://gram-editor.com/docs/themes/ + ''; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.extraPackages != [ ] -> cfg.package != null; + message = '' + The `programs.gram.extraPackages` option requires that `programs.gram.package` + not be null. + ''; + } + ] + ++ ( + let + mkSnippetException = scope: substitute: { + assertion = !((hasAttr scope cfg.snippets) || (hasAttr (scope + ".json") cfg.snippets)); + message = '' + The snippet scope defined at `programs.gram.snippets.${scope}` is incorrect, + use `programs.gram.snippets.${substitute}` instead. + + (reasoning: ) + ''; + }; + in + [ + (mkSnippetException "global" "snippets") + (mkSnippetException "jsx" "javascript") + (mkSnippetException "plain" "plaintext") + ] + ); + + home.packages = optional (cfg.package != null) ( + if cfg.extraPackages == [ ] then + cfg.package + else + (symlinkJoin { + pname = "${getName cfg.package}-wrapped"; + version = getVersion cfg.package; + paths = [ cfg.package ]; + preferLocalBuild = true; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + wrapProgram $out/bin/${cfg.package.meta.mainProgram or "gram"} \ + --suffix PATH : ${lib.makeBinPath cfg.extraPackages} + ''; + }) + ); + + xdg.configFile = + let + withSuffix = suffix: value: if (hasSuffix suffix value) then value else (value + suffix); + + mkFileValue = + name: value: + if isString value then { text = value; } else { source = jsonFormat.generate name value; }; + + mapConfigAttrs = + prefix: suffix: + mapAttrs' ( + name: value: + let + suffixedName = withSuffix suffix name; + in + nameValuePair (prefix + suffixedName) (mkFileValue suffixedName value) + ); + + singleFileConfigs = filterNullAttrs { + settings = if (cfg.settings != { }) then cfg.settings else null; + debug = if (cfg.debugger != [ ]) then cfg.debugger else null; + keymap = if (cfg.keymaps != [ ]) then cfg.keymaps else null; + tasks = if (cfg.tasks != [ ]) then cfg.tasks else null; + }; + in + (mapConfigAttrs "gram/" ".jsonc" singleFileConfigs) + // (mapConfigAttrs "gram/snippets/" ".json" cfg.snippets) + // (mapConfigAttrs "gram/themes/" ".json" cfg.themes); + + home.file."${dataDir}/extensions/installed" = mkIf (cfg.extensionPackages != [ ]) { + recursive = true; + source = runCommand "gram-extensions" { } '' + set -euo pipefail + + drvs=(${escapeShellArgs cfg.extensionPackages}) + paths=(/share/gram/extensions /share/zed/extensions) + + mkdir $out/ + + for drv in "''${drvs[@]}"; do + for path in "''${paths[@]}"; do + if [ -e "$drv/$path" ]; then + ln -s "$drv/$path"/* $out/ + fi + done + done + ''; + }; + }; +} diff --git a/tests/darwinScrublist.nix b/tests/darwinScrublist.nix index 7d03908dcdb6..ad3c64f10bf0 100644 --- a/tests/darwinScrublist.nix +++ b/tests/darwinScrublist.nix @@ -79,6 +79,7 @@ let "google-chrome-beta" "google-chrome-dev" "gradle" + "gram" "granted" "gurk-rs" "halloy" diff --git a/tests/modules/programs/gram/assert-packages.nix b/tests/modules/programs/gram/assert-packages.nix new file mode 100644 index 000000000000..7498debb7828 --- /dev/null +++ b/tests/modules/programs/gram/assert-packages.nix @@ -0,0 +1,20 @@ +{ config, ... }: + +let + inherit (config.lib.test) mkStubPackage; +in + +{ + programs.gram = { + enable = true; + package = null; + extraPackages = [ (mkStubPackage { }) ]; + }; + + test.asserts.assertions.expected = [ + '' + The `programs.gram.extraPackages` option requires that `programs.gram.package` + not be null. + '' + ]; +} diff --git a/tests/modules/programs/gram/assert-snippets.nix b/tests/modules/programs/gram/assert-snippets.nix new file mode 100644 index 000000000000..22e12c40b538 --- /dev/null +++ b/tests/modules/programs/gram/assert-snippets.nix @@ -0,0 +1,26 @@ +{ + programs.gram = { + enable = true; + + snippets = { + global = { }; + jsx = { }; + plain = { }; + }; + }; + + test.asserts.assertions.expected = + let + mkExpectedAssertion = scope: substitute: '' + The snippet scope defined at `programs.gram.snippets.${scope}` is incorrect, + use `programs.gram.snippets.${substitute}` instead. + + (reasoning: ) + ''; + in + [ + (mkExpectedAssertion "global" "snippets") + (mkExpectedAssertion "jsx" "javascript") + (mkExpectedAssertion "plain" "plaintext") + ]; +} diff --git a/tests/modules/programs/gram/default.nix b/tests/modules/programs/gram/default.nix new file mode 100644 index 000000000000..776cb4b8cdad --- /dev/null +++ b/tests/modules/programs/gram/default.nix @@ -0,0 +1,9 @@ +{ + gram-assert-packages = ./assert-packages.nix; + gram-assert-snippets = ./assert-snippets.nix; + gram-empty-configs = ./empty-configs.nix; + gram-example-extensions = ./example-extensions.nix; + gram-example-settings = ./example-settings.nix; + gram-example-snippets = ./example-snippets.nix; + gram-example-themes = ./example-themes.nix; +} diff --git a/tests/modules/programs/gram/empty-configs.nix b/tests/modules/programs/gram/empty-configs.nix new file mode 100644 index 000000000000..dbb1c564c7c8 --- /dev/null +++ b/tests/modules/programs/gram/empty-configs.nix @@ -0,0 +1,18 @@ +{ pkgs, ... }: + +let + inherit (pkgs) stdenv; + + dataDir = + "home-files/" + + (if stdenv.isDarwin then "Library/Application Support/Gram" else ".local/share/gram"); +in + +{ + programs.gram.enable = true; + + nmt.script = '' + assertPathNotExists home-files/.config/gram + assertPathNotExists '${dataDir}' + ''; +} diff --git a/tests/modules/programs/gram/example-extensions.nix b/tests/modules/programs/gram/example-extensions.nix new file mode 100644 index 000000000000..5c7ae3e91d22 --- /dev/null +++ b/tests/modules/programs/gram/example-extensions.nix @@ -0,0 +1,26 @@ +{ pkgs, ... }: + +let + inherit (pkgs) stdenv writeTextDir; + + dataDir = + "home-files/" + + (if stdenv.isDarwin then "Library/Application Support/Gram" else ".local/share/gram"); +in + +{ + programs.gram = { + enable = true; + extensionPackages = [ + (writeTextDir "share/gram/extensions/foo/.keep" "") + (writeTextDir "share/zed/extensions/bar/.keep" "") + ]; + }; + + nmt.script = '' + extensionDir='${dataDir}/extensions/installed' + + assertDirectoryExists "$extensionDir"/foo + assertDirectoryExists "$extensionDir"/bar + ''; +} diff --git a/tests/modules/programs/gram/example-settings.nix b/tests/modules/programs/gram/example-settings.nix new file mode 100644 index 000000000000..95c673a2cf8e --- /dev/null +++ b/tests/modules/programs/gram/example-settings.nix @@ -0,0 +1,79 @@ +{ config, pkgs, ... }: + +let + inherit (pkgs) formats; + + jsonFormat = formats.json { }; + + cfg = config.programs.gram; + + expectedSettings = jsonFormat.generate "settings.jsonc" cfg.settings; + expectedDebugger = jsonFormat.generate "debug.jsonc" cfg.debugger; + expectedKeymaps = jsonFormat.generate "keymap.jsonc" cfg.keymaps; + expectedTasks = jsonFormat.generate "tasks.jsonc" cfg.tasks; +in + +{ + programs.gram = { + enable = true; + + settings = { + buffer_font_family = "JetBrains Mono"; + buffer_font_weight = 400; + buffer_font_size = 14; + }; + + debugger = [ + { + label = "Example Start debugger config"; + adapter = "Example adapter name"; + request = "launch"; + program = "path_to_program"; + cwd = "$GRAM_WORKTREE_ROOT"; + } + ]; + + keymaps = [ + { + bindings = { + ctrl-right = "editor::SelectLargerSyntaxNode"; + ctrl-left = "editor::SelectSmallerSyntaxNode"; + }; + } + { + context = "ProjectPanel && not_editing"; + bindings.o = "project_panel::Open"; + } + ]; + + tasks = [ + { + label = "Example task"; + command = ''for i in {1..5}; do echo "Hello $i/5"; sleep 1; done''; + env.foo = "bar"; + use_new_terminal = false; + allow_concurrent_runs = false; + reveal = "always"; + hide = "never"; + shell = "system"; + show_summary = true; + show_command = true; + save = "none"; + } + ]; + }; + + nmt.script = '' + assertFileExists home-files/.config/gram/settings.jsonc + assertFileContent home-files/.config/gram/settings.jsonc ${expectedSettings} + + assertFileExists home-files/.config/gram/debug.jsonc + assertFileContent home-files/.config/gram/debug.jsonc ${expectedDebugger} + + assertFileExists home-files/.config/gram/keymap.jsonc + assertFileContent home-files/.config/gram/keymap.jsonc ${expectedKeymaps} + + assertFileExists home-files/.config/gram/tasks.jsonc + assertFileContent home-files/.config/gram/tasks.jsonc ${expectedTasks} + ''; +} diff --git a/tests/modules/programs/gram/example-snippets.nix b/tests/modules/programs/gram/example-snippets.nix new file mode 100644 index 000000000000..11cf8cc28e9d --- /dev/null +++ b/tests/modules/programs/gram/example-snippets.nix @@ -0,0 +1,58 @@ +{ config, pkgs, ... }: + +let + inherit (pkgs) formats writeText; + + jsonFormat = formats.json { }; + + cfg = config.programs.gram; + + expectedHtml = jsonFormat.generate "html.json" cfg.snippets.html; + expectedJavascript = jsonFormat.generate "javascript.json" cfg.snippets.javascript; + expectedFoobar = writeText "foobar.json" cfg.snippets."foobar.json"; +in + +{ + programs.gram = { + enable = true; + + snippets = { + html = { + "Define doctype" = { + prefix = "doctype"; + description = "Defines the document type"; + body = [ + "" + "$1" + ]; + }; + }; + javascript = { + "Log to console" = { + prefix = "log"; + description = "Logs to console"; + body = [ + ''console.info("Hello, ''${1:World}!")'' + "$0" + ]; + }; + }; + "foobar.json" = '' + This should be written as-is. + ''; + }; + }; + + nmt.script = '' + snippetsDir=home-files/.config/gram/snippets + + assertFileExists $snippetsDir/html.json + assertFileContent $snippetsDir/html.json ${expectedHtml} + + assertFileExists $snippetsDir/javascript.json + assertFileContent $snippetsDir/javascript.json ${expectedJavascript} + + assertFileExists $snippetsDir/foobar.json + assertFileContent $snippetsDir/foobar.json ${expectedFoobar} + ''; +} diff --git a/tests/modules/programs/gram/example-themes.nix b/tests/modules/programs/gram/example-themes.nix new file mode 100644 index 000000000000..7f6df4251d44 --- /dev/null +++ b/tests/modules/programs/gram/example-themes.nix @@ -0,0 +1,50 @@ +{ config, pkgs, ... }: + +let + inherit (pkgs) formats writeText; + + jsonFormat = formats.json { }; + + cfg = config.programs.gram; + + expectedTheme = jsonFormat.generate "my-cool-theme.json" cfg.themes.my-cool-theme; + expectedFoobar = writeText "foobar.json" cfg.themes."foobar.json"; +in + +{ + programs.gram = { + enable = true; + + themes = { + my-cool-theme = { + name = "My Cool Theme"; + author = "You!"; + themes = [ + { + name = "My Cool Dark Theme"; + appearance = "dark"; + style."editor.background" = "#000"; + } + { + name = "My Cool Light Theme"; + appearance = "light"; + style."editor.background" = "#fff"; + } + ]; + }; + "foobar.json" = '' + This should be written as-is. + ''; + }; + }; + + nmt.script = '' + themesDir=home-files/.config/gram/themes + + assertFileExists $themesDir/my-cool-theme.json + assertFileContent $themesDir/my-cool-theme.json ${expectedTheme} + + assertFileExists $themesDir/foobar.json + assertFileContent $themesDir/foobar.json ${expectedFoobar} + ''; +}