Added plugin support for Dequoter
All checks were successful
Release Build / build (push) Successful in 6m59s

This commit is contained in:
Leon Mika 2026-09-07 01:09:21 +00:00
parent cb9c05a57b
commit ac437b7409
5 changed files with 528 additions and 1 deletions

View file

@ -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)

43
app.go
View file

@ -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

View file

@ -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,
},

144
plugins.go Normal file
View file

@ -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), "-"), "-")
}

320
plugins_test.go Normal file
View file

@ -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")
}
})
}