From adfc9232ac257ef111124aefd79c090c10261411 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 09:53:03 +0200 Subject: [PATCH 1/8] feat(platform): add WriteUserFile atomic user-space writer --- internal/platform/writefile.go | 25 +++++++++++++ internal/platform/writefile_test.go | 54 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 internal/platform/writefile.go create mode 100644 internal/platform/writefile_test.go diff --git a/internal/platform/writefile.go b/internal/platform/writefile.go new file mode 100644 index 0000000..a1c66ac --- /dev/null +++ b/internal/platform/writefile.go @@ -0,0 +1,25 @@ +package platform + +import ( + "os" + "path/filepath" +) + +// WriteUserFile writes data to path atomically (tmp + rename) with the +// given mode, creating any missing parent directories (mode 0755). +// Intended for user-owned destinations under $HOME — no sudo. +func WriteUserFile(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, mode); err != nil { + os.Remove(tmp) + return err + } + if err := os.Rename(tmp, path); err != nil { + os.Remove(tmp) + return err + } + return nil +} diff --git a/internal/platform/writefile_test.go b/internal/platform/writefile_test.go new file mode 100644 index 0000000..c7f2736 --- /dev/null +++ b/internal/platform/writefile_test.go @@ -0,0 +1,54 @@ +package platform + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteUserFileCreatesParent(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "a", "b", "c.txt") + if err := WriteUserFile(dest, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dest) + if string(got) != "hi" { + t.Fatalf("content %q", got) + } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o644 { + t.Fatalf("mode %v", info.Mode().Perm()) + } +} + +func TestWriteUserFileOverwritesAtomically(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "x") + if err := WriteUserFile(dest, []byte("one"), 0o644); err != nil { + t.Fatal(err) + } + if err := WriteUserFile(dest, []byte("two"), 0o644); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(dest) + if string(got) != "two" { + t.Fatalf("got %q want two", got) + } + // No .tmp sibling should be left behind. + if _, err := os.Stat(dest + ".tmp"); !os.IsNotExist(err) { + t.Fatalf(".tmp left behind: %v", err) + } +} + +func TestWriteUserFileExecMode(t *testing.T) { + dir := t.TempDir() + dest := filepath.Join(dir, "bin") + if err := WriteUserFile(dest, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o755 { + t.Fatalf("mode %v want 0755", info.Mode().Perm()) + } +} From e830e4dfda63c3804a1ddb8b4b542353948e1be9 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 09:58:04 +0200 Subject: [PATCH 2/8] feat(tui): add NvChadTheme field and ThemeByID helper --- internal/tui/state.go | 30 +++++++++++++++++++++--------- internal/tui/state_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/internal/tui/state.go b/internal/tui/state.go index 5120f51..63b5965 100644 --- a/internal/tui/state.go +++ b/internal/tui/state.go @@ -7,21 +7,33 @@ import ( "github.com/MDietermann/ArduinoArchSetup/internal/module" ) -// Theme is a named colorscheme presented in the wizard. Plan 3 wires these -// names to actual theme modules; for now they are display-only strings. +// Theme is a named colorscheme presented in the wizard. Each entry carries +// per-tool theme names; Plan 3b uses NvChadTheme, later plans add more. type Theme struct { - ID string - Name string + ID string + Name string + NvChadTheme string } // BuiltinThemes is the fixed list the wizard shows. Keep in sync with the // themes shipped by Plan 3's theme module. var BuiltinThemes = []Theme{ - {ID: "gruvbox", Name: "Gruvbox Dark"}, - {ID: "catppuccin", Name: "Catppuccin Mocha"}, - {ID: "tokyonight", Name: "Tokyo Night"}, - {ID: "nord", Name: "Nord"}, - {ID: "dracula", Name: "Dracula"}, + {ID: "gruvbox", Name: "Gruvbox Dark", NvChadTheme: "gruvbox"}, + {ID: "catppuccin", Name: "Catppuccin Mocha", NvChadTheme: "catppuccin"}, + {ID: "tokyonight", Name: "Tokyo Night", NvChadTheme: "tokyonight"}, + {ID: "nord", Name: "Nord", NvChadTheme: "nord"}, + {ID: "dracula", Name: "Dracula", NvChadTheme: "dracula"}, +} + +// ThemeByID returns the Theme with the given ID. The bool is false when no +// match is found; callers decide whether to fall back or error. +func ThemeByID(themes []Theme, id string) (Theme, bool) { + for _, t := range themes { + if t.ID == id { + return t, true + } + } + return Theme{}, false } // ModuleChoice is one row in the module selector. diff --git a/internal/tui/state_test.go b/internal/tui/state_test.go index 192272d..90d5dc7 100644 --- a/internal/tui/state_test.go +++ b/internal/tui/state_test.go @@ -70,3 +70,31 @@ func TestSelectedIDsOnlyReturnsChecked(t *testing.T) { t.Fatalf("got %v", got) } } + +func TestBuiltinThemesCarryNvChadName(t *testing.T) { + for _, th := range BuiltinThemes { + if th.NvChadTheme == "" { + t.Errorf("theme %q has empty NvChadTheme", th.ID) + } + } +} + +func TestThemeByIDReturnsMatch(t *testing.T) { + got, ok := ThemeByID(BuiltinThemes, "dracula") + if !ok { + t.Fatal("dracula should exist") + } + if got.ID != "dracula" { + t.Fatalf("got id=%q", got.ID) + } + if got.NvChadTheme == "" { + t.Fatal("NvChadTheme empty") + } +} + +func TestThemeByIDFallsBackWhenUnknown(t *testing.T) { + _, ok := ThemeByID(BuiltinThemes, "does-not-exist") + if ok { + t.Fatal("unknown id should report ok=false") + } +} From e2637bc09384b294f689f6213fba9fa8c41fb79f Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 10:01:40 +0200 Subject: [PATCH 3/8] feat(config): add Neovim and Renode typed inputs --- internal/config/config.go | 24 ++++++++++++++++++++++++ internal/config/config_test.go | 25 +++++++++++++++++++++++++ internal/config/defaults.yaml | 17 +++++++++++++++++ 3 files changed, 66 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index 1ff0a1f..42d2274 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,22 @@ type ShellConfigSection struct { Blocks []ShellConfigBlock `yaml:"blocks"` } +type NeovimConfig struct { + NvChadStarterRepo string `yaml:"nvchad_starter_repo"` +} + +type RenodePlatform struct { + Name string `yaml:"name"` + ReplPath string `yaml:"repl_path"` + Machine string `yaml:"machine"` +} + +type RenodeConfig struct { + WokwiVersion string `yaml:"wokwi_version"` + WokwiURL string `yaml:"wokwi_url"` + Platforms []RenodePlatform `yaml:"platforms"` +} + // Config is the fully-resolved runtime configuration. type Config struct { Version int `yaml:"version"` @@ -58,6 +74,8 @@ type Config struct { Zellij ZellijConfig `yaml:"zellij"` Themes ThemesConfig `yaml:"themes"` ShellConfig ShellConfigSection `yaml:"shell_config"` + Neovim NeovimConfig `yaml:"neovim"` + Renode RenodeConfig `yaml:"renode"` } const currentVersion = 1 @@ -137,5 +155,11 @@ func merge(base, over Config) Config { if len(over.ShellConfig.Blocks) > 0 { out.ShellConfig = over.ShellConfig } + if over.Neovim.NvChadStarterRepo != "" { + out.Neovim = over.Neovim + } + if over.Renode.WokwiVersion != "" || len(over.Renode.Platforms) > 0 { + out.Renode = over.Renode + } return out } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index db6cf95..4becd3c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -73,6 +73,31 @@ func TestDefaultsCarryModuleInputs(t *testing.T) { } } +func TestDefaultsCarryPlan3bInputs(t *testing.T) { + cfg, err := Load("/nonexistent/overlay.yaml") + if err != nil { + t.Fatal(err) + } + if cfg.Neovim.NvChadStarterRepo == "" { + t.Error("Neovim.NvChadStarterRepo empty") + } + if cfg.Renode.WokwiVersion == "" { + t.Error("Renode.WokwiVersion empty") + } + if cfg.Renode.WokwiURL == "" { + t.Error("Renode.WokwiURL empty") + } + if len(cfg.Renode.Platforms) == 0 { + t.Error("Renode.Platforms empty") + } + want := map[string]string{"stm32f4": "STM32F4_Discovery", "rp2040": "RP2040", "nrf52840": "nRF52840"} + for _, p := range cfg.Renode.Platforms { + if want[p.Name] != p.Machine { + t.Errorf("platform %q: machine=%q want %q", p.Name, p.Machine, want[p.Name]) + } + } +} + func TestLoadRejectsUnknownVersion(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "overlay.yaml") diff --git a/internal/config/defaults.yaml b/internal/config/defaults.yaml index ff2bc1f..f609072 100644 --- a/internal/config/defaults.yaml +++ b/internal/config/defaults.yaml @@ -80,3 +80,20 @@ shell_config: alias v="nvim" alias y="yazi" alias theme="archduino-theme" + +neovim: + nvchad_starter_repo: "https://github.com/NvChad/starter" + +renode: + wokwi_version: "0.28.1" + wokwi_url: "https://github.com/wokwi/wokwi-cli/releases/download/v0.28.1/wokwi-cli-linuxstatic-x64" + platforms: + - name: stm32f4 + repl_path: "platforms/boards/stm32f4_discovery.repl" + machine: "STM32F4_Discovery" + - name: rp2040 + repl_path: "platforms/cpus/rp2040.repl" + machine: "RP2040" + - name: nrf52840 + repl_path: "platforms/cpus/nrf52840.repl" + machine: "nRF52840" From e0c1fc3c5d6d07eceee6996368fd8cfa28c00bfd Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 10:03:19 +0200 Subject: [PATCH 4/8] feat(cmd): thread themeID through buildRegistry, rebuild post-wizard Adds a themeID parameter to buildRegistry (unused in the body until Task 7 wires neovim.New) and rebuilds the registry after the wizard returns with finalState.ThemeID, so the dashboard runs against a registry configured with the user's theme selection. The pre-wizard call passes "" since only the dashboard path consumes theme-aware modules. --- cmd/archduino/main.go | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/cmd/archduino/main.go b/cmd/archduino/main.go index 58120d1..c5e7b82 100644 --- a/cmd/archduino/main.go +++ b/cmd/archduino/main.go @@ -37,7 +37,8 @@ var version = "dev" // modules, wired against the provided config and platform dependencies. // It replaces the earlier init()-time hello-module registration now that // the real modules need runtime config to be instantiated. -func buildRegistry(cfg config.Config) (*module.Registry, error) { +func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) { + _ = themeID // reserved for Task 7 (neovim.New wiring); unused until then reg := module.NewRegistry() runner := platform.ExecRunner{} pm := platform.NewPacman(runner) @@ -122,7 +123,7 @@ func newInstallCmd() *cobra.Command { return err } - reg, err := buildRegistry(cfg) + reg, err := buildRegistry(cfg, "") if err != nil { return fmt.Errorf("build registry: %w", err) } @@ -183,6 +184,13 @@ func newInstallCmd() *cobra.Command { fmt.Fprintln(os.Stdout, "installation cancelled") return nil } + // Rebuild the registry with the theme the user chose in the + // wizard so theme-aware modules (e.g. neovim in Task 7) pick + // up the selection instead of the empty pre-wizard default. + reg, err = buildRegistry(cfg, finalState.ThemeID) + if err != nil { + return fmt.Errorf("build registry: %w", err) + } // Either the CLI flag or the wizard toggle can force halt-on-error. sum, runErr = dashboard.Run(ctx, reg, finalState, halt || finalState.HaltOnError) } From 8677a1b95048e2aec4a136e0dd612879f00d2375 Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 10:08:06 +0200 Subject: [PATCH 5/8] feat(modules): port neovim module (NvChad + Archduino Lua assets) to Go --- internal/modules/neovim/assets/arduino.lua | 10 + .../modules/neovim/assets/chadrc.lua.tmpl | 8 + internal/modules/neovim/assets/clangd.lua | 10 + internal/modules/neovim/assets/dap.lua | 57 +++ internal/modules/neovim/assets/lspconfig.lua | 2 + internal/modules/neovim/assets/mappings.lua | 20 + internal/modules/neovim/neovim.go | 422 ++++++++++++++++++ internal/modules/neovim/neovim_test.go | 177 ++++++++ 8 files changed, 706 insertions(+) create mode 100644 internal/modules/neovim/assets/arduino.lua create mode 100644 internal/modules/neovim/assets/chadrc.lua.tmpl create mode 100644 internal/modules/neovim/assets/clangd.lua create mode 100644 internal/modules/neovim/assets/dap.lua create mode 100644 internal/modules/neovim/assets/lspconfig.lua create mode 100644 internal/modules/neovim/assets/mappings.lua create mode 100644 internal/modules/neovim/neovim.go create mode 100644 internal/modules/neovim/neovim_test.go diff --git a/internal/modules/neovim/assets/arduino.lua b/internal/modules/neovim/assets/arduino.lua new file mode 100644 index 0000000..616b62a --- /dev/null +++ b/internal/modules/neovim/assets/arduino.lua @@ -0,0 +1,10 @@ +return { + { + "stevearc/vim-arduino", + ft = { "arduino", "cpp", "c" }, + config = function() + vim.g.arduino_use_cli = 1 + vim.g.arduino_cli_path = "arduino-cli" + end, + }, +} diff --git a/internal/modules/neovim/assets/chadrc.lua.tmpl b/internal/modules/neovim/assets/chadrc.lua.tmpl new file mode 100644 index 0000000..12a3505 --- /dev/null +++ b/internal/modules/neovim/assets/chadrc.lua.tmpl @@ -0,0 +1,8 @@ +---@type ChadrcConfig +local M = {} + +M.base46 = { + theme = "{{.NvChadTheme}}", +} + +return M diff --git a/internal/modules/neovim/assets/clangd.lua b/internal/modules/neovim/assets/clangd.lua new file mode 100644 index 0000000..bb31868 --- /dev/null +++ b/internal/modules/neovim/assets/clangd.lua @@ -0,0 +1,10 @@ +return { + cmd = { + "clangd", + "--background-index", + "--clang-tidy", + "--header-insertion=iwyu", + "--completion-style=detailed", + }, + filetypes = { "c", "cpp", "objc", "objcpp", "cuda", "arduino" }, +} diff --git a/internal/modules/neovim/assets/dap.lua b/internal/modules/neovim/assets/dap.lua new file mode 100644 index 0000000..0bc9198 --- /dev/null +++ b/internal/modules/neovim/assets/dap.lua @@ -0,0 +1,57 @@ +return { + { + "mfussenegger/nvim-dap", + keys = { "d" }, + config = function() + local dap = require("dap") + + -- GDB remote adapter (connects to Renode's GDB server) + dap.adapters.gdb_remote = { + type = "executable", + command = vim.fn.executable("arm-none-eabi-gdb") == 1 + and "arm-none-eabi-gdb" + or "gdb-multiarch", + args = { "-i", "dap" }, + } + + -- Default debug configurations for Arduino/embedded + dap.configurations.c = { + { + name = "Attach to Renode (GDB :3333)", + type = "gdb_remote", + request = "attach", + target = "localhost:3333", + cwd = "${workspaceFolder}", + program = function() + return vim.fn.input("ELF path: ", vim.fn.getcwd() .. "/build/", "file") + end, + }, + } + dap.configurations.cpp = dap.configurations.c + dap.configurations.arduino = dap.configurations.c + + -- Breakpoint signs + vim.fn.sign_define("DapBreakpoint", { text = "●", texthl = "DiagnosticError" }) + vim.fn.sign_define("DapStopped", { text = "▶", texthl = "DiagnosticOk", linehl = "DiffAdd" }) + end, + }, + { + "rcarriga/nvim-dap-ui", + dependencies = { "mfussenegger/nvim-dap", "nvim-neotest/nvim-nio" }, + keys = { "du" }, + config = function() + local dapui = require("dapui") + dapui.setup() + + -- Auto open/close UI on debug sessions + local dap = require("dap") + dap.listeners.after.event_initialized["dapui_config"] = function() dapui.open() end + dap.listeners.before.event_terminated["dapui_config"] = function() dapui.close() end + dap.listeners.before.event_exited["dapui_config"] = function() dapui.close() end + end, + }, + { + "nvim-neotest/nvim-nio", + lazy = true, + }, +} diff --git a/internal/modules/neovim/assets/lspconfig.lua b/internal/modules/neovim/assets/lspconfig.lua new file mode 100644 index 0000000..cc9ace7 --- /dev/null +++ b/internal/modules/neovim/assets/lspconfig.lua @@ -0,0 +1,2 @@ +vim.lsp.enable("clangd") +return {} diff --git a/internal/modules/neovim/assets/mappings.lua b/internal/modules/neovim/assets/mappings.lua new file mode 100644 index 0000000..8df26d9 --- /dev/null +++ b/internal/modules/neovim/assets/mappings.lua @@ -0,0 +1,20 @@ +require "nvchad.mappings" + +local map = vim.keymap.set + +map("n", "ac", "ArduinoChooseBoard", { desc = "Arduino Choose Board" }) +map("n", "ap", "ArduinoChoosePort", { desc = "Arduino Choose Port" }) +map("n", "av", "ArduinoVerify", { desc = "Arduino Verify (compile)" }) +map("n", "au", "ArduinoUpload", { desc = "Arduino Upload" }) +map("n", "as", "ArduinoSerial", { desc = "Arduino Serial Monitor" }) +map("n", "ai", "ArduinoInfo", { desc = "Arduino Board Info" }) +map("n", "aw", "!wokwi-cli .", { desc = "Arduino Wokwi Simulate" }) + +-- Debug (DAP) +map("n", "ds", function() require("dap").continue() end, { desc = "Debug Start/Continue" }) +map("n", "db", function() require("dap").toggle_breakpoint() end, { desc = "Debug Toggle Breakpoint" }) +map("n", "dn", function() require("dap").step_over() end, { desc = "Debug Step Over" }) +map("n", "di", function() require("dap").step_into() end, { desc = "Debug Step Into" }) +map("n", "do", function() require("dap").step_out() end, { desc = "Debug Step Out" }) +map("n", "dx", function() require("dap").terminate() end, { desc = "Debug Terminate" }) +map("n", "du", function() require("dapui").toggle() end, { desc = "Debug UI Toggle" }) diff --git a/internal/modules/neovim/neovim.go b/internal/modules/neovim/neovim.go new file mode 100644 index 0000000..b2c7e27 --- /dev/null +++ b/internal/modules/neovim/neovim.go @@ -0,0 +1,422 @@ +// Package neovim installs Neovim via pacman, clones the NvChad starter, +// backs up any non-NvChad config, strips the starter's nvim-lspconfig +// block, and writes Archduino's Lua asset files. +package neovim + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "text/template" + "time" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +//go:embed assets/* +var assets embed.FS + +// assetMap lists each asset and its destination under ~/.config/nvim. +// Order matters: Detect iterates in this order and Apply writes in this order. +var assetMap = []struct { + SrcPath string // under assets/ + DstRel string // under ~/.config/nvim/ +}{ + {"assets/arduino.lua", "lua/plugins/arduino.lua"}, + {"assets/dap.lua", "lua/plugins/dap.lua"}, + {"assets/lspconfig.lua", "lua/plugins/lspconfig.lua"}, + {"assets/mappings.lua", "lua/plugins/mappings.lua"}, + {"assets/clangd.lua", "lsp/clangd.lua"}, +} + +// Module installs and configures Neovim + NvChad for Archduino. +type Module struct { + cfg config.NeovimConfig + themeID string + themes []tui.Theme + pm platform.PackageManager + git platform.GitClient +} + +// New constructs a Neovim Module. +func New(cfg config.NeovimConfig, themeID string, themes []tui.Theme, pm platform.PackageManager, git platform.GitClient) Module { + return Module{cfg: cfg, themeID: themeID, themes: themes, pm: pm, git: git} +} + +type state struct { + NvimInstalled bool + ConfigDirExists bool + ChadrcPresent bool + BackupNeeded bool + BackupSource string + StarterCloned bool + MissingAssets []string + ChadrcDrift bool + LspconfigBlock bool + StarterLspFile bool + ThemeName string + RenderedChadrc []byte +} + +// Meta returns the module metadata. +func (Module) Meta() module.Meta { + return module.Meta{ID: "neovim", Name: "Neovim + NvChad", Description: "Install Neovim and configure NvChad with Archduino plugins."} +} + +func nvimCfg() (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, ".config", "nvim"), nil +} + +func (m Module) resolvedTheme() string { + if t, ok := tui.ThemeByID(m.themes, m.themeID); ok && t.NvChadTheme != "" { + return t.NvChadTheme + } + if len(m.themes) > 0 { + return m.themes[0].NvChadTheme + } + return "gruvbox" +} + +func (m Module) renderChadrc() ([]byte, error) { + tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl") + if err != nil { + return nil, err + } + tmpl, err := template.New("chadrc").Parse(string(tmplBytes)) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: m.resolvedTheme()}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Detect inspects the filesystem and returns the current state. +func (m Module) Detect(ctx context.Context) (module.State, error) { + cfg, err := nvimCfg() + if err != nil { + return nil, err + } + var s state + s.ThemeName = m.resolvedTheme() + if m.pm != nil { + ok, err := m.pm.IsInstalled(ctx, "neovim") + if err != nil { + return nil, err + } + s.NvimInstalled = ok + } + + rendered, err := m.renderChadrc() + if err != nil { + return nil, err + } + s.RenderedChadrc = rendered + + if fi, err := os.Stat(cfg); err == nil && fi.IsDir() { + s.ConfigDirExists = true + if _, err := os.Stat(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil { + s.ChadrcPresent = true + } + if _, err := os.Stat(filepath.Join(cfg, "lua")); err == nil && !s.ChadrcPresent { + s.BackupNeeded = true + s.BackupSource = cfg + } + } + if _, err := os.Stat(filepath.Join(cfg, "lua", "plugins", "init.lua")); err == nil { + s.StarterCloned = true + data, _ := os.ReadFile(filepath.Join(cfg, "lua", "plugins", "init.lua")) + if bytes.Contains(data, []byte("nvim-lspconfig")) { + s.LspconfigBlock = true + } + } + if _, err := os.Stat(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")); err == nil { + s.StarterLspFile = true + } + + if cur, err := os.ReadFile(filepath.Join(cfg, "lua", "chadrc.lua")); err == nil { + if !bytes.Equal(cur, rendered) { + s.ChadrcDrift = true + } + } + + for _, a := range assetMap { + want, _ := assets.ReadFile(a.SrcPath) + got, err := os.ReadFile(filepath.Join(cfg, a.DstRel)) + if err != nil || !bytes.Equal(got, want) { + s.MissingAssets = append(s.MissingAssets, a.DstRel) + } + } + return s, nil +} + +// stripLspconfigBlock removes the plugin-table entry that references +// "neovim/nvim-lspconfig" from the NvChad starter's init.lua. It walks the +// source line-by-line: find the marker line, scan backward for the opening +// `{` of that entry, then scan forward counting braces until the matching +// `}` (plus trailing `,` if present). Robust against nested tables inside +// the entry (e.g. `opts = {}`) that defeat single-level regexes. +func stripLspconfigBlock(src []byte) []byte { + lines := strings.Split(string(src), "\n") + markerIdx := -1 + for i, l := range lines { + if strings.Contains(l, "neovim/nvim-lspconfig") { + markerIdx = i + break + } + } + if markerIdx == -1 { + return src + } + + // Scan backward for the opening `{` that begins the plugin entry. + // We track braces relative to the marker line so nested tables on + // earlier lines don't confuse the search: we want the first `{` + // whose pair would enclose the marker. + startLine := -1 + depth := 0 + for i := markerIdx; i >= 0; i-- { + // Count braces on this line from right to left. + for j := len(lines[i]) - 1; j >= 0; j-- { + c := lines[i][j] + if c == '}' { + depth++ + } else if c == '{' { + if depth == 0 { + startLine = i + // break out of both loops + i = -1 + break + } + depth-- + } + } + } + if startLine == -1 { + return src + } + + // Scan forward from markerIdx for the matching `}`. + endLine := -1 + depth = 1 // we're already inside the `{` at startLine + // Start just after the opening `{` on startLine. + for i := startLine; i < len(lines); i++ { + start := 0 + if i == startLine { + // skip past the first `{` on this line + idx := strings.Index(lines[i], "{") + if idx >= 0 { + start = idx + 1 + } + } + for j := start; j < len(lines[i]); j++ { + c := lines[i][j] + if c == '{' { + depth++ + } else if c == '}' { + depth-- + if depth == 0 { + endLine = i + break + } + } + } + if endLine != -1 { + break + } + } + if endLine == -1 { + return src + } + + // Include an optional trailing comma and whitespace-only lines after endLine. + cutEnd := endLine + // If the `}` line also has a trailing `,`, that's fine; keep the line as-is + // but drop it. If the endLine text after the closing `}` is just `,` or + // `,` + whitespace, keep cutEnd = endLine. Otherwise also keep cutEnd. + // Skip a single blank line after the block to avoid leaving a stray gap. + if cutEnd+1 < len(lines) && strings.TrimSpace(lines[cutEnd+1]) == "" { + cutEnd++ + } + + out := make([]string, 0, len(lines)-(cutEnd-startLine+1)) + out = append(out, lines[:startLine]...) + out = append(out, lines[cutEnd+1:]...) + return []byte(strings.Join(out, "\n")) +} + +// Plan computes the idempotent set of actions to converge to desired state. +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + cfg, err := nvimCfg() + if err != nil { + return module.Plan{}, err + } + var actions []module.Action + + if !s.NvimInstalled && m.pm != nil { + actions = append(actions, module.Action{ + Description: "pacman install neovim", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"neovim"}) }, + }) + } + + if s.BackupNeeded { + src := s.BackupSource + actions = append(actions, module.Action{ + Description: "back up existing non-NvChad config", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + ts := time.Now().UTC().Format("20060102T150405Z") + return os.Rename(src, src+".bak."+ts) + }, + }) + } + + if !s.StarterCloned || s.BackupNeeded { + repo := m.cfg.NvChadStarterRepo + actions = append(actions, module.Action{ + Description: "clone NvChad starter", + Kind: module.ActionGitClone, + Exec: func(ctx context.Context) error { + if err := os.MkdirAll(filepath.Dir(cfg), 0o755); err != nil { + return err + } + if err := m.git.Clone(ctx, repo, cfg); err != nil { + return err + } + return os.RemoveAll(filepath.Join(cfg, ".git")) + }, + }) + } + + if s.LspconfigBlock || !s.StarterCloned || s.BackupNeeded { + actions = append(actions, module.Action{ + Description: "strip nvim-lspconfig block from starter init.lua", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + p := filepath.Join(cfg, "lua", "plugins", "init.lua") + data, err := os.ReadFile(p) + if err != nil { + return err + } + return platform.WriteUserFile(p, stripLspconfigBlock(data), 0o644) + }, + }) + } + + if s.StarterLspFile || !s.StarterCloned || s.BackupNeeded { + actions = append(actions, module.Action{ + Description: "remove starter lspconfig.lua", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + err := os.Remove(filepath.Join(cfg, "lua", "configs", "lspconfig.lua")) + if err != nil && !os.IsNotExist(err) { + return err + } + return nil + }, + }) + } + + if s.ChadrcDrift || !s.ChadrcPresent || s.BackupNeeded { + rendered := append([]byte{}, s.RenderedChadrc...) + actions = append(actions, module.Action{ + Description: "write chadrc.lua with theme " + s.ThemeName, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + return platform.WriteUserFile(filepath.Join(cfg, "lua", "chadrc.lua"), rendered, 0o644) + }, + }) + } + + for _, dst := range s.MissingAssets { + dst := dst + actions = append(actions, module.Action{ + Description: "write asset " + dst, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + var src string + for _, a := range assetMap { + if a.DstRel == dst { + src = a.SrcPath + break + } + } + data, err := assets.ReadFile(src) + if err != nil { + return err + } + return platform.WriteUserFile(filepath.Join(cfg, dst), data, 0o644) + }, + }) + } + + if len(actions) > 0 { + actions = append(actions, module.Action{ + Description: "clear NvChad base46 theme cache", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + home, _ := os.UserHomeDir() + _ = os.RemoveAll(filepath.Join(home, ".local", "share", "nvim", "lazy", "base46", "lua", "base46", "themes")) + _ = os.RemoveAll(filepath.Join(home, ".cache", "nvim", "base46")) + return nil + }, + }) + } + + return module.Plan{Actions: actions}, nil +} + +// Apply executes the planned actions in order. +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { + return err + } + } + return nil +} + +// Verify re-Detects and returns an error if state is not converged. +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx) + if err != nil { + return err + } + s := st.(state) + if !s.NvimInstalled { + return fmt.Errorf("neovim not installed") + } + if !s.ChadrcPresent { + return fmt.Errorf("chadrc.lua missing") + } + if s.ChadrcDrift { + return fmt.Errorf("chadrc.lua content drifted") + } + if len(s.MissingAssets) > 0 { + return fmt.Errorf("missing assets: %v", s.MissingAssets) + } + if s.LspconfigBlock { + return fmt.Errorf("nvim-lspconfig block not stripped") + } + if s.StarterLspFile { + return fmt.Errorf("starter lspconfig.lua still present") + } + return nil +} diff --git a/internal/modules/neovim/neovim_test.go b/internal/modules/neovim/neovim_test.go new file mode 100644 index 0000000..964ebff --- /dev/null +++ b/internal/modules/neovim/neovim_test.go @@ -0,0 +1,177 @@ +package neovim + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" + "github.com/MDietermann/ArduinoArchSetup/internal/tui" +) + +type fakeRunner struct { + runCalls, outCalls [][]string + installed map[string]bool +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + if name == "git" && len(args) >= 1 && args[0] == "clone" { + dest := args[len(args)-1] + os.MkdirAll(filepath.Join(dest, "lua", "plugins"), 0o755) + os.WriteFile(filepath.Join(dest, "lua", "plugins", "init.lua"), + []byte("return {\n \"neovim/nvim-lspconfig\",\n opts = {},\n},\n{\n \"other\",\n},\n"), + 0o644) + os.MkdirAll(filepath.Join(dest, "lua", "configs"), 0o755) + os.WriteFile(filepath.Join(dest, "lua", "configs", "lspconfig.lua"), []byte("stub"), 0o644) + } + return nil +} + +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { + return []byte("ok"), nil + } + return nil, errExit1 + } + return nil, nil +} + +type exitOne struct{} + +func (exitOne) Error() string { return "exit 1" } + +var errExit1 = exitOne{} + +type nopReporter struct{} + +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func TestDetectFreshSystem(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, err := m.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + s := st.(state) + if s.NvimInstalled { + t.Error("nvim should be absent") + } + if s.ChadrcPresent { + t.Error("chadrc should be absent") + } + if s.BackupNeeded { + t.Error("no backup on empty system") + } +} + +func TestDetectNonNvChadTriggersBackup(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + os.MkdirAll(filepath.Join(home, ".config", "nvim", "lua"), 0o755) + os.WriteFile(filepath.Join(home, ".config", "nvim", "lua", "plugins.lua"), []byte("-- lazyvim"), 0o644) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + if !st.(state).BackupNeeded { + t.Fatal("expected BackupNeeded=true") + } +} + +func TestPlanApplyInstallsEverything(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "dracula", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if len(p.Actions) == 0 { + t.Fatal("empty plan") + } + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + + sawPacman, sawClone := false, false + for _, c := range r.runCalls { + if len(c) >= 3 && c[0] == "sudo" && c[1] == "pacman" { + sawPacman = true + } + if c[0] == "git" && len(c) >= 2 && c[1] == "clone" { + sawClone = true + } + } + if !sawPacman || !sawClone { + t.Fatalf("missing steps; calls=%v", r.runCalls) + } + + nvimCfg := filepath.Join(home, ".config", "nvim") + chadrc, err := os.ReadFile(filepath.Join(nvimCfg, "lua", "chadrc.lua")) + if err != nil { + t.Fatalf("chadrc missing: %v", err) + } + if !strings.Contains(string(chadrc), `theme = "dracula"`) { + t.Fatalf("chadrc theme not applied: %s", chadrc) + } + + initLua, _ := os.ReadFile(filepath.Join(nvimCfg, "lua", "plugins", "init.lua")) + if strings.Contains(string(initLua), "nvim-lspconfig") { + t.Fatalf("nvim-lspconfig not stripped: %s", initLua) + } + + if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "configs", "lspconfig.lua")); !os.IsNotExist(err) { + t.Fatal("starter lspconfig.lua should be removed") + } + + for _, name := range []string{"arduino.lua", "dap.lua", "mappings.lua"} { + if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "plugins", name)); err != nil { + t.Errorf("asset %s missing: %v", name, err) + } + } + if _, err := os.Stat(filepath.Join(nvimCfg, "lsp", "clangd.lua")); err != nil { + t.Errorf("clangd.lua missing: %v", err) + } +} + +func TestPlanAfterApplyIsEmpty(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{"neovim": true}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "gruvbox", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + _ = m.Apply(context.Background(), p, nopReporter{}) + st2, _ := m.Detect(context.Background()) + p2, _ := m.Plan(context.Background(), st2) + if !p2.IsEmpty() { + t.Fatalf("second plan not empty: %+v", p2.Actions) + } +} + +func TestUnknownThemeFallsBackToFirst(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{"neovim": true}} + m := New(config.NeovimConfig{NvChadStarterRepo: "https://x/starter"}, "does-not-exist", tui.BuiltinThemes, platform.NewPacman(r), platform.GitClient{Runner: r}) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + chadrc, _ := os.ReadFile(filepath.Join(home, ".config", "nvim", "lua", "chadrc.lua")) + if !strings.Contains(string(chadrc), `theme = "gruvbox"`) { + t.Fatalf("fallback theme not applied: %s", chadrc) + } +} + +var _ module.Module = Module{} From daa20b1dcaea9039e096245b18b0da725bad247c Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 10:09:26 +0200 Subject: [PATCH 6/8] fix(neovim): write mappings.lua to lua/mappings.lua per NvChad convention --- internal/modules/neovim/neovim.go | 2 +- internal/modules/neovim/neovim_test.go | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/modules/neovim/neovim.go b/internal/modules/neovim/neovim.go index b2c7e27..e61c51f 100644 --- a/internal/modules/neovim/neovim.go +++ b/internal/modules/neovim/neovim.go @@ -32,7 +32,7 @@ var assetMap = []struct { {"assets/arduino.lua", "lua/plugins/arduino.lua"}, {"assets/dap.lua", "lua/plugins/dap.lua"}, {"assets/lspconfig.lua", "lua/plugins/lspconfig.lua"}, - {"assets/mappings.lua", "lua/plugins/mappings.lua"}, + {"assets/mappings.lua", "lua/mappings.lua"}, {"assets/clangd.lua", "lsp/clangd.lua"}, } diff --git a/internal/modules/neovim/neovim_test.go b/internal/modules/neovim/neovim_test.go index 964ebff..6ad7510 100644 --- a/internal/modules/neovim/neovim_test.go +++ b/internal/modules/neovim/neovim_test.go @@ -133,11 +133,14 @@ func TestPlanApplyInstallsEverything(t *testing.T) { t.Fatal("starter lspconfig.lua should be removed") } - for _, name := range []string{"arduino.lua", "dap.lua", "mappings.lua"} { + for _, name := range []string{"arduino.lua", "dap.lua"} { if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "plugins", name)); err != nil { t.Errorf("asset %s missing: %v", name, err) } } + if _, err := os.Stat(filepath.Join(nvimCfg, "lua", "mappings.lua")); err != nil { + t.Errorf("mappings.lua missing: %v", err) + } if _, err := os.Stat(filepath.Join(nvimCfg, "lsp", "clangd.lua")); err != nil { t.Errorf("clangd.lua missing: %v", err) } From 66f02edfb2dca89593fc788e61b7a247672bb07a Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 12:13:42 +0200 Subject: [PATCH 7/8] feat(modules): port renode module (AUR + arduino-sim + wokwi-cli) to Go --- .../modules/renode/assets/arduino-sim.tmpl | 54 +++++ internal/modules/renode/renode.go | 187 ++++++++++++++++++ internal/modules/renode/renode_test.go | 145 ++++++++++++++ 3 files changed, 386 insertions(+) create mode 100644 internal/modules/renode/assets/arduino-sim.tmpl create mode 100644 internal/modules/renode/renode.go create mode 100644 internal/modules/renode/renode_test.go diff --git a/internal/modules/renode/assets/arduino-sim.tmpl b/internal/modules/renode/assets/arduino-sim.tmpl new file mode 100644 index 0000000..ef5c0ec --- /dev/null +++ b/internal/modules/renode/assets/arduino-sim.tmpl @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +usage() { + echo "Usage: arduino-sim [platform]" + echo "" + echo "Launch Renode with a GDB server for debugging firmware." + echo "Connect nvim-dap or gdb to localhost:3333" + echo "" + echo "Platforms:" +{{range .Platforms}} echo " {{.Name}} {{.Machine}}" +{{end}} exit 1 +} + +[[ $# -lt 1 ]] && usage + +ELF="$1" +PLATFORM="${2:-{{(index .Platforms 0).Name}}}" + +[[ ! -f "$ELF" ]] && echo "Error: $ELF not found" && exit 1 + +case "$PLATFORM" in +{{range .Platforms}} {{.Name}}) + RENODE_PLATFORM="{{.ReplPath}}" + MACHINE="{{.Machine}}" + ;; +{{end}} *) + echo "Unknown platform: $PLATFORM" + usage + ;; +esac + +RESC=$(mktemp /tmp/renode-XXXXXX.resc) +cat >"$RESC" <ds or gdb → target remote :3333" +echo "" + +renode --disable-xwt "$RESC" +rm -f "$RESC" diff --git a/internal/modules/renode/renode.go b/internal/modules/renode/renode.go new file mode 100644 index 0000000..385f9e3 --- /dev/null +++ b/internal/modules/renode/renode.go @@ -0,0 +1,187 @@ +// Package renode installs the Renode emulator (AUR), arm-none-eabi-gdb, +// the arduino-sim helper script, and wokwi-cli. +package renode + +import ( + "bytes" + "context" + "embed" + "fmt" + "os" + "path/filepath" + "text/template" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +//go:embed assets/arduino-sim.tmpl +var assetsFS embed.FS + +// Module installs Renode and adjacent emulator tooling. +type Module struct { + cfg config.RenodeConfig + pm platform.PackageManager + aur platform.AURHelper +} + +// New constructs a renode Module. +func New(cfg config.RenodeConfig, pm platform.PackageManager, aur platform.AURHelper) Module { + return Module{cfg: cfg, pm: pm, aur: aur} +} + +type state struct { + RenodeInstalled bool + GdbInstalled bool + SimScriptCurrent bool + WokwiInstalled bool + RenderedSim []byte + SimPath string + WokwiPath string +} + +// Meta returns the module metadata. +func (Module) Meta() module.Meta { + return module.Meta{ID: "renode", Name: "Renode emulator", Description: "Install Renode, ARM GDB, arduino-sim helper, and Wokwi CLI."} +} + +func binPath() (string, error) { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(h, ".local", "bin"), nil +} + +func (m Module) renderSim() ([]byte, error) { + tmplBytes, err := assetsFS.ReadFile("assets/arduino-sim.tmpl") + if err != nil { + return nil, err + } + tmpl, err := template.New("sim").Parse(string(tmplBytes)) + if err != nil { + return nil, err + } + var buf bytes.Buffer + if err := tmpl.Execute(&buf, m.cfg); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Detect inspects the system and returns current state. +func (m Module) Detect(ctx context.Context) (module.State, error) { + bin, err := binPath() + if err != nil { + return nil, err + } + s := state{ + SimPath: filepath.Join(bin, "arduino-sim"), + WokwiPath: filepath.Join(bin, "wokwi-cli"), + } + if ok, err := m.pm.IsInstalled(ctx, "renode-bin"); err == nil && ok { + s.RenodeInstalled = true + } + if ok, err := m.pm.IsInstalled(ctx, "arm-none-eabi-gdb"); err == nil && ok { + s.GdbInstalled = true + } + + rendered, err := m.renderSim() + if err != nil { + return nil, err + } + s.RenderedSim = rendered + if cur, err := os.ReadFile(s.SimPath); err == nil && bytes.Equal(cur, rendered) { + s.SimScriptCurrent = true + } + if _, err := os.Stat(s.WokwiPath); err == nil { + s.WokwiInstalled = true + } + return s, nil +} + +// Plan computes the set of actions needed to converge to desired state. +func (m Module) Plan(ctx context.Context, cur module.State) (module.Plan, error) { + s, _ := cur.(state) + var actions []module.Action + + if !s.RenodeInstalled { + actions = append(actions, module.Action{ + Description: "ensure yay installed", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.aur.EnsureInstalled(ctx) }, + }) + actions = append(actions, module.Action{ + Description: "yay install renode-bin", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.aur.Install(ctx, []string{"renode-bin"}) }, + }) + } + if !s.GdbInstalled { + actions = append(actions, module.Action{ + Description: "pacman install arm-none-eabi-gdb", + Kind: module.ActionPackageInstall, + Exec: func(ctx context.Context) error { return m.pm.Install(ctx, []string{"arm-none-eabi-gdb"}) }, + }) + } + if !s.SimScriptCurrent { + rendered := append([]byte{}, s.RenderedSim...) + path := s.SimPath + actions = append(actions, module.Action{ + Description: "write arduino-sim helper", + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + return platform.WriteUserFile(path, rendered, 0o755) + }, + }) + } + if !s.WokwiInstalled { + url := m.cfg.WokwiURL + dst := s.WokwiPath + actions = append(actions, module.Action{ + Description: "download wokwi-cli " + m.cfg.WokwiVersion, + Kind: module.ActionFileWrite, + Exec: func(ctx context.Context) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + return platform.Download(ctx, url, dst, 0o755) + }, + }) + } + return module.Plan{Actions: actions}, nil +} + +// Apply executes the planned actions in order. +func (m Module) Apply(ctx context.Context, p module.Plan, r module.Reporter) error { + for i, a := range p.Actions { + r.Progress(m.Meta().ID, i+1, len(p.Actions), a.Description) + if err := a.Exec(ctx); err != nil { + return err + } + } + return nil +} + +// Verify re-Detects and errors if state hasn't converged. +func (m Module) Verify(ctx context.Context) error { + st, err := m.Detect(ctx) + if err != nil { + return err + } + s := st.(state) + if !s.RenodeInstalled { + return fmt.Errorf("renode-bin not installed") + } + if !s.GdbInstalled { + return fmt.Errorf("arm-none-eabi-gdb not installed") + } + if !s.SimScriptCurrent { + return fmt.Errorf("arduino-sim script drift") + } + if !s.WokwiInstalled { + return fmt.Errorf("wokwi-cli missing") + } + return nil +} diff --git a/internal/modules/renode/renode_test.go b/internal/modules/renode/renode_test.go new file mode 100644 index 0000000..2d37b85 --- /dev/null +++ b/internal/modules/renode/renode_test.go @@ -0,0 +1,145 @@ +package renode + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MDietermann/ArduinoArchSetup/internal/config" + "github.com/MDietermann/ArduinoArchSetup/internal/module" + "github.com/MDietermann/ArduinoArchSetup/internal/platform" +) + +type fakeRunner struct { + runCalls, outCalls [][]string + installed map[string]bool +} + +func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { + f.runCalls = append(f.runCalls, append([]string{name}, args...)) + return nil +} +func (f *fakeRunner) Output(_ context.Context, name string, args ...string) ([]byte, error) { + f.outCalls = append(f.outCalls, append([]string{name}, args...)) + if name == "pacman" && len(args) == 2 && args[0] == "-Q" { + if f.installed[args[1]] { + return []byte("ok"), nil + } + return nil, errExit1 + } + return nil, nil +} + +type exitOne struct{} + +func (exitOne) Error() string { return "exit 1" } + +var errExit1 = exitOne{} + +type nopReporter struct{} + +func (nopReporter) Progress(string, int, int, string) {} +func (nopReporter) Log(string, string) {} + +func sampleCfg(url string) config.RenodeConfig { + return config.RenodeConfig{ + WokwiVersion: "0.28.1", + WokwiURL: url, + Platforms: []config.RenodePlatform{ + {Name: "stm32f4", ReplPath: "platforms/boards/stm32f4_discovery.repl", Machine: "STM32F4_Discovery"}, + {Name: "rp2040", ReplPath: "platforms/cpus/rp2040.repl", Machine: "RP2040"}, + }, + } +} + +func TestDetectNothingInstalled(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + r := &fakeRunner{installed: map[string]bool{}} + m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r)) + st, err := m.Detect(context.Background()) + if err != nil { + t.Fatal(err) + } + s := st.(state) + if s.RenodeInstalled || s.GdbInstalled || s.SimScriptCurrent || s.WokwiInstalled { + t.Fatalf("want all absent, got %+v", s) + } +} + +func TestDetectAllInstalled(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + os.MkdirAll(filepath.Join(home, ".local", "bin"), 0o755) + os.WriteFile(filepath.Join(home, ".local", "bin", "wokwi-cli"), []byte("x"), 0o755) + r := &fakeRunner{installed: map[string]bool{"renode-bin": true, "arm-none-eabi-gdb": true}} + m := New(sampleCfg("http://x/wokwi"), platform.NewPacman(r), platform.NewYay(r)) + // Apply once to seed the sim script, then re-detect. + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + _ = m.Apply(context.Background(), p, nopReporter{}) + st2, _ := m.Detect(context.Background()) + p2, _ := m.Plan(context.Background(), st2) + if !p2.IsEmpty() { + t.Fatalf("second plan not empty: %+v", p2.Actions) + } +} + +func TestApplyRendersSimScriptAndDownloadsWokwi(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write([]byte("wokwi-binary")) + })) + defer srv.Close() + + r := &fakeRunner{installed: map[string]bool{"yay": true}} + m := New(sampleCfg(srv.URL), platform.NewPacman(r), platform.NewYay(r)) + st, _ := m.Detect(context.Background()) + p, _ := m.Plan(context.Background(), st) + if err := m.Apply(context.Background(), p, nopReporter{}); err != nil { + t.Fatal(err) + } + + // arduino-sim rendered with both platforms + sim, err := os.ReadFile(filepath.Join(home, ".local", "bin", "arduino-sim")) + if err != nil { + t.Fatalf("arduino-sim not written: %v", err) + } + if !strings.Contains(string(sim), "stm32f4)") { + t.Errorf("stm32f4 branch missing: %s", sim) + } + if !strings.Contains(string(sim), "rp2040)") { + t.Errorf("rp2040 branch missing: %s", sim) + } + info, _ := os.Stat(filepath.Join(home, ".local", "bin", "arduino-sim")) + if info.Mode().Perm() != 0o755 { + t.Errorf("arduino-sim mode %v", info.Mode().Perm()) + } + + // wokwi-cli downloaded + wokwi, err := os.ReadFile(filepath.Join(home, ".local", "bin", "wokwi-cli")) + if err != nil { + t.Fatalf("wokwi-cli missing: %v", err) + } + if string(wokwi) != "wokwi-binary" { + t.Errorf("wokwi-cli content %q", wokwi) + } + + // yay install renode-bin called + sawYay := false + for _, c := range r.runCalls { + if c[0] == "yay" && len(c) >= 2 && c[1] == "-S" { + sawYay = true + } + } + if !sawYay { + t.Fatalf("yay install missing; calls=%v", r.runCalls) + } +} + +var _ module.Module = Module{} From bf2d8de82c41ad96a3aa91857b86fdd12fa9d2be Mon Sep 17 00:00:00 2001 From: MDietermann Date: Tue, 14 Apr 2026 12:16:05 +0200 Subject: [PATCH 8/8] feat(cmd): register neovim and renode modules in install command --- cmd/archduino/main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/archduino/main.go b/cmd/archduino/main.go index c5e7b82..bbcc835 100644 --- a/cmd/archduino/main.go +++ b/cmd/archduino/main.go @@ -16,7 +16,9 @@ import ( "github.com/MDietermann/ArduinoArchSetup/internal/modules/arduinocli" "github.com/MDietermann/ArduinoArchSetup/internal/modules/fonts" "github.com/MDietermann/ArduinoArchSetup/internal/modules/gittools" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/neovim" "github.com/MDietermann/ArduinoArchSetup/internal/modules/packages" + "github.com/MDietermann/ArduinoArchSetup/internal/modules/renode" "github.com/MDietermann/ArduinoArchSetup/internal/modules/shellconfig" "github.com/MDietermann/ArduinoArchSetup/internal/modules/starship" "github.com/MDietermann/ArduinoArchSetup/internal/modules/themes" @@ -38,7 +40,6 @@ var version = "dev" // It replaces the earlier init()-time hello-module registration now that // the real modules need runtime config to be instantiated. func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) { - _ = themeID // reserved for Task 7 (neovim.New wiring); unused until then reg := module.NewRegistry() runner := platform.ExecRunner{} pm := platform.NewPacman(runner) @@ -56,6 +57,8 @@ func buildRegistry(cfg config.Config, themeID string) (*module.Registry, error) themes.New(cfg.Themes), arduinocli.New(cfg.ArduinoCLI, runner), shellconfig.New(cfg.ShellConfig), + neovim.New(cfg.Neovim, themeID, tui.BuiltinThemes, pm, git), + renode.New(cfg.Renode, pm, yay), } for _, m := range mods { if err := reg.Register(m); err != nil {