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}
+ '';
+}