diff --git a/.idea/dequoter.iml b/.idea/dequoter.iml index 9c408cc..5e764c4 100644 --- a/.idea/dequoter.iml +++ b/.idea/dequoter.iml @@ -1,7 +1,6 @@ - diff --git a/README.md b/README.md index ef8a722..f727d8a 100644 --- a/README.md +++ b/README.md @@ -10,33 +10,6 @@ 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) @@ -55,4 +28,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. +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 diff --git a/app.go b/app.go index 8a467ef..7148f8b 100644 --- a/app.go +++ b/app.go @@ -4,10 +4,8 @@ import ( "context" "fmt" "log" - "os/exec" "sort" "strings" - "sync" "github.com/wailsapp/wails/v2/pkg/runtime" "lmika.dev/pkg/modash/moslice" @@ -17,23 +15,15 @@ import ( // App struct type App struct { - store *Store - - // mu guards uclInst, plugins and ctx, which are swapped together when plugins are reloaded. - mu sync.RWMutex + store *Store 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 + ctx context.Context } -// 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()), +// NewApp creates a new App application struct +func NewApp(store *Store) *App { + uclInst := ucl.New( ucl.WithModule(builtins.CSV(nil)), ucl.WithModule(builtins.Fns()), ucl.WithModule(builtins.FS(nil)), @@ -43,140 +33,17 @@ func newUCLInst(plugins *PluginFilters) *ucl.Inst { ucl.WithModule(builtins.OS()), ucl.WithModule(builtins.Strs()), ucl.WithModule(builtins.Time()), - ucl.WithModule(builtins.URLs()), ) -} - -// NewApp creates a new App application struct -func NewApp(store *Store) *App { - app := &App{ + return &App{ store: store, - baseCtx: context.Background(), + uclInst: uclInst, } - 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.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 + a.ctx = context.WithValue(ctx, uclInstKey, a.uclInst) } func (a *App) LoadCurrentBuffer() (string, error) { @@ -188,17 +55,7 @@ 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 }) @@ -206,15 +63,13 @@ func (a *App) ListProcessors() (resp []ListProcessorsResponse) { } func (a *App) TriggerTextProcess(action string) { - _, _, ctx := a.current() - runtime.EventsEmit(ctx, "request-text-process", RequestTextProcess{ + runtime.EventsEmit(a.ctx, "request-text-process", RequestTextProcess{ Action: action, }) } func (a *App) PromptUser(label string, resp func(string)) { - _, _, ctx := a.current() - runtime.EventsOnce(ctx, "prompt-response", func(data ...interface{}) { + runtime.EventsOnce(a.ctx, "prompt-response", func(data ...interface{}) { if len(data) == 0 { return } @@ -224,29 +79,21 @@ func (a *App) PromptUser(label string, resp func(string)) { } resp(ans) }) - runtime.EventsEmit(ctx, "prompt-request", PromptRequest{ + runtime.EventsEmit(a.ctx, "prompt-request", PromptRequest{ Label: label, }) } func (a *App) ProcessText(req ProcessTextRequest) { - // 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) + filter, ok := TextFilters[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(ctx, span.Text) + outRes, err := filter(a.ctx, span.Text) if err != nil { return TextSpan{}, err } @@ -259,14 +106,14 @@ func (a *App) ProcessText(req ProcessTextRequest) { }, nil }) if err != nil { - runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running filter: %v", err.Error()), Error: true, }) return } - runtime.EventsEmit(ctx, "process-text-response", ProcessTextResponse{ + runtime.EventsEmit(a.ctx, "process-text-response", ProcessTextResponse{ Output: resp, }) } @@ -281,25 +128,25 @@ func (a *App) ProcessText(req ProcessTextRequest) { inBfr.WriteString(span.Text) } - msg, err := filter.Analyze(ctx, inBfr.String()) + msg, err := filter.Analyze(a.ctx, inBfr.String()) if err != nil { - runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(a.ctx, "set-statusbar-message", SetStatusbarMessage{ Message: fmt.Sprintf("Error running analysis: %v", err.Error()), Error: true, }) return } - runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(a.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(ctx, ans) + filter, err := filter.FilterWithArg.OnConfirm(a.ctx, ans) if err != nil { - runtime.EventsEmit(ctx, "set-statusbar-message", SetStatusbarMessage{ + runtime.EventsEmit(a.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 928863a..5d74380 100644 --- a/frontend/src/controllers/commands_controller.js +++ b/frontend/src/controllers/commands_controller.js @@ -12,33 +12,15 @@ 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(); - - let options = processors.map((processor) => { + processors.forEach((processor) => { let option = document.createElement("option"); option.value = processor.name; option.text = processor.label; - return option; + this.commandSelectTarget.appendChild(option); }); - 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); - } + this._options = Array.from(this.commandSelectTarget.options); } showCommands(ev) { diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index f44f801..56c40cf 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -10,8 +10,6 @@ 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 78a313d..144d9f3 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -18,10 +18,6 @@ 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 d967a05..9a8d136 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.7 + ucl.lmika.dev v0.1.3 ) require ( diff --git a/go.sum b/go.sum index f3643af..04dd12d 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.7 h1:3yfvWOhBnWpQIm4NhKrUBh5PajB1wzUWzoPPa8ltaVo= -ucl.lmika.dev v0.1.7/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= +ucl.lmika.dev v0.1.3 h1:Y2m+ORnLMA8boQmkXpW41VbkR3bwmnZjDLGVTYbZQic= +ucl.lmika.dev v0.1.3/go.mod h1:Hjl6Udph2yuOPcBSytz4SFJVt5MZbM5JyiSAyTMTbNU= diff --git a/main.go b/main.go index 167c39f..2145e34 100644 --- a/main.go +++ b/main.go @@ -37,20 +37,9 @@ 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 - _, _, ctx := app.current() - rt.Quit(ctx) + rt.Quit(app.ctx) }) if runtime.GOOS == "darwin" { @@ -68,7 +57,6 @@ 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 deleted file mode 100644 index 036062e..0000000 --- a/plugins.go +++ /dev/null @@ -1,202 +0,0 @@ -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 deleted file mode 100644 index f82f69e..0000000 --- a/plugins_test.go +++ /dev/null @@ -1,412 +0,0 @@ -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()) - } - }) -}