diff --git a/README.md b/README.md index 89fb19b..4febefc 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,12 @@ go run . ## `config.lua` ```lua -return { +---@class SigilConfig +---@field target table +---@field ignore? string[] + +---@type SigilConfig +local config = { target = { linux = "~/.config/nvim", macos = "~/Library/Application Support/nvim", @@ -38,7 +43,14 @@ return { windows = false, default = "~/.config/nvim", }, + ignore = { + "**/.DS_Store", + "**/*.tmp", + "cache/**", + }, } + +return config ``` ## Spec formats @@ -53,4 +65,5 @@ return { - Uses `SIGIL_REPO` env var to override the repo path. - Conflicts are detected (existing non-symlink files will stop apply). +- `config.ignore` supports gitignore-like globs (`*`, `?`, `**`) relative to each package `files/` directory. - Prefer `sigil add` over manual edits in `files/`. diff --git a/main.go b/main.go index f8c0122..7e91494 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,9 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" + "regexp" "runtime" "strings" @@ -20,8 +22,10 @@ const ( ) type packageConfig struct { - targets map[string]string - disabled map[string]bool + targets map[string]string + disabled map[string]bool + ignore []string + compiledIgnores []*regexp.Regexp } var errTargetDisabled = errors.New("target disabled for this platform") @@ -262,7 +266,7 @@ func applyCmd(args []string) error { return err } - if err := applyPackage(filesDir, targetRoot); err != nil { + if err := applyPackage(filesDir, targetRoot, cfg); err != nil { return fmt.Errorf("%s: %w", entry.Name(), err) } @@ -410,7 +414,12 @@ func addCmd(args []string) error { } } - return applyPackage(filesDir, targetRoot) + cfg, err := loadConfig(configPath) + if err != nil { + return err + } + + return applyPackage(filesDir, targetRoot, cfg) } func unlinkCmd(args []string) error { @@ -535,7 +544,7 @@ func removeCmd(args []string) error { return nil } -func applyPackage(filesDir, targetRoot string) error { +func applyPackage(filesDir, targetRoot string, cfg *packageConfig) error { if err := ensureDir(targetRoot); err != nil { return err } @@ -552,6 +561,14 @@ func applyPackage(filesDir, targetRoot string) error { if err != nil { return err } + + if shouldIgnorePath(rel, cfg) { + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + targetPath := filepath.Join(targetRoot, rel) if entry.IsDir() { @@ -653,7 +670,16 @@ func loadConfig(path string) (*packageConfig, error) { return nil, errors.New("config.target is empty") } - return &packageConfig{targets: targets, disabled: disabled}, nil + ignore, err := parseIgnore(tbl) + if err != nil { + return nil, err + } + compiledIgnores, err := compileIgnorePatterns(ignore) + if err != nil { + return nil, err + } + + return &packageConfig{targets: targets, disabled: disabled, ignore: ignore, compiledIgnores: compiledIgnores}, nil } func statusCmd() error { @@ -723,6 +749,108 @@ func selectTarget(cfg *packageConfig) (string, error) { return "", fmt.Errorf("missing target for %s and default", osKey) } +func parseIgnore(cfgTbl *lua.LTable) ([]string, error) { + ignoreVal := cfgTbl.RawGetString("ignore") + if ignoreVal == lua.LNil { + return nil, nil + } + + ignoreTbl, ok := ignoreVal.(*lua.LTable) + if !ok { + return nil, errors.New("config.ignore must be an array of strings") + } + + ignore := make([]string, 0, ignoreTbl.Len()) + var parseErr error + ignoreTbl.ForEach(func(k, v lua.LValue) { + if parseErr != nil { + return + } + + if _, ok := k.(lua.LNumber); !ok { + parseErr = errors.New("config.ignore must be an array of strings") + return + } + + s, ok := v.(lua.LString) + if !ok { + parseErr = errors.New("config.ignore must contain only strings") + return + } + + pattern := strings.TrimSpace(string(s)) + if pattern == "" { + parseErr = errors.New("config.ignore cannot contain empty patterns") + return + } + + ignore = append(ignore, pattern) + }) + if parseErr != nil { + return nil, parseErr + } + return ignore, nil +} + +func compileIgnorePatterns(patterns []string) ([]*regexp.Regexp, error) { + compiled := make([]*regexp.Regexp, 0, len(patterns)) + for _, pattern := range patterns { + re, err := globToRegexp(pattern) + if err != nil { + return nil, fmt.Errorf("invalid ignore pattern %q: %w", pattern, err) + } + compiled = append(compiled, re) + } + return compiled, nil +} + +func globToRegexp(pattern string) (*regexp.Regexp, error) { + pattern = strings.ReplaceAll(pattern, "\\", "/") + pattern = strings.TrimPrefix(pattern, "./") + if strings.HasPrefix(pattern, "/") { + pattern = strings.TrimPrefix(pattern, "/") + } + + var b strings.Builder + b.WriteString("^") + for i := 0; i < len(pattern); { + if i+1 < len(pattern) && pattern[i] == '*' && pattern[i+1] == '*' { + b.WriteString(".*") + i += 2 + continue + } + + ch := pattern[i] + switch ch { + case '*': + b.WriteString("[^/]*") + case '?': + b.WriteString("[^/]") + default: + b.WriteString(regexp.QuoteMeta(string(ch))) + } + i++ + } + b.WriteString("$") + + return regexp.Compile(b.String()) +} + +func shouldIgnorePath(rel string, cfg *packageConfig) bool { + if cfg == nil || len(cfg.compiledIgnores) == 0 { + return false + } + + normalized := path.Clean(filepath.ToSlash(rel)) + for _, re := range cfg.compiledIgnores { + if re.MatchString(normalized) { + return true + } + } + + return false +} + func repoPath() (string, error) { if override := os.Getenv("SIGIL_REPO"); override != "" { return filepath.Abs(expandHome(override)) @@ -772,7 +900,7 @@ func writeConfig(path, targetRoot string) error { } prettyTarget := compressHome(targetRoot) - content := fmt.Sprintf("return {\n\ttarget = {\n\t\t%s = %q,\n\t\tdefault = %q,\n\t},\n}\n", osKey, prettyTarget, prettyTarget) + content := fmt.Sprintf("---@class SigilConfig\n---@field target table\n---@field ignore? string[]\n\n---@type SigilConfig\nlocal config = {\n\ttarget = {\n\t\t%s = %q,\n\t\tdefault = %q,\n\t},\n\tignore = {\n\t\t-- \"**/.DS_Store\",\n\t\t-- \"**/*.tmp\",\n\t\t-- \"cache/**\",\n\t},\n}\n\nreturn config\n", osKey, prettyTarget, prettyTarget) return os.WriteFile(path, []byte(content), 0o644) } diff --git a/sigil b/sigil new file mode 100755 index 0000000..29b8b1d Binary files /dev/null and b/sigil differ