// 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/theming" ) //go:embed assets/* var assets embed.FS // Register WriteChadrc with the theming package so theming.Apply can // rewrite chadrc.lua on theme changes without importing this package // (which would be a cycle: neovim imports theming). func init() { theming.ChadrcWriter = WriteChadrc } // 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/mappings.lua"}, {"assets/clangd.lua", "lsp/clangd.lua"}, } // Module installs and configures Neovim + NvChad for Archduino. type Module struct { cfg config.NeovimConfig themeID string pm platform.PackageManager git platform.GitClient } // New constructs a Neovim Module. func New(cfg config.NeovimConfig, themeID string, pm platform.PackageManager, git platform.GitClient) Module { return Module{cfg: cfg, themeID: themeID, 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 := theming.Get(m.themeID); ok && t.NvChadTheme != "" { return t.NvChadTheme } if list := theming.List(); len(list) > 0 && list[0].NvChadTheme != "" { return list[0].NvChadTheme } return "gruvbox" } // WriteChadrc renders chadrc.lua for the given NvChad theme name and writes // it to ~/.config/nvim/lua/chadrc.lua. If the file does not already exist // and the surrounding nvim config dir does not exist, the function returns // nil without writing anything (the user hasn't installed neovim yet). // // Exported so the theming package can re-render chadrc on theme switches // without re-running the full Module pipeline. func WriteChadrc(nvChadThemeName string) error { home, err := os.UserHomeDir() if err != nil { return err } cfg := filepath.Join(home, ".config", "nvim") chadrcPath := filepath.Join(cfg, "lua", "chadrc.lua") // If chadrc doesn't exist, only write when the parent dir exists. A // fresh system where nvim was never installed should be a no-op. if _, err := os.Stat(chadrcPath); err != nil { if _, err := os.Stat(cfg); err != nil { return nil } } tmplBytes, err := assets.ReadFile("assets/chadrc.lua.tmpl") if err != nil { return err } tmpl, err := template.New("chadrc").Parse(string(tmplBytes)) if err != nil { return err } var buf bytes.Buffer if err := tmpl.Execute(&buf, struct{ NvChadTheme string }{NvChadTheme: nvChadThemeName}); err != nil { return err } return platform.WriteUserFile(chadrcPath, buf.Bytes(), 0o644) } 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 }