package main import ( "bufio" "bytes" "context" "errors" "fmt" "io/fs" "log" "os" "path/filepath" "regexp" "strings" "ucl.lmika.dev/ucl" ) const pluginKeyPrefix = "plugin:" // PluginFilters is the registry of text filters defined by UCL plugin files. type PluginFilters struct { filters map[string]TextProcessor errors []error } func newPluginFilters() *PluginFilters { return &PluginFilters{ filters: make(map[string]TextProcessor), } } // Module returns the "d" UCL module, exposing d:filter. func (p *PluginFilters) Module() ucl.Module { return ucl.Module{ Name: "d", Builtins: map[string]ucl.BuiltinHandler{ "filter": p.filterBuiltin, "map-lines": p.mapLinesBuiltin, }, } } // filterBuiltin implements `d:filter LABEL PROC`. PROC receives the filter input as its only // argument and its result becomes the filter output. func (p *PluginFilters) filterBuiltin(ctx context.Context, args ucl.CallArgs) (any, error) { var ( label string proc ucl.Invokable ) if err := args.Bind(&label, &proc); err != nil { return nil, fmt.Errorf("d:filter: expected LABEL and PROC: %w", err) } if strings.TrimSpace(label) == "" { return nil, errors.New("d:filter: label must not be empty") } if proc.IsNil() { return nil, errors.New("d:filter: PROC must not be nil") } p.filters[pluginKeyPrefix+slugify(label)] = TextProcessor{ Label: label, Filter: func(ctx context.Context, input string) (TextFilterResponse, error) { res, err := proc.Invoke(ctx, input) if err != nil { return TextFilterResponse{}, err } switch v := res.(type) { case nil: return TextFilterResponse{}, errors.New("filter returned no value") case string: return TextFilterResponse{Output: v}, nil default: return TextFilterResponse{Output: fmt.Sprint(v)}, nil } }, } return nil, nil } // mapLinesBuiltin implements `d:map-lines INPUT PROC` which applies a transform for each line of the input. func (p *PluginFilters) mapLinesBuiltin(ctx context.Context, args ucl.CallArgs) (any, error) { var ( input string proc ucl.Invokable ) if err := args.Bind(&input, &proc); err != nil { return nil, fmt.Errorf("d:map-lines: expected INPUT and PROC: %w", err) } var ( dst bytes.Buffer dstLine bytes.Buffer ) scnr := bufio.NewScanner(strings.NewReader(input)) isFirst := true for scnr.Scan() { dstLine.Reset() line := scnr.Text() out, err := proc.Invoke(ctx, line) if err != nil { return TextFilterResponse{}, err } else if out == nil { continue } else if strOut, ok := out.(fmt.Stringer); ok { dstLine.WriteString(strOut.String()) } else { dstLine.WriteString(fmt.Sprint(out)) } if isFirst { isFirst = false } else { dst.WriteString("\n") } dst.WriteString(dstLine.String()) } return dst.String(), nil } // LoadDir evaluates every *.ucl file found under dir (recursively, in lexical order). Files // that fail to load are recorded in Errors() and skipped; loading continues with the rest. // A missing directory is not an error. func (p *PluginFilters) LoadDir(ctx context.Context, inst *ucl.Inst, dir string) { if _, err := os.Stat(dir); err != nil { if !errors.Is(err, fs.ErrNotExist) { p.recordError(fmt.Errorf("%s: %w", dir, err)) } return } _ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil { p.recordError(fmt.Errorf("%s: %w", path, err)) return nil } if d.IsDir() || !strings.EqualFold(filepath.Ext(path), ".ucl") { return nil } if err := p.loadFile(ctx, inst, path); err != nil { p.recordError(fmt.Errorf("%s: %w", path, err)) } return nil }) } func (p *PluginFilters) loadFile(ctx context.Context, inst *ucl.Inst, path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() _, err = inst.Eval(ctx, f) return err } func (p *PluginFilters) recordError(err error) { log.Printf("plugin load error: %v", err) p.errors = append(p.errors, err) } // Errors returns the errors encountered while loading plugin files. func (p *PluginFilters) Errors() []error { return p.errors } // pluginDir returns the plugin directory under macOS Application Support. func pluginDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, "Library", "Application Support", "dev.lmika.dequoter", "Plugins"), nil } func ensurePluginDir() (string, error) { dir, err := pluginDir() if err != nil { return "", err } if err := os.MkdirAll(dir, 0755); err != nil { return "", fmt.Errorf("create plugin directory: %w", err) } return dir, nil } var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`) // slugify converts a label into a key-safe slug: lower-cased, with runs of characters other than // [a-z0-9] replaced by a single hyphen and leading/trailing hyphens removed. func slugify(label string) string { return strings.Trim(nonSlugChars.ReplaceAllString(strings.ToLower(label), "-"), "-") }