2026-09-07 01:09:21 +00:00
|
|
|
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 }`)
|
|
|
|
|
|
2026-09-07 22:57:44 +00:00
|
|
|
if n := app.loadPluginsFrom(dir); n != 2 {
|
|
|
|
|
t.Fatalf("expected 2 filters loaded, got %d", n)
|
|
|
|
|
}
|
|
|
|
|
_ = ctx
|
2026-09-07 01:09:21 +00:00
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
2026-09-07 22:57:44 +00:00
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|