From ac437b74099a87398de70c0f85362115946e7dd0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 7 Sep 2026 01:09:21 +0000 Subject: [PATCH] Added plugin support for Dequoter --- README.md | 21 ++++ app.go | 43 ++++++- main.go | 1 + plugins.go | 144 ++++++++++++++++++++++ plugins_test.go | 320 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 528 insertions(+), 1 deletion(-) create mode 100644 plugins.go create mode 100644 plugins_test.go diff --git a/README.md b/README.md index f727d8a..b17f494 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,27 @@ where all your chances will be lost when you close it. This project uses [Wails](https://wails.io) to build a desktop application with a Go backend and a Vite frontend. +## Plugins + +Dequoter can be extended with new filters written in [UCL](https://ucl.lmika.dev). +On startup, if the directory `$HOME/.config/dequoter/plugins` exists, every file ending in `.ucl` +found in it (including subdirectories) is evaluated. Filters are defined with the `d:filter` builtin: + +``` +d:filter "String: To Upper Case" { |in| + strs:to-upper $in +} +``` + +`d:filter` takes a label, which is what appears in the command palette (Cmd+P), and a proc. The proc is +called with the text to process as its only argument. The result of the proc becomes the filter output: +strings are used as-is, other values are converted to their string form, and a proc that returns nothing +is reported as an error. Errors raised with `error` are shown in the status bar. + +A plugin filter with the same label as a built-in filter replaces it in the palette. If two plugin files +define the same label, the one loaded later wins (files are loaded in lexical path order). Files that +fail to load are skipped and reported in the status bar; the remaining files are still loaded. + ## Download [Download Release](/dequoter-darwin-arm64.zip) diff --git a/app.go b/app.go index 7148f8b..3a8f012 100644 --- a/app.go +++ b/app.go @@ -17,13 +17,16 @@ import ( type App struct { store *Store uclInst *ucl.Inst + plugins *PluginFilters ctx context.Context } // NewApp creates a new App application struct func NewApp(store *Store) *App { + plugins := newPluginFilters() uclInst := ucl.New( + ucl.WithModule(plugins.Module()), ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.FS(nil)), @@ -37,6 +40,7 @@ func NewApp(store *Store) *App { return &App{ store: store, uclInst: uclInst, + plugins: plugins, } } @@ -44,6 +48,35 @@ func NewApp(store *Store) *App { // so we can call the runtime methods func (a *App) startup(ctx context.Context) { a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst) + + dir, err := pluginDir() + if err != nil { + log.Printf("cannot determine plugin directory: %v", err) + return + } + a.plugins.LoadDir(a.ctx, a.uclInst, dir) +} + +// domReady is called once the frontend has loaded. Reports any plugin load errors in the status bar. +func (a *App) domReady(ctx context.Context) { + errs := a.plugins.Errors() + if len(errs) == 0 { + return + } + + runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ + Message: fmt.Sprintf("%d plugin file(s) failed to load: %v", len(errs), errs[0]), + Error: true, + }) +} + +// lookupProcessor finds a text processor by action key, preferring plugin-defined filters. +func (a *App) lookupProcessor(name string) (TextProcessor, bool) { + if proc, ok := a.plugins.filters[name]; ok { + return proc, true + } + proc, ok := TextFilters[name] + return proc, ok } func (a *App) LoadCurrentBuffer() (string, error) { @@ -55,7 +88,15 @@ func (a *App) SaveCurrentBuffer(buffer string) error { } func (a *App) ListProcessors() (resp []ListProcessorsResponse) { + pluginLabels := make(map[string]struct{}, len(a.plugins.filters)) + for k, v := range a.plugins.filters { + pluginLabels[v.Label] = struct{}{} + resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label}) + } for k, v := range TextFilters { + if _, shadowed := pluginLabels[v.Label]; shadowed { + continue + } resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label}) } sort.Slice(resp, func(i, j int) bool { return resp[i].Label < resp[j].Label }) @@ -85,7 +126,7 @@ func (a *App) PromptUser(label string, resp func(string)) { } func (a *App) ProcessText(req ProcessTextRequest) { - filter, ok := TextFilters[req.Action] + filter, ok := a.lookupProcessor(req.Action) if !ok { log.Printf("Unknown filter: [%s]", req.Action) return diff --git a/main.go b/main.go index 2145e34..cc7ade6 100644 --- a/main.go +++ b/main.go @@ -57,6 +57,7 @@ func main() { }, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, OnStartup: app.startup, + OnDomReady: app.domReady, Bind: []interface{}{ app, }, diff --git a/plugins.go b/plugins.go new file mode 100644 index 0000000..86dc977 --- /dev/null +++ b/plugins.go @@ -0,0 +1,144 @@ +package main + +import ( + "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 { + inst *ucl.Inst + 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, + }, + } +} + +// 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 +} + +// 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: $HOME/.config/dequoter/plugins +func pluginDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".config", "dequoter", "plugins"), 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), "-"), "-") +} diff --git a/plugins_test.go b/plugins_test.go new file mode 100644 index 0000000..522d2e6 --- /dev/null +++ b/plugins_test.go @@ -0,0 +1,320 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "ucl.lmika.dev/ucl" + "ucl.lmika.dev/ucl/builtins" +) + +func newTestPlugins() (*PluginFilters, *ucl.Inst) { + plugins := newPluginFilters() + inst := ucl.New( + ucl.WithModule(plugins.Module()), + ucl.WithModule(builtins.Strs()), + ) + return plugins, inst +} + +func writePluginFile(t *testing.T, dir, name, content string) { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func TestPluginFilters_LoadDir(t *testing.T) { + ctx := context.Background() + + t.Run("registers filter from upper-case example", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "upper.ucl", ` +d:filter "String: To Upper Case" { |in| + strs:to-upper $in +} +`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + proc, ok := plugins.filters["plugin:string-to-upper-case"] + if !ok { + t.Fatalf("filter not registered, have %v", plugins.filters) + } + if proc.Label != "String: To Upper Case" { + t.Errorf("label = %q", proc.Label) + } + res, err := proc.Filter(ctx, "abc") + if err != nil { + t.Fatal(err) + } + if res.Output != "ABC" || res.Append { + t.Errorf("got %+v", res) + } + }) + + t.Run("finds nested files and ignores non-ucl files", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "sub/dir/nested.ucl", `d:filter "Nested" { |in| $in }`) + writePluginFile(t, dir, "notes.txt", `d:filter "Ignored" { |in| $in }`) + writePluginFile(t, dir, "README.md", `this is not ucl {`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + if _, ok := plugins.filters["plugin:nested"]; !ok { + t.Error("nested filter not registered") + } + if _, ok := plugins.filters["plugin:ignored"]; ok { + t.Error("non-ucl file should have been ignored") + } + }) + + t.Run("missing directory is not an error", func(t *testing.T) { + plugins, inst := newTestPlugins() + + plugins.LoadDir(ctx, inst, filepath.Join(t.TempDir(), "does-not-exist")) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + if len(plugins.filters) != 0 { + t.Errorf("unexpected filters: %v", plugins.filters) + } + }) + + t.Run("bad file is reported and sibling still loads", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "a-bad.ucl", `d:filter "Broken" { |in| `) + writePluginFile(t, dir, "b-good.ucl", `d:filter "Good" { |in| $in }`) + writePluginFile(t, dir, "c-bad-usage.ucl", `d:filter "Only Label"`) + + plugins.LoadDir(ctx, inst, dir) + + errs := plugins.Errors() + if len(errs) != 2 { + t.Fatalf("expected 2 errors, got %v", errs) + } + if !strings.Contains(errs[0].Error(), "a-bad.ucl") { + t.Errorf("first error should name the file: %v", errs[0]) + } + if !strings.Contains(errs[1].Error(), "c-bad-usage.ucl") { + t.Errorf("second error should name the file: %v", errs[1]) + } + if _, ok := plugins.filters["plugin:good"]; !ok { + t.Error("good filter not registered") + } + if _, ok := plugins.filters["plugin:broken"]; ok { + t.Error("broken filter should not be registered") + } + }) + + t.Run("later definition with same label wins", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "01-first.ucl", `d:filter "Dup" { |in| "first" }`) + writePluginFile(t, dir, "02-second.ucl", `d:filter "Dup" { |in| "second" }`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.filters) != 1 { + t.Fatalf("expected 1 filter, got %v", plugins.filters) + } + res, err := plugins.filters["plugin:dup"].Filter(ctx, "x") + if err != nil { + t.Fatal(err) + } + if res.Output != "second" { + t.Errorf("output = %q", res.Output) + } + }) + + t.Run("procs defined in plugin files are reusable", func(t *testing.T) { + plugins, inst := newTestPlugins() + dir := t.TempDir() + writePluginFile(t, dir, "helpers.ucl", ` +proc shout { |s| strs:to-upper $s } +d:filter "Shout" { |in| shout $in } +`) + + plugins.LoadDir(ctx, inst, dir) + + if len(plugins.Errors()) != 0 { + t.Fatalf("unexpected errors: %v", plugins.Errors()) + } + res, err := plugins.filters["plugin:shout"].Filter(ctx, "hi") + if err != nil { + t.Fatal(err) + } + if res.Output != "HI" { + t.Errorf("output = %q", res.Output) + } + }) +} + +func TestPluginFilters_ProcResults(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + script string + input string + want string + wantErr string + }{ + { + name: "string result used as-is", + script: `d:filter "F" { |in| strs:to-upper $in }`, + input: "abc", + want: "ABC", + }, + { + name: "non-string result is stringified", + script: `d:filter "F" { |in| add 40 2 }`, + input: "ignored", + want: "42", + }, + { + name: "nil result is an error", + script: `d:filter "F" { |in| }`, + input: "abc", + wantErr: "filter returned no value", + }, + { + name: "error raised in proc propagates", + script: `d:filter "F" { |in| error "boom" }`, + input: "abc", + wantErr: "boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugins, inst := newTestPlugins() + if _, err := inst.EvalString(ctx, tt.script); err != nil { + t.Fatal(err) + } + proc, ok := plugins.filters["plugin:f"] + if !ok { + t.Fatalf("filter not registered") + } + + res, err := proc.Filter(ctx, tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v (res %+v)", tt.wantErr, err, res) + } + return + } + if err != nil { + t.Fatal(err) + } + if res.Output != tt.want { + t.Errorf("output = %q, want %q", res.Output, tt.want) + } + }) + } +} + +func TestPluginFilters_FilterBuiltinValidation(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + script string + }{ + {name: "missing proc", script: `d:filter "Label"`}, + {name: "no args", script: `d:filter`}, + {name: "empty label", script: `d:filter " " { |in| $in }`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + plugins, inst := newTestPlugins() + if _, err := inst.EvalString(ctx, tt.script); err == nil { + t.Fatal("expected an error") + } + if len(plugins.filters) != 0 { + t.Errorf("no filter should be registered, got %v", plugins.filters) + } + }) + } +} + +func TestSlugify(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"String: To Upper Case", "string-to-upper-case"}, + {" Lines: Go Template… ", "lines-go-template"}, + {"already-a-slug", "already-a-slug"}, + {"MiXeD CaSe 123", "mixed-case-123"}, + {"---", ""}, + } + for _, tt := range tests { + if got := slugify(tt.in); got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestApp_PluginIntegration(t *testing.T) { + ctx := context.Background() + app := NewApp(nil) + dir := t.TempDir() + writePluginFile(t, dir, "upper.ucl", `d:filter "String: To Upper Case" { |in| strs:to-upper $in }`) + writePluginFile(t, dir, "rev.ucl", `d:filter "Custom: Echo" { |in| $in }`) + + app.plugins.LoadDir(ctx, app.uclInst, dir) + + t.Run("ListProcessors shadows built-in by label", func(t *testing.T) { + resp := app.ListProcessors() + + byLabel := map[string][]string{} + for _, r := range resp { + byLabel[r.Label] = append(byLabel[r.Label], r.Name) + } + if names := byLabel["String: To Upper Case"]; len(names) != 1 || names[0] != "plugin:string-to-upper-case" { + t.Errorf("expected only the plugin entry for the shadowed label, got %v", names) + } + if names := byLabel["Custom: Echo"]; len(names) != 1 || names[0] != "plugin:custom-echo" { + t.Errorf("expected plugin entry, got %v", names) + } + if names := byLabel["String: To Lower Case"]; len(names) != 1 || names[0] != "lower-case" { + t.Errorf("built-in should be unaffected, got %v", names) + } + for i := 1; i < len(resp); i++ { + if resp[i-1].Label > resp[i].Label { + t.Errorf("response not sorted by label at %d: %q > %q", i, resp[i-1].Label, resp[i].Label) + } + } + }) + + t.Run("lookupProcessor prefers plugin then falls back to built-in", func(t *testing.T) { + if proc, ok := app.lookupProcessor("plugin:custom-echo"); !ok || proc.Label != "Custom: Echo" { + t.Errorf("plugin lookup failed: %v %v", proc, ok) + } + if proc, ok := app.lookupProcessor("lower-case"); !ok || proc.Label != "String: To Lower Case" { + t.Errorf("built-in lookup failed: %v %v", proc, ok) + } + if _, ok := app.lookupProcessor("nope"); ok { + t.Error("unknown key should not be found") + } + }) +}