Compare commits
4 commits
main
...
feature/pl
| Author | SHA1 | Date | |
|---|---|---|---|
| 9751be125e | |||
| add62fbfa0 | |||
| 18604ec5a6 | |||
| ac437b7409 |
11 changed files with 860 additions and 29 deletions
1
.idea/dequoter.iml
generated
1
.idea/dequoter.iml
generated
|
|
@ -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" />
|
||||||
|
|
|
||||||
29
README.md
29
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.
|
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)
|
||||||
|
|
@ -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
|
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
|
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
|
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.
|
||||||
|
|
|
||||||
195
app.go
195
app.go
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
2
frontend/wailsjs/go/main/App.d.ts
vendored
2
frontend/wailsjs/go/main/App.d.ts
vendored
|
|
@ -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>;
|
||||||
|
|
|
||||||
|
|
@ -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
2
go.mod
|
|
@ -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
4
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 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
14
main.go
|
|
@ -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
202
plugins.go
Normal 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
412
plugins_test.go
Normal 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())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue