diff --git a/.idea/dequoter.iml b/.idea/dequoter.iml index 5e764c4..9c408cc 100644 --- a/.idea/dequoter.iml +++ b/.idea/dequoter.iml @@ -1,6 +1,7 @@ + diff --git a/README.md b/README.md index f727d8a..ef8a722 100644 --- a/README.md +++ b/README.md @@ -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. +## 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 Release](/dequoter-darwin-arm64.zip) @@ -28,4 +55,4 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/app.go b/app.go index 7148f8b..8a467ef 100644 --- a/app.go +++ b/app.go @@ -4,8 +4,10 @@ import ( "context" "fmt" "log" + "os/exec" "sort" "strings" + "sync" "github.com/wailsapp/wails/v2/pkg/runtime" "lmika.dev/pkg/modash/moslice" @@ -15,15 +17,23 @@ import ( // App struct type App struct { - store *Store - uclInst *ucl.Inst + store *Store - 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 -func NewApp(store *Store) *App { - uclInst := ucl.New( +// newUCLInst creates a UCL interpreter with the standard modules plus the plugin module. +func newUCLInst(plugins *PluginFilters) *ucl.Inst { + return ucl.New( + ucl.WithModule(plugins.Module()), + ucl.WithModule(builtins.Bytes()), ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.FS(nil)), @@ -33,17 +43,140 @@ func NewApp(store *Store) *App { ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.Strs()), 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, - 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 // so we can call the runtime methods 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) { @@ -55,7 +188,17 @@ func (a *App) SaveCurrentBuffer(buffer string) error { } 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 { + 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 }) @@ -63,13 +206,15 @@ func (a *App) ListProcessors() (resp []ListProcessorsResponse) { } 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, }) } 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 { return } @@ -79,21 +224,29 @@ func (a *App) PromptUser(label string, resp func(string)) { } resp(ans) }) - runtime.EventsEmit(a.ctx, "prompt-request", PromptRequest{ + runtime.EventsEmit(ctx, "prompt-request", PromptRequest{ Label: label, }) } 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 { 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 } applyFilter := func(filter TextFilter) { 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 { return TextSpan{}, err } @@ -106,14 +259,14 @@ func (a *App) ProcessText(req ProcessTextRequest) { }, 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()), Error: true, }) return } - runtime.EventsEmit(a.ctx, "process-text-response", ProcessTextResponse{ + runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{ Output: resp, }) } @@ -128,25 +281,25 @@ func (a *App) ProcessText(req ProcessTextRequest) { inBfr.WriteString(span.Text) } - msg, err := filter.Analyze(a.ctx, inBfr.String()) + msg, err := filter.Analyze(ctx, inBfr.String()) 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()), Error: true, }) return } - runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: msg, }) case filter.Filter != nil: applyFilter(filter.Filter) case filter.FilterWithArg != nil: 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 { - runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running filter: %v", err.Error()), Error: true, }) diff --git a/frontend/src/controllers/commands_controller.js b/frontend/src/controllers/commands_controller.js index 5d74380..928863a 100644 --- a/frontend/src/controllers/commands_controller.js +++ b/frontend/src/controllers/commands_controller.js @@ -12,15 +12,33 @@ export class CommandsController extends Controller { async connect() { this._lastCommand = null; + this._options = []; + await this._loadProcessors(); + window.runtime.EventsOn("processors-changed", () => { + this._loadProcessors(); + }); + } + + async _loadProcessors() { let processors = await ListProcessors(); - processors.forEach((processor) => { + + let options = processors.map((processor) => { let option = document.createElement("option"); option.value = processor.name; 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) { diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 56c40cf..f44f801 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -10,6 +10,8 @@ export function ProcessText(arg1:main.ProcessTextRequest):Promise; export function PromptUser(arg1:string,arg2:any):Promise; +export function ReloadPlugins():Promise; + export function SaveCurrentBuffer(arg1:string):Promise; export function TriggerTextProcess(arg1:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 144d9f3..78a313d 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -18,6 +18,10 @@ export function PromptUser(arg1, arg2) { return window['go']['main']['App']['PromptUser'](arg1, arg2); } +export function ReloadPlugins() { + return window['go']['main']['App']['ReloadPlugins'](); +} + export function SaveCurrentBuffer(arg1) { return window['go']['main']['App']['SaveCurrentBuffer'](arg1); } diff --git a/go.mod b/go.mod index 9a8d136..d967a05 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 lmika.dev/pkg/modash v0.1.0 lmika.dev/pkg/progdoc v0.0.0-20260207235039-984257fb414b - ucl.lmika.dev v0.1.3 + ucl.lmika.dev v0.1.7 ) require ( diff --git a/go.sum b/go.sum index 04dd12d..f3643af 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:GefQ+wxW6L0p2TfB4fzoP7ihOgEt5Lb/1qgXhruVHos= 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.3/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= +ucl.lmika.dev v0.1.7 h1:3yfvWOhBnWpQIm4NhKrUBh5PajB1wzUWzoPPa8ltaVo= +ucl.lmika.dev v0.1.7/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= diff --git a/main.go b/main.go index 2145e34..167c39f 100644 --- a/main.go +++ b/main.go @@ -37,9 +37,20 @@ func main() { appMenu.Append(menu.AppMenu()) // On macOS platform, this must be done right after `NewMenu()` } 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) { // `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" { @@ -57,6 +68,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..036062e --- /dev/null +++ b/plugins.go @@ -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), "-"), "-") +} diff --git a/plugins_test.go b/plugins_test.go new file mode 100644 index 0000000..f82f69e --- /dev/null +++ b/plugins_test.go @@ -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()) + } + }) +}