Skip to content

Commit 34d35d9

Browse files
authored
Feat/func case ins (#992)
* feat: normalize function names to lowercase for case-insensitive consistency * test: add tests for case-insensitive namespace and function registration * refactor: remove `hostfunction` package and migrate functionality to `runtime` * refactor: switch from canonical to declared function name handling for improved consistency * test: update namespace casing tests for clarity and consistency * test: update registry test to use consistent casing for function names
1 parent aa2ec82 commit 34d35d9

211 files changed

Lines changed: 1558 additions & 744 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

compat/compat_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ func TestFunctions_RegisteredFunctions(t *testing.T) {
183183
}
184184

185185
if !found["CUSTOM_A"] || !found["CUSTOM_B"] {
186-
t.Fatalf("expected CUSTOM_A and CUSTOM_B in registered functions, got %v", names)
186+
t.Fatalf("expected declared CUSTOM_A and CUSTOM_B in registered functions, got %v", names)
187187
}
188188
}
189189

compat/compiler/compiler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func (c *Compiler) Namespace(name string) core.Namespace {
104104
func (c *Compiler) RegisterFunction(name string, fun core.Function) error {
105105
fns := c.library.Function()
106106
if fns.Has(name) {
107-
return nil // already registered — match v1 silent-overwrite semantics
107+
return nil
108108
}
109109

110110
fns.Var().Add(name, core.UnwrapFunction(fun))

compat/compiler/compiler_test.go

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ func TestCompiler_RegisterFunction(t *testing.T) {
4545
t.Fatalf("unexpected error: %v", err)
4646
}
4747

48+
if err := c.RegisterFunction("hello", func(context.Context, ...core.Value) (core.Value, error) {
49+
return core.WrapValue(runtime.NewString("replacement")), nil
50+
}); err != nil {
51+
t.Fatalf("case-only duplicate should be silently skipped: %v", err)
52+
}
53+
4854
prog, err := c.Compile(`RETURN HELLO()`)
4955
if err != nil {
5056
t.Fatalf("compile error: %v", err)
@@ -63,6 +69,57 @@ func TestCompiler_RegisterFunction(t *testing.T) {
6369
if result != "hello" {
6470
t.Fatalf("expected \"hello\", got %q", result)
6571
}
72+
73+
if got := c.RegisteredFunctions(); len(got) != 1 || got[0] != "HELLO" {
74+
t.Fatalf("registered functions = %v, want original HELLO spelling", got)
75+
}
76+
}
77+
78+
func TestCompiler_NamespaceRegistrationIsCaseInsensitive(t *testing.T) {
79+
c := compiler.New()
80+
ns := c.Namespace("DB").Namespace("POSTGRES")
81+
82+
err := ns.RegisterFunction("QUERY", func(context.Context, ...core.Value) (core.Value, error) {
83+
return core.WrapValue(runtime.NewString("ok")), nil
84+
})
85+
if err != nil {
86+
t.Fatalf("register namespaced function: %v", err)
87+
}
88+
89+
if err := c.Namespace("db").Namespace("postgres").RegisterFunction("query", func(context.Context, ...core.Value) (core.Value, error) {
90+
return core.WrapValue(runtime.None), nil
91+
}); err == nil {
92+
t.Fatal("expected duplicate normalized namespace member to fail")
93+
}
94+
95+
for _, query := range []string{
96+
"RETURN db::postgres::query()",
97+
"RETURN DB::POSTGRES::QUERY()",
98+
"RETURN Db::Postgres::Query()",
99+
} {
100+
prog, compileErr := c.Compile(query)
101+
if compileErr != nil {
102+
t.Fatalf("compile %q: %v", query, compileErr)
103+
}
104+
105+
out, runErr := prog.Run(t.Context())
106+
if runErr != nil {
107+
t.Fatalf("run %q: %v", query, runErr)
108+
}
109+
110+
var result string
111+
if err := json.Unmarshal(out, &result); err != nil {
112+
t.Fatalf("unmarshal %q output: %v", query, err)
113+
}
114+
115+
if result != "ok" {
116+
t.Fatalf("run %q = %q, want ok", query, result)
117+
}
118+
}
119+
120+
if got := ns.RegisteredFunctions(); len(got) != 1 || got[0] != "DB::POSTGRES::QUERY" {
121+
t.Fatalf("registered functions = %v, want declared qualified name", got)
122+
}
66123
}
67124

68125
func TestCompiler_RegisteredFunctions(t *testing.T) {
@@ -82,7 +139,7 @@ func TestCompiler_RegisteredFunctions(t *testing.T) {
82139
}
83140

84141
if !found["FUNC_A"] || !found["FUNC_B"] {
85-
t.Fatalf("expected FUNC_A and FUNC_B, got %v", names)
142+
t.Fatalf("expected declared FUNC_A and FUNC_B, got %v", names)
86143
}
87144
}
88145

@@ -100,9 +157,9 @@ func TestCompiler_RegisterFunctions_duplicate(t *testing.T) {
100157
t.Fatalf("first RegisterFunctions error: %v", err)
101158
}
102159

103-
// Second registration of the same set should be silently skipped (no error).
160+
// Second registration of the same set is silently skipped for v1 compatibility.
104161
if err := c.RegisterFunctions(fns); err != nil {
105-
t.Fatalf("second RegisterFunctions should silently skip duplicates, got: %v", err)
162+
t.Fatalf("second RegisterFunctions should skip duplicates: %v", err)
106163
}
107164

108165
// Most importantly: Compile must succeed — no latent builder error must have

compat/runtime/core/functions.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,9 @@ func (a *namespaceAdapter) RegisteredFunctions() []string {
141141
fns.A4(),
142142
} {
143143
for _, n := range defs.List() {
144-
if _, ok := seen[n]; !ok {
145-
seen[n] = struct{}{}
144+
key := runtime.NormalizeRegisteredName(n)
145+
if _, ok := seen[key]; !ok {
146+
seen[key] = struct{}{}
146147
names = append(names, n)
147148
}
148149
}

docs/maintainers/core-api-reference.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,17 @@ fixed arity, variadic definition, overload, root function, and nested namespace.
1313
Unregistered Go declarations are not published.
1414

1515
The generator then loads `pkg/stdlib` source with `go/packages` and resolves each
16-
registered function to its declaration. The assertion descriptors used by `T`
17-
and `T::NOT` are resolved statically, including their `Args.Min` and `Args.Max`
16+
registered function to its declaration. The assertion descriptors used by `t`
17+
and `t::not` are resolved statically, including their `Args.Min` and `Args.Max`
1818
bounds. Any unresolved declaration, unsupported registration shape, documentation
1919
error, arity contradiction, or runtime/source mismatch fails generation without
2020
writing a partial artifact.
2121

22+
Host-function qualified names are case-insensitive in FQL and have one canonical
23+
lowercase presentation in the registry and generated API Reference. This includes
24+
every namespace segment; casing compatibility is represented by lookup rather
25+
than duplicate aliases.
26+
2227
The API Reference and discovery-index wire contracts belong to
2328
[`github.com/MontFerret/specs`](https://github.com/MontFerret/specs). Ferret pins
2429
the released Specs version in the independent generator and publisher modules,
@@ -32,7 +37,7 @@ Registered declarations use the strict structured format parsed by
3237
`api.ParseDocumentation`:
3338

3439
```go
35-
// SPLIT divides a string at each separator.
40+
// split divides a string at each separator.
3641
// @param value {String} Source string.
3742
// @param separator {String} Separator string.
3843
// @return {String[]} Split values.
@@ -53,7 +58,7 @@ The rules are intentionally strict:
5358
- Parameters are flat. Describe nested map fields in the parent parameter's
5459
description instead of using names such as `params.mode`.
5560
- Assertion descriptor prose is namespace-neutral because the same descriptor
56-
documents both positive and `T::NOT` overloads. The descriptor documents its
61+
documents both positive and `t::not` overloads. The descriptor documents its
5762
maximum argument list; each fixed overload receives the corresponding prefix.
5863

5964
Run the focused authoring and parity checks after changing stdlib registration

engine_options.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ func WithNamespace(ns runtime.Namespace) Option {
193193
}
194194

195195
// WithFunctionsRegistrar creates an Option that invokes the provided registrar with the engine's runtime.Namespace if the registrar is not nil.
196+
// Registered host-function names and namespace segments are canonicalized to lowercase and resolve case-insensitively in FQL.
196197
func WithFunctionsRegistrar(setter func(ns runtime.Namespace)) Option {
197198
return func(env *options) error {
198199
if setter == nil {

engine_options_test.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
package ferret
22

33
import (
4+
"context"
45
"errors"
6+
"slices"
57
"strings"
68
"testing"
79

810
"github.com/MontFerret/ferret/v2/pkg/compiler"
911
ferretnet "github.com/MontFerret/ferret/v2/pkg/net"
1012
ferrethttp "github.com/MontFerret/ferret/v2/pkg/net/http"
1113
"github.com/MontFerret/ferret/v2/pkg/runtime"
14+
"github.com/MontFerret/ferret/v2/pkg/source"
1215
"github.com/MontFerret/ferret/v2/pkg/stdlib"
16+
"github.com/MontFerret/ferret/v2/pkg/vm"
1317
)
1418

1519
func mustNewOptionsForTest(t *testing.T, setters ...Option) *options {
@@ -226,6 +230,109 @@ func TestWithStdlibSafeRegistersSelectedGroups(t *testing.T) {
226230
}
227231
}
228232

233+
func TestWithFunctionsRegistrarPreservesQualifiedHostFunctionNames(t *testing.T) {
234+
t.Parallel()
235+
236+
eng := mustNewEngine(t,
237+
WithoutStdlib(),
238+
WithFunctionsRegistrar(func(ns runtime.Namespace) {
239+
ns.Namespace("Tools").Namespace("Risk").Function().A0().Add("Calculate_Risk", func(context.Context) (runtime.Value, error) {
240+
return runtime.NewString("ok"), nil
241+
})
242+
}),
243+
)
244+
defer func() { _ = eng.Close() }()
245+
246+
if got := eng.host.functions.List(); !slices.Equal(got, []string{"Tools::Risk::Calculate_Risk"}) {
247+
t.Fatalf("expected declared host metadata, got %v", got)
248+
}
249+
250+
for _, query := range []string{
251+
"return tools::risk::calculate_risk()",
252+
"return TOOLS::RISK::CALCULATE_RISK()",
253+
"return Tools::Risk::Calculate_Risk()",
254+
} {
255+
output, err := eng.Run(t.Context(), source.NewAnonymous(query))
256+
if err != nil {
257+
t.Fatalf("run %q: %v", query, err)
258+
}
259+
260+
if got := string(output.Content); got != `"ok"` {
261+
t.Fatalf("run %q = %s, want %q", query, got, `"ok"`)
262+
}
263+
}
264+
}
265+
266+
func TestWithFunctionsRegistrarRejectsCaseOnlyDuplicate(t *testing.T) {
267+
t.Parallel()
268+
269+
eng, err := New(
270+
WithoutStdlib(),
271+
WithFunctionsRegistrar(func(ns runtime.Namespace) {
272+
fn := func(context.Context) (runtime.Value, error) { return runtime.None, nil }
273+
ns.Function().A0().Add("foo", fn).Add("FOO", fn)
274+
}),
275+
)
276+
if eng != nil {
277+
_ = eng.Close()
278+
}
279+
280+
if err == nil {
281+
t.Fatal("expected case-only duplicate registration to fail")
282+
}
283+
}
284+
285+
func TestUnknownHostFunctionDiagnosticPreservesSourceSpelling(t *testing.T) {
286+
t.Parallel()
287+
288+
eng := mustNewEngine(t, WithoutStdlib())
289+
defer func() { _ = eng.Close() }()
290+
291+
const name = "TOOLS::MiSsInG"
292+
_, err := eng.Run(t.Context(), source.NewAnonymous("return "+name+"()"))
293+
if err == nil {
294+
t.Fatal("expected unknown host function to fail")
295+
}
296+
297+
var runtimeErr *vm.RuntimeError
298+
if !errors.As(err, &runtimeErr) {
299+
t.Fatalf("expected RuntimeError, got %T: %v", err, err)
300+
}
301+
302+
want := "function '" + name + "' is not registered"
303+
if got := runtimeErr.Spans[0].Label; got != want {
304+
t.Fatalf("diagnostic label = %q, want %q", got, want)
305+
}
306+
}
307+
308+
func TestResolvedHostFunctionDiagnosticUsesRegisteredQualifiedName(t *testing.T) {
309+
t.Parallel()
310+
311+
eng := mustNewEngine(t,
312+
WithoutStdlib(),
313+
WithFunctionsRegistrar(func(ns runtime.Namespace) {
314+
ns.Namespace("DB").Namespace("POSTGRES").Function().A1().Add("QUERY", func(context.Context, runtime.Value) (runtime.Value, error) {
315+
return runtime.None, nil
316+
})
317+
}),
318+
)
319+
defer func() { _ = eng.Close() }()
320+
321+
_, err := eng.Run(t.Context(), source.NewAnonymous("return Db::Postgres::Query()"))
322+
if err == nil {
323+
t.Fatal("expected invalid host function arity to fail")
324+
}
325+
326+
var runtimeErr *vm.RuntimeError
327+
if !errors.As(err, &runtimeErr) {
328+
t.Fatalf("expected RuntimeError, got %T: %v", err, err)
329+
}
330+
331+
if got, want := runtimeErr.Note, "DB::POSTGRES::QUERY expects 1 argument, but got 0"; got != want {
332+
t.Fatalf("diagnostic note = %q, want %q", got, want)
333+
}
334+
}
335+
229336
func TestWithStdlibEmptyMatchesWithoutStdlib(t *testing.T) {
230337
t.Parallel()
231338

pkg/asm/disassembler_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -517,7 +517,7 @@ func TestDisassemble_HCallCommentMissingWhenNoMatchingLoadConst(t *testing.T) {
517517
t.Fatalf("Disassemble() error: %v", err)
518518
}
519519

520-
if strings.Contains(out, "; host X::DO") {
520+
if strings.Contains(out, "; host x::do") {
521521
t.Fatalf("did not expect host comment when LOADC register does not match HCALL register:\n%s", out)
522522
}
523523
}

pkg/bytecode/artifact/artifact_test.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,9 @@ func TestMarshalAndUnmarshal_DefaultMessagePack(t *testing.T) {
3434
if got, want := decoded.ISAVersion, program.ISAVersion; got != want {
3535
t.Fatalf("unexpected isaVersion: got %d, want %d", got, want)
3636
}
37-
if !reflect.DeepEqual(decoded.Functions.Host, program.Functions.Host) {
38-
t.Fatalf("host signature order mismatch: got %v, want %v", decoded.Functions.Host, program.Functions.Host)
37+
expected := artifactHostFunctions()
38+
if !reflect.DeepEqual(decoded.Functions.Host, expected) {
39+
t.Fatalf("host signature order or spelling mismatch: got %v, want %v", decoded.Functions.Host, expected)
3940
}
4041
}
4142

@@ -60,8 +61,9 @@ func TestMarshalAndUnmarshal_JSON(t *testing.T) {
6061
if err != nil {
6162
t.Fatalf("Unmarshal() error = %v", err)
6263
}
63-
if !reflect.DeepEqual(decoded.Functions.Host, program.Functions.Host) {
64-
t.Fatalf("host signature order mismatch: got %v, want %v", decoded.Functions.Host, program.Functions.Host)
64+
expected := artifactHostFunctions()
65+
if !reflect.DeepEqual(decoded.Functions.Host, expected) {
66+
t.Fatalf("host signature order or spelling mismatch: got %v, want %v", decoded.Functions.Host, expected)
6567
}
6668
}
6769

@@ -461,8 +463,8 @@ func newArtifactTestProgram() *bytecode.Program {
461463
return &bytecode.Program{
462464
Source: source.New("artifact.fql", "RETURN 1"),
463465
Functions: bytecode.Functions{Host: []bytecode.HostFunction{
464-
{Name: "PICK", ArgCount: 2},
465-
{Name: "PICK", ArgCount: 1},
466+
{Name: "DB::POSTGRES::PICK", ArgCount: 2},
467+
{Name: "Db::Postgres::Pick", ArgCount: 1},
466468
}},
467469
Bytecode: []bytecode.Instruction{
468470
bytecode.NewInstruction(bytecode.OpLoadConst, bytecode.NewRegister(0), bytecode.NewConstant(0)),
@@ -485,6 +487,13 @@ func newArtifactTestProgram() *bytecode.Program {
485487
}
486488
}
487489

490+
func artifactHostFunctions() []bytecode.HostFunction {
491+
return []bytecode.HostFunction{
492+
{Name: "DB::POSTGRES::PICK", ArgCount: 2},
493+
{Name: "Db::Postgres::Pick", ArgCount: 1},
494+
}
495+
}
496+
488497
func TestLoaderRejectsMalformedPayload(t *testing.T) {
489498
isaVersion := bytecode.Version
490499
registers := 1

pkg/bytecode/format/json/format_test.go

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,18 +102,22 @@ func TestFormatAllowsOverloadedHostsAndRejectsDuplicateSignaturesAndLabels(t *te
102102
one := 1
103103
two := 2
104104
frame.Functions.Host = []persist.HostFunctionFrame{
105-
{Name: "dup", ArgCount: &one},
106-
{Name: "dup", ArgCount: &two},
105+
{Name: "DB::POSTGRES::DUP", ArgCount: &one},
106+
{Name: "db::postgres::dup", ArgCount: &two},
107107
}
108108

109109
data := mustMarshalFrame(t, frame)
110-
if _, err := Default.Unmarshal(data); err != nil {
110+
decoded, err := Default.Unmarshal(data)
111+
if err != nil {
111112
t.Fatalf("expected overloaded host signatures to decode, got %v", err)
112113
}
114+
if got := decoded.Functions.Host[0].Name; got != "DB::POSTGRES::DUP" {
115+
t.Fatalf("expected stored host metadata spelling to survive, got %q", got)
116+
}
113117

114118
frame.Functions.Host[1].ArgCount = &one
115119
data = mustMarshalFrame(t, frame)
116-
_, err := Default.Unmarshal(data)
120+
_, err = Default.Unmarshal(data)
117121
if !errors.Is(err, bytecode.ErrInvalidProgram) {
118122
t.Fatalf("expected ErrInvalidProgram for duplicate host signatures, got %v", err)
119123
}

0 commit comments

Comments
 (0)