Compare commits

...
Sign in to create a new pull request.

4 commits

Author SHA1 Message Date
9751be125e Moved the plugin directory
All checks were successful
Release Build / build (push) Successful in 1m53s
2026-09-09 21:08:46 +10:00
add62fbfa0 Added map-lines and upgraded UCL
All checks were successful
Release Build / build (push) Successful in 1m52s
2026-09-08 22:03:41 +10:00
18604ec5a6 Added plugin reload 2026-09-07 22:57:44 +00:00
ac437b7409 Added plugin support for Dequoter
All checks were successful
Release Build / build (push) Successful in 6m59s
2026-09-07 01:09:21 +00:00
11 changed files with 860 additions and 29 deletions

1
.idea/dequoter.iml generated
View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4"> <module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" /> <component name="Go" enabled="true" />
<component name="GoModuleSettings" enabled="true" />
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" /> <content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" /> <orderEntry type="inheritedJdk" />

View file

@ -10,6 +10,33 @@ 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. 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, Dequoter creates `$HOME/Library/Application Support/dev.lmika.dequoter/plugins` if needed,
then evaluates every file ending in `.ucl` found in it (including subdirectories).
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.
After editing plugin files, choose File → Reload Plugins (Shift+Cmd+R) to rescan the directory without
restarting. This discards the previous plugin filters and UCL interpreter state before loading the files again.
Use File → Open Plugins in Finder or File → Open Plugins in Terminal to access the directory.
If you have plugins in the old `$HOME/.config/dequoter/plugins` directory, move them to the new location.
## Download ## Download
[Download Release](/dequoter-darwin-arm64.zip) [Download Release](/dequoter-darwin-arm64.zip)

195
app.go
View file

@ -4,8 +4,10 @@ import (
"context" "context"
"fmt" "fmt"
"log" "log"
"os/exec"
"sort" "sort"
"strings" "strings"
"sync"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
"lmika.dev/pkg/modash/moslice" "lmika.dev/pkg/modash/moslice"
@ -15,15 +17,23 @@ import (
// App struct // App struct
type App struct { type App struct {
store *Store store *Store
uclInst *ucl.Inst
ctx context.Context // mu guards uclInst, plugins and ctx, which are swapped together when plugins are reloaded.
mu sync.RWMutex
uclInst *ucl.Inst
plugins *PluginFilters
ctx context.Context
// baseCtx is the raw Wails context supplied to startup. ctx is derived from it.
baseCtx context.Context
} }
// NewApp creates a new App application struct // newUCLInst creates a UCL interpreter with the standard modules plus the plugin module.
func NewApp(store *Store) *App { func newUCLInst(plugins *PluginFilters) *ucl.Inst {
uclInst := ucl.New( return ucl.New(
ucl.WithModule(plugins.Module()),
ucl.WithModule(builtins.Bytes()),
ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.CSV(nil)),
ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.Fns()),
ucl.WithModule(builtins.FS(nil)), ucl.WithModule(builtins.FS(nil)),
@ -33,17 +43,140 @@ func NewApp(store *Store) *App {
ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.OS()),
ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Strs()),
ucl.WithModule(builtins.Time()), ucl.WithModule(builtins.Time()),
ucl.WithModule(builtins.URLs()),
) )
return &App{ }
// NewApp creates a new App application struct
func NewApp(store *Store) *App {
app := &App{
store: store, store: store,
uclInst: uclInst, baseCtx: context.Background(),
} }
app.setUCLInst(newUCLInst(newPluginFilters()), nil)
return app
}
// setUCLInst installs a UCL interpreter and its plugin registry, rebuilding the app context so that
// filters which fetch the interpreter from the context see the new instance. A nil plugins argument
// is replaced with an empty registry.
func (a *App) setUCLInst(inst *ucl.Inst, plugins *PluginFilters) {
if plugins == nil {
plugins = newPluginFilters()
}
a.mu.Lock()
defer a.mu.Unlock()
a.uclInst = inst
a.plugins = plugins
a.ctx = context.WithValue(a.baseCtx, uclInstKey, inst)
}
// current returns the interpreter, plugin registry and context in use.
func (a *App) current() (*ucl.Inst, *PluginFilters, context.Context) {
a.mu.RLock()
defer a.mu.RUnlock()
return a.uclInst, a.plugins, a.ctx
}
// loadPluginsFrom builds a fresh interpreter and plugin registry, loads the plugin files found under
// dir into them, and installs them as the current instance. Returns the number of plugin filters
// registered. Any load errors are available from the new registry's Errors method.
func (a *App) loadPluginsFrom(dir string) int {
plugins := newPluginFilters()
inst := newUCLInst(plugins)
plugins.LoadDir(context.WithValue(a.baseCtx, uclInstKey, inst), inst, dir)
a.setUCLInst(inst, plugins)
return len(plugins.filters)
} }
// startup is called when the app starts. The context is saved // startup is called when the app starts. The context is saved
// so we can call the runtime methods // so we can call the runtime methods
func (a *App) startup(ctx context.Context) { func (a *App) startup(ctx context.Context) {
a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst) a.baseCtx = ctx
dir, err := ensurePluginDir()
if err != nil {
plugins := newPluginFilters()
plugins.recordError(err)
a.setUCLInst(newUCLInst(plugins), plugins)
return
}
a.loadPluginsFrom(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) {
_, plugins, _ := a.current()
a.reportPluginStatus(len(plugins.filters), false)
}
// ReloadPlugins discards the current plugin filters and UCL interpreter, rescans the plugin
// directory, and refreshes the command palette. Invoked from the File menu.
func (a *App) ReloadPlugins() {
dir, err := ensurePluginDir()
if err != nil {
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Cannot prepare plugin directory: %v", err),
Error: true,
})
return
}
count := a.loadPluginsFrom(dir)
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "processors-changed")
a.reportPluginStatus(count, true)
}
// openPluginDir opens the plugin directory using the specified macOS application.
func (a *App) openPluginDir(application string) {
dir, err := ensurePluginDir()
if err == nil {
err = exec.Command("/usr/bin/open", "-a", application, dir).Run()
}
if err != nil {
_, _, ctx := a.current()
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Cannot open plugins in %s: %v", application, err),
Error: true,
})
}
}
// reportPluginStatus shows plugin load errors in the status bar. If there are no errors and
// reloaded is true, a confirmation message is shown instead.
func (a *App) reportPluginStatus(filterCount int, reloaded bool) {
_, plugins, ctx := a.current()
if errs := plugins.Errors(); len(errs) > 0 {
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("%d plugin file(s) failed to load: %v", len(errs), errs[0]),
Error: true,
})
return
}
if reloaded {
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Plugins reloaded: %d filter(s)", filterCount),
})
}
}
// lookupProcessor finds a text processor by action key, preferring plugin-defined filters.
func (a *App) lookupProcessor(name string) (TextProcessor, bool) {
_, plugins, _ := a.current()
if proc, ok := plugins.filters[name]; ok {
return proc, true
}
proc, ok := TextFilters[name]
return proc, ok
} }
func (a *App) LoadCurrentBuffer() (string, error) { func (a *App) LoadCurrentBuffer() (string, error) {
@ -55,7 +188,17 @@ func (a *App) SaveCurrentBuffer(buffer string) error {
} }
func (a *App) ListProcessors() (resp []ListProcessorsResponse) { func (a *App) ListProcessors() (resp []ListProcessorsResponse) {
_, plugins, _ := a.current()
pluginLabels := make(map[string]struct{}, len(plugins.filters))
for k, v := range plugins.filters {
pluginLabels[v.Label] = struct{}{}
resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label})
}
for k, v := range TextFilters { for k, v := range TextFilters {
if _, shadowed := pluginLabels[v.Label]; shadowed {
continue
}
resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label}) resp = append(resp, ListProcessorsResponse{Name: k, Label: v.Label})
} }
sort.Slice(resp, func(i, j int) bool { return resp[i].Label < resp[j].Label }) sort.Slice(resp, func(i, j int) bool { return resp[i].Label < resp[j].Label })
@ -63,13 +206,15 @@ func (a *App) ListProcessors() (resp []ListProcessorsResponse) {
} }
func (a *App) TriggerTextProcess(action string) { func (a *App) TriggerTextProcess(action string) {
runtime.EventsEmit(a.ctx, "request-text-process", RequestTextProcess{ _, _, ctx := a.current()
runtime.EventsEmit(ctx, "request-text-process", RequestTextProcess{
Action: action, Action: action,
}) })
} }
func (a *App) PromptUser(label string, resp func(string)) { func (a *App) PromptUser(label string, resp func(string)) {
runtime.EventsOnce(a.ctx, "prompt-response", func(data ...interface{}) { _, _, ctx := a.current()
runtime.EventsOnce(ctx, "prompt-response", func(data ...interface{}) {
if len(data) == 0 { if len(data) == 0 {
return return
} }
@ -79,21 +224,29 @@ func (a *App) PromptUser(label string, resp func(string)) {
} }
resp(ans) resp(ans)
}) })
runtime.EventsEmit(a.ctx, "prompt-request", PromptRequest{ runtime.EventsEmit(ctx, "prompt-request", PromptRequest{
Label: label, Label: label,
}) })
} }
func (a *App) ProcessText(req ProcessTextRequest) { func (a *App) ProcessText(req ProcessTextRequest) {
filter, ok := TextFilters[req.Action] // Capture the context once so the whole operation runs against a single interpreter, even if
// plugins are reloaded part way through.
_, _, ctx := a.current()
filter, ok := a.lookupProcessor(req.Action)
if !ok { if !ok {
log.Printf("Unknown filter: [%s]", req.Action) log.Printf("Unknown filter: [%s]", req.Action)
runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Unknown filter: %s", req.Action),
Error: true,
})
return return
} }
applyFilter := func(filter TextFilter) { applyFilter := func(filter TextFilter) {
resp, err := moslice.MapWithError(req.Input, func(span TextSpan) (TextSpan, error) { resp, err := moslice.MapWithError(req.Input, func(span TextSpan) (TextSpan, error) {
outRes, err := filter(a.ctx, span.Text) outRes, err := filter(ctx, span.Text)
if err != nil { if err != nil {
return TextSpan{}, err return TextSpan{}, err
} }
@ -106,14 +259,14 @@ func (a *App) ProcessText(req ProcessTextRequest) {
}, nil }, nil
}) })
if err != nil { if err != nil {
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running filter: %v", err.Error()), Message: fmt.Sprintf("Error running filter: %v", err.Error()),
Error: true, Error: true,
}) })
return return
} }
runtime.EventsEmit(a.ctx, "process-text-response", ProcessTextResponse{ runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{
Output: resp, Output: resp,
}) })
} }
@ -128,25 +281,25 @@ func (a *App) ProcessText(req ProcessTextRequest) {
inBfr.WriteString(span.Text) inBfr.WriteString(span.Text)
} }
msg, err := filter.Analyze(a.ctx, inBfr.String()) msg, err := filter.Analyze(ctx, inBfr.String())
if err != nil { if err != nil {
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running analysis: %v", err.Error()), Message: fmt.Sprintf("Error running analysis: %v", err.Error()),
Error: true, Error: true,
}) })
return return
} }
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: msg, Message: msg,
}) })
case filter.Filter != nil: case filter.Filter != nil:
applyFilter(filter.Filter) applyFilter(filter.Filter)
case filter.FilterWithArg != nil: case filter.FilterWithArg != nil:
a.PromptUser(filter.FilterWithArg.Label, func(ans string) { a.PromptUser(filter.FilterWithArg.Label, func(ans string) {
filter, err := filter.FilterWithArg.OnConfirm(a.ctx, ans) filter, err := filter.FilterWithArg.OnConfirm(ctx, ans)
if err != nil { if err != nil {
runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{
Message: fmt.Sprintf("Error running filter: %v", err.Error()), Message: fmt.Sprintf("Error running filter: %v", err.Error()),
Error: true, Error: true,
}) })

View file

@ -12,15 +12,33 @@ export class CommandsController extends Controller {
async connect() { async connect() {
this._lastCommand = null; this._lastCommand = null;
this._options = [];
await this._loadProcessors();
window.runtime.EventsOn("processors-changed", () => {
this._loadProcessors();
});
}
async _loadProcessors() {
let processors = await ListProcessors(); let processors = await ListProcessors();
processors.forEach((processor) => {
let options = processors.map((processor) => {
let option = document.createElement("option"); let option = document.createElement("option");
option.value = processor.name; option.value = processor.name;
option.text = processor.label; option.text = processor.label;
this.commandSelectTarget.appendChild(option); return option;
}); });
this._options = Array.from(this.commandSelectTarget.options); this._options = options;
this.commandSelectTarget.replaceChildren(...options);
if (this._lastCommand !== null && !options.some(opt => opt.value === this._lastCommand)) {
this._lastCommand = null;
}
if (this.element.open) {
this._filterOptions(this.commandInputTarget.value);
}
} }
showCommands(ev) { showCommands(ev) {

View file

@ -10,6 +10,8 @@ export function ProcessText(arg1:main.ProcessTextRequest):Promise<void>;
export function PromptUser(arg1:string,arg2:any):Promise<void>; export function PromptUser(arg1:string,arg2:any):Promise<void>;
export function ReloadPlugins():Promise<void>;
export function SaveCurrentBuffer(arg1:string):Promise<void>; export function SaveCurrentBuffer(arg1:string):Promise<void>;
export function TriggerTextProcess(arg1:string):Promise<void>; export function TriggerTextProcess(arg1:string):Promise<void>;

View file

@ -18,6 +18,10 @@ export function PromptUser(arg1, arg2) {
return window['go']['main']['App']['PromptUser'](arg1, arg2); return window['go']['main']['App']['PromptUser'](arg1, arg2);
} }
export function ReloadPlugins() {
return window['go']['main']['App']['ReloadPlugins']();
}
export function SaveCurrentBuffer(arg1) { export function SaveCurrentBuffer(arg1) {
return window['go']['main']['App']['SaveCurrentBuffer'](arg1); return window['go']['main']['App']['SaveCurrentBuffer'](arg1);
} }

2
go.mod
View file

@ -9,7 +9,7 @@ require (
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
lmika.dev/pkg/modash v0.1.0 lmika.dev/pkg/modash v0.1.0
lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b
ucl.lmika.dev v0.1.3 ucl.lmika.dev v0.1.7
) )
require ( require (

4
go.sum
View file

@ -796,5 +796,5 @@ lmika.dev/pkg/modash v0.1.0/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcT
lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b h1:uarBEkpjAnpzO98btsA6ZDw9oGnJX8zSb8gZnCFl+6Y= lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b h1:uarBEkpjAnpzO98btsA6ZDw9oGnJX8zSb8gZnCFl+6Y=
lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b/go.mod h1:GefQ+wxW6L0p2TfB4fzoP7ihOgEt5Lb/1qgXhruVHos= lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b/go.mod h1:GefQ+wxW6L0p2TfB4fzoP7ihOgEt5Lb/1qgXhruVHos=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
ucl.lmika.dev v0.1.3 h1:Y2m+ORnLMA8boQmkXpW41VbkR3bwmnZjDLGVTYbZQic= ucl.lmika.dev v0.1.7 h1:3yfvWOhBnWpQIm4NhKrUBh5PajB1wzUWzoPPa8ltaVo=
ucl.lmika.dev v0.1.3/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= ucl.lmika.dev v0.1.7/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU=

14
main.go
View file

@ -37,9 +37,20 @@ func main() {
appMenu.Append(menu.AppMenu()) // On macOS platform, this must be done right after `NewMenu()` appMenu.Append(menu.AppMenu()) // On macOS platform, this must be done right after `NewMenu()`
} }
fileMenu := appMenu.AddSubmenu("File") fileMenu := appMenu.AddSubmenu("File")
fileMenu.AddText("Reload Plugins", keys.Combo("r", keys.CmdOrCtrlKey, keys.ShiftKey), func(_ *menu.CallbackData) {
app.ReloadPlugins()
})
fileMenu.AddText("Open Plugins in Finder", nil, func(_ *menu.CallbackData) {
app.openPluginDir("Finder")
})
fileMenu.AddText("Open Plugins in Terminal", nil, func(_ *menu.CallbackData) {
app.openPluginDir("Terminal")
})
fileMenu.AddSeparator()
fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) { fileMenu.AddText("Quit", keys.CmdOrCtrl("q"), func(_ *menu.CallbackData) {
// `rt` is an alias of "github.com/wailsapp/wails/v2/pkg/runtime" to prevent collision with standard package // `rt` is an alias of "github.com/wailsapp/wails/v2/pkg/runtime" to prevent collision with standard package
rt.Quit(app.ctx) _, _, ctx := app.current()
rt.Quit(ctx)
}) })
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
@ -57,6 +68,7 @@ func main() {
}, },
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1}, BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup, OnStartup: app.startup,
OnDomReady: app.domReady,
Bind: []interface{}{ Bind: []interface{}{
app, app,
}, },

202
plugins.go Normal file
View file

@ -0,0 +1,202 @@
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), "-"), "-")
}

412
plugins_test.go Normal file
View file

@ -0,0 +1,412 @@
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 }`)
if n := app.loadPluginsFrom(dir); n != 2 {
t.Fatalf("expected 2 filters loaded, got %d", n)
}
_ = ctx
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")
}
})
}
func TestApp_ReloadPlugins(t *testing.T) {
ctx := context.Background()
app := NewApp(nil)
dir := t.TempDir()
writePluginFile(t, dir, "a.ucl", `
proc helper { |s| strs:to-upper $s }
d:filter "Filter A" { |in| helper $in }
`)
if n := app.loadPluginsFrom(dir); n != 1 {
t.Fatalf("expected 1 filter, got %d", n)
}
firstInst, firstPlugins, firstCtx := app.current()
if uclInstFromContext(firstCtx) != firstInst {
t.Fatal("context does not carry the current interpreter")
}
if res, err := firstInst.EvalString(ctx, `helper "x"`); err != nil || res != "X" {
t.Fatalf("helper proc should be defined: %v %v", res, err)
}
filterA, ok := firstPlugins.filters["plugin:filter-a"]
if !ok {
t.Fatal("Filter A not registered")
}
// Replace a.ucl with b.ucl and reload.
if err := os.Remove(filepath.Join(dir, "a.ucl")); err != nil {
t.Fatal(err)
}
writePluginFile(t, dir, "b.ucl", `d:filter "Filter B" { |in| strs:to-lower $in }`)
if n := app.loadPluginsFrom(dir); n != 1 {
t.Fatalf("expected 1 filter after reload, got %d", n)
}
secondInst, secondPlugins, secondCtx := app.current()
if secondInst == firstInst {
t.Error("interpreter should have been rebuilt")
}
if uclInstFromContext(secondCtx) != secondInst {
t.Error("context should carry the new interpreter")
}
if _, ok := secondPlugins.filters["plugin:filter-a"]; ok {
t.Error("Filter A should have been deregistered")
}
if _, ok := app.lookupProcessor("plugin:filter-a"); ok {
t.Error("lookupProcessor should no longer find Filter A")
}
if proc, ok := app.lookupProcessor("plugin:filter-b"); !ok || proc.Label != "Filter B" {
t.Errorf("Filter B should be registered, got %v %v", proc, ok)
}
if _, err := secondInst.EvalString(ctx, `helper "x"`); err == nil {
t.Error("helper proc from the removed file should no longer be defined")
}
t.Run("filter captured before reload still works", func(t *testing.T) {
res, err := filterA.Filter(ctx, "abc")
if err != nil {
t.Fatal(err)
}
if res.Output != "ABC" {
t.Errorf("output = %q", res.Output)
}
})
t.Run("reload with bad file reports fresh errors", func(t *testing.T) {
writePluginFile(t, dir, "c.ucl", `d:filter "Broken" { |in|`)
n := app.loadPluginsFrom(dir)
_, plugins, _ := app.current()
if n != 1 || len(plugins.filters) != 1 {
t.Errorf("expected only Filter B to survive, got n=%d filters=%v", n, plugins.filters)
}
if errs := plugins.Errors(); len(errs) != 1 || !strings.Contains(errs[0].Error(), "c.ucl") {
t.Errorf("expected one error naming c.ucl, got %v", errs)
}
if err := os.Remove(filepath.Join(dir, "c.ucl")); err != nil {
t.Fatal(err)
}
app.loadPluginsFrom(dir)
if _, plugins, _ := app.current(); len(plugins.Errors()) != 0 {
t.Errorf("errors should be cleared after a clean reload, got %v", plugins.Errors())
}
})
}