diff --git a/README.md b/README.md index a8a71cb..0693f2c 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ Usage: Available Commands: browser Manage Ferret browsers + build Compile FQL scripts into bytecode artifacts check Check FQL scripts for syntax and semantic errors config Manage Ferret configs fmt Format FQL scripts @@ -207,7 +208,7 @@ Use "ferret [command] --help" for more information about a command. ### run / exec -Run a FQL script from a file, inline expression, or launch the REPL when called with no arguments. +Run a FQL script, a compiled artifact file, or an inline expression. To launch the interactive REPL, use the `ferret repl` command. ```bash ferret run [script] @@ -226,6 +227,8 @@ ferret exec [script] # alias | `--param` | `-p` | Query parameter (`key:value`, repeatable) | | | `--eval` | `-e` | Inline FQL expression (cannot be used with file args) | | +Compiled artifacts are auto-detected by content for file inputs and piped stdin, so artifacts produced by `ferret build` work even when they do not use a `.fqlc` filename. Artifact execution currently requires the builtin runtime. + ### repl Launch the interactive FQL shell. Supports command history, multiline input (toggle with `%`), and all runtime flags. @@ -244,6 +247,20 @@ Compile one or more FQL scripts without executing them. Reports syntax and seman ferret check [files...] ``` +### build + +Compile one or more FQL scripts into serialized bytecode artifacts. + +```bash +ferret build [files...] +``` + +| Flag | Short | Description | +|------|-------|-------------| +| `--output` | `-o` | Output file path, or output directory (if the path is an existing directory) for one or more inputs | + +Without `--output`, each input writes a sibling artifact with the same base name and a `.fqlc` extension. + ### fmt Format FQL scripts. By default, files are overwritten in place. diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 0000000..4e67def --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/MontFerret/ferret/v2/pkg/compiler" + + "github.com/MontFerret/cli/pkg/build" + "github.com/MontFerret/cli/pkg/config" + "github.com/MontFerret/cli/pkg/source" +) + +func BuildCommand(store *config.Store) *cobra.Command { + cmd := &cobra.Command{ + Use: "build [files...]", + Short: "Compile FQL scripts into bytecode artifacts", + Args: cobra.MinimumNArgs(1), + PreRun: func(cmd *cobra.Command, _ []string) { + store.BindFlags(cmd) + }, + RunE: func(cmd *cobra.Command, args []string) error { + output, err := cmd.Flags().GetString("output") + + if err != nil { + return err + } + + return runBuild(args, output) + }, + } + + cmd.Flags().StringP("output", "o", "", "Output path: file (for single input) or directory (for single or multiple inputs)") + + return cmd +} + +func runBuild(args []string, output string) error { + plan, err := build.PlanOutputs(args, output) + + if err != nil { + return err + } + + sources, err := source.Resolve(source.Input{Args: args}) + + if err != nil { + return err + } + + if plan.OutputDir != "" { + if err := os.MkdirAll(plan.OutputDir, 0o755); err != nil { + return fmt.Errorf("create output directory %s: %w", plan.OutputDir, err) + } + } + + c := compiler.New() + failed := 0 + + for i, src := range sources { + if err := build.WriteArtifact(c, src, plan.Targets[i].OutputPath); err != nil { + printError(err) + failed++ + } + } + + if failed > 0 { + return fmt.Errorf("%d of %d scripts failed to build", failed, len(sources)) + } + + return nil +} diff --git a/cmd/build_test.go b/cmd/build_test.go new file mode 100644 index 0000000..d8bbb6c --- /dev/null +++ b/cmd/build_test.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestRunBuild_MixedMultiFileBuildContinues(t *testing.T) { + dir := t.TempDir() + valid := filepath.Join(dir, "valid.fql") + invalid := filepath.Join(dir, "invalid.fql") + outputDir := filepath.Join(dir, "dist") + + writeQuery(t, valid, "RETURN 1") + writeQuery(t, invalid, "FOR item IN") + + _, err := captureStderr(t, func() error { + return runBuild([]string{valid, invalid}, outputDir) + }) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "1 of 2 scripts failed to build") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunBuild_PlanErrorReturned(t *testing.T) { + dir := t.TempDir() + inputA := filepath.Join(dir, "first.fql") + inputB := filepath.Join(dir, "second.fql") + output := filepath.Join(dir, "artifact.fqlc") + + writeQuery(t, inputA, "RETURN 1") + writeQuery(t, inputB, "RETURN 2") + writeQuery(t, output, "not a directory") + + _, err := captureStderr(t, func() error { + return runBuild([]string{inputA, inputB}, output) + }) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "--output must be a directory when building multiple files") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/cmd/repl.go b/cmd/repl.go index cc54324..7efb582 100644 --- a/cmd/repl.go +++ b/cmd/repl.go @@ -16,7 +16,7 @@ func ReplCommand(store *config.Store) *cobra.Command { PreRun: func(cmd *cobra.Command, _ []string) { store.BindFlags(cmd) }, - RunE: func(cmd *cobra.Command, args []string) error { + RunE: func(cmd *cobra.Command, _ []string) error { paramFlag, err := cmd.Flags().GetStringArray(paramFlag) if err != nil { diff --git a/cmd/run.go b/cmd/run.go index 13d358c..1a72e50 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -7,20 +7,18 @@ import ( "github.com/spf13/cobra" - "github.com/MontFerret/ferret/v2/pkg/file" - "github.com/MontFerret/cli/pkg/browser" "github.com/MontFerret/cli/pkg/config" + clirun "github.com/MontFerret/cli/pkg/run" cliruntime "github.com/MontFerret/cli/pkg/runtime" - "github.com/MontFerret/cli/pkg/source" ) func RunCommand(store *config.Store) *cobra.Command { cmd := &cobra.Command{ Use: "run [script]", Aliases: []string{"exec"}, - Short: "Run a FQL script", - Args: cobra.MinimumNArgs(0), + Short: "Run a FQL script or compiled artifact", + Args: cobra.MaximumNArgs(1), PreRun: func(cmd *cobra.Command, _ []string) { store.BindFlags(cmd) }, @@ -48,27 +46,7 @@ func RunCommand(store *config.Store) *cobra.Command { } store := config.From(cmd.Context()) - rtOpts := store.GetRuntimeOptions() - - cleanup, err := browser.EnsureBrowser(cmd.Context(), rtOpts, store.GetBrowserOptions()) - - if err != nil { - return err - } - - defer cleanup() - - sources, err := source.Resolve(source.Input{Eval: eval, Args: args}) - - if err != nil { - return err - } - - if sources == nil { - return cmd.Help() - } - - return runScript(cmd, rtOpts, params, sources[0]) + return executeRun(cmd, store.GetRuntimeOptions(), store.GetBrowserOptions(), params, eval, args) }, } @@ -79,8 +57,30 @@ func RunCommand(store *config.Store) *cobra.Command { return cmd } -func runScript(cmd *cobra.Command, opts cliruntime.Options, params map[string]interface{}, query *file.Source) error { - out, err := cliruntime.Run(cmd.Context(), opts, query, params) +func executeRun(cmd *cobra.Command, rtOpts cliruntime.Options, brOpts browser.Options, params map[string]interface{}, eval string, args []string) error { + input, err := clirun.ResolveInput(eval, args) + + if err != nil { + return err + } + + if input == nil { + return cmd.Help() + } + + if len(input.Artifact) > 0 && !cliruntime.IsBuiltinType(rtOpts.Type) { + return cliruntime.ErrArtifactRequiresBuiltinRuntime + } + + cleanup, err := browser.EnsureBrowser(cmd.Context(), rtOpts, brOpts) + + if err != nil { + return err + } + + defer cleanup() + + out, err := clirun.Execute(cmd.Context(), rtOpts, params, input) if err != nil { printError(err) diff --git a/cmd/run_test.go b/cmd/run_test.go new file mode 100644 index 0000000..19026a6 --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,125 @@ +package cmd + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + + "github.com/MontFerret/cli/pkg/browser" + "github.com/MontFerret/cli/pkg/build" + "github.com/MontFerret/cli/pkg/config" + cliruntime "github.com/MontFerret/cli/pkg/runtime" + "github.com/MontFerret/ferret/v2/pkg/compiler" + "github.com/MontFerret/ferret/v2/pkg/file" +) + +func TestExecuteRun_ArtifactRemoteRuntimeRejected(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + artifactPath := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 42") + + if err := build.WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 42"), artifactPath); err != nil { + t.Fatalf("build artifact: %v", err) + } + + _, err := captureStdout(t, func() error { + return executeRun( + newTestCommand(), + cliruntime.Options{Type: "https://worker.example"}, + browser.Options{}, + nil, + "", + []string{artifactPath}, + ) + }) + + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, cliruntime.ErrArtifactRequiresBuiltinRuntime) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestExecuteRun_ArtifactStdinRemoteRuntimeRejected(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + artifactPath := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 42") + + if err := build.WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 42"), artifactPath); err != nil { + t.Fatalf("build artifact: %v", err) + } + + data, err := os.ReadFile(artifactPath) + if err != nil { + t.Fatal(err) + } + + withStdinBytes(t, data, func() { + err := executeRun( + newTestCommand(), + cliruntime.Options{ + Type: "https://worker.example", + WithBrowser: true, + BrowserAddress: "://invalid", + }, + browser.Options{}, + nil, + "", + nil, + ) + + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, cliruntime.ErrArtifactRequiresBuiltinRuntime) { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestRunCommand_RejectsMultiplePositionalArgs(t *testing.T) { + cmd := RunCommand(new(config.Store)) + + if err := cmd.Args(cmd, []string{"one.fql", "two.fql"}); err == nil { + t.Fatal("expected argument validation error") + } +} + +func TestRunCommand_RejectsEvalWithFileArgs(t *testing.T) { + cmd := RunCommand(new(config.Store)) + cmd.SetContext(config.With(context.Background(), new(config.Store))) + cmd.Flags().Set("eval", "RETURN 1") + + err := cmd.RunE(cmd, []string{"query.fql"}) + + if err == nil { + t.Fatal("expected error") + } +} + +func TestExecuteRun_NoInputShowsHelp(t *testing.T) { + cmd := newTestCommand() + withDevNullStdin(t, func() { + if err := executeRun(cmd, cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func newTestCommand() *cobra.Command { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + return cmd +} diff --git a/cmd/test_helpers_test.go b/cmd/test_helpers_test.go new file mode 100644 index 0000000..9da3c90 --- /dev/null +++ b/cmd/test_helpers_test.go @@ -0,0 +1,126 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "testing" +) + +func writeQuery(t *testing.T, path, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + + original := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + os.Stdout = writer + + runErr := fn() + + if closeErr := writer.Close(); closeErr != nil { + t.Fatal(closeErr) + } + + os.Stdout = original + + data, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + + if closeErr := reader.Close(); closeErr != nil { + t.Fatal(closeErr) + } + + return string(data), runErr +} + +func captureStderr(t *testing.T, fn func() error) (string, error) { + t.Helper() + + original := os.Stderr + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + os.Stderr = writer + + runErr := fn() + + if closeErr := writer.Close(); closeErr != nil { + t.Fatal(closeErr) + } + + os.Stderr = original + + data, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + + if closeErr := reader.Close(); closeErr != nil { + t.Fatal(closeErr) + } + + return string(data), runErr +} + +func withStdinBytes(t *testing.T, data []byte, fn func()) { + t.Helper() + + original := os.Stdin + reader, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + os.Stdin = reader + defer func() { + os.Stdin = original + reader.Close() + }() + + fn() +} + +func withDevNullStdin(t *testing.T, fn func()) { + t.Helper() + + original := os.Stdin + stdin, err := os.Open(os.DevNull) + if err != nil { + t.Fatal(err) + } + + os.Stdin = stdin + defer func() { + os.Stdin = original + stdin.Close() + }() + + fn() +} diff --git a/ferret/main.go b/ferret/main.go index affd3c2..662c584 100644 --- a/ferret/main.go +++ b/ferret/main.go @@ -54,6 +54,7 @@ func main() { cmd.ReplCommand(store), cmd.FormatCommand(store), cmd.CheckCommand(store), + cmd.BuildCommand(store), cmd.InspectCommand(store), cmd.BrowserCommand(store), cmd.SelfUpdateCommand(store), diff --git a/go.mod b/go.mod index 22f4c96..8fc9b69 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.26.1 require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/MontFerret/contrib/modules/html v0.0.0-20260320005250-d56e1385f33b - github.com/MontFerret/ferret/v2 v2.0.0-alpha.1 + github.com/MontFerret/ferret/v2 v2.0.0-alpha.3 github.com/chzyer/readline v1.5.1 github.com/go-waitfor/waitfor v1.1.0 github.com/go-waitfor/waitfor-http v1.1.0 diff --git a/go.sum b/go.sum index 2d5441c..f38afc0 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,8 @@ github.com/MontFerret/contrib/modules/html v0.0.0-20260320005250-d56e1385f33b h1 github.com/MontFerret/contrib/modules/html v0.0.0-20260320005250-d56e1385f33b/go.mod h1:fKoicAORubv4yvVwplkqCAr4FJy0yi3CcRqFOHMT3lE= github.com/MontFerret/cssx v0.2.0 h1:De0C6Irbg+qgFPXgWmPpVnwD4RRYUBQSbIYFTUVCNWU= github.com/MontFerret/cssx v0.2.0/go.mod h1:fmGtRUNVaeJYpiPSDlNIbbYzb3+K8NxmNmJOYqlHATU= -github.com/MontFerret/ferret/v2 v2.0.0-alpha.1 h1:bxvVf4oe4qkFcK6sCERIQ4ReMPkZ/Ssd4RrQVwsRU7I= -github.com/MontFerret/ferret/v2 v2.0.0-alpha.1/go.mod h1:LV7GoILRI4RIBC+OKpToK7yr04caWhxlEFO0ZiXUzl4= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.3 h1:DIduvurGWD1yXv59f7b7wkmAtzycO4kFLHOd7JmhmoE= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.3/go.mod h1:8Yo9I2voi8bue4oR8rWwRHfjHfmEzGmH4oeGCIoUdGk= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -53,8 +53,9 @@ github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= diff --git a/pkg/browser/browser_darwin.go b/pkg/browser/browser_darwin.go index eb888d9..128c259 100644 --- a/pkg/browser/browser_darwin.go +++ b/pkg/browser/browser_darwin.go @@ -23,7 +23,7 @@ func (b *DarwinBrowser) Open(ctx context.Context) (uint64, error) { return 0, err } - pid, detached, err := openProcessWithOpts(ctx, path, b.opts.ToFlags(), b.opts.Detach) + pid, detached, err := openProcess(ctx, path, b.opts.ToFlags(), b.opts.Detach) if err != nil || !detached { return 0, err diff --git a/pkg/browser/browser_linux.go b/pkg/browser/browser_linux.go index 1daec1b..0c15b37 100644 --- a/pkg/browser/browser_linux.go +++ b/pkg/browser/browser_linux.go @@ -21,7 +21,7 @@ func (b *LinuxBrowser) Open(ctx context.Context) (uint64, error) { return 0, err } - pid, detached, err := openProcessWithOpts(ctx, path, b.opts.ToFlags(), b.opts.Detach) + pid, detached, err := openProcess(ctx, path, b.opts.ToFlags(), b.opts.Detach) if err != nil || !detached { return 0, err diff --git a/pkg/browser/process.go b/pkg/browser/process.go index 82cad87..d8912bb 100644 --- a/pkg/browser/process.go +++ b/pkg/browser/process.go @@ -13,13 +13,7 @@ import ( // ProcessMatcher determines if a running process matches the target browser command. type ProcessMatcher func(processCmd, targetCmd string) bool -// openProcess runs the browser binary with the given flags. -// If detach is true, starts in background and returns PID. -func openProcess(ctx context.Context, path string, flags []string) (uint64, bool, error) { - return openProcessWithOpts(ctx, path, flags, true) -} - -func openProcessWithOpts(ctx context.Context, path string, flags []string, detach bool) (uint64, bool, error) { +func openProcess(ctx context.Context, path string, flags []string, detach bool) (uint64, bool, error) { cmd := exec.CommandContext(ctx, path, flags...) if detach { @@ -45,7 +39,7 @@ func killPID(pid uint64, killCmd string, killArgs ...string) error { // findProcessByPS searches for a matching process using ps output and returns its PID. func findProcessByPS(ctx context.Context, targetCmd string, matcher ProcessMatcher) (uint64, error) { - psOut, err := exec.Command("ps", "-o", "pid=", "-o", "command=").Output() + psOut, err := exec.CommandContext(ctx, "ps", "-o", "pid=", "-o", "command=").Output() if err != nil { return 0, ErrProcNotFound diff --git a/pkg/build/artifact.go b/pkg/build/artifact.go new file mode 100644 index 0000000..54c9a85 --- /dev/null +++ b/pkg/build/artifact.go @@ -0,0 +1,88 @@ +package build + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" + "github.com/MontFerret/ferret/v2/pkg/compiler" + "github.com/MontFerret/ferret/v2/pkg/file" +) + +var renameArtifactFile = os.Rename + +func WriteArtifact(c *compiler.Compiler, src *file.Source, outputPath string) error { + same, err := samePath(src.Name(), outputPath) + + if err != nil { + return err + } + + if same { + return fmt.Errorf("output path %s would overwrite source file %s", outputPath, src.Name()) + } + + program, err := c.Compile(src) + + if err != nil { + return err + } + + data, err := artifact.Marshal(program, artifact.Options{}) + + if err != nil { + return fmt.Errorf("serialize %s: %w", src.Name(), err) + } + + outputDir := filepath.Dir(outputPath) + + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output directory %s: %w", outputDir, err) + } + + tempFile, err := os.CreateTemp(outputDir, artifactTempPattern(outputPath)) + + if err != nil { + return fmt.Errorf("create temporary artifact for %s: %w", outputPath, err) + } + + tempPath := tempFile.Name() + cleanupTemp := true + defer func() { + if !cleanupTemp { + return + } + + _ = tempFile.Close() + _ = os.Remove(tempPath) + }() + + if _, err := tempFile.Write(data); err != nil { + return fmt.Errorf("write temporary artifact for %s: %w", outputPath, err) + } + + if err := tempFile.Chmod(0o644); err != nil { + return fmt.Errorf("set permissions on temporary artifact for %s: %w", outputPath, err) + } + + if err := tempFile.Sync(); err != nil { + return fmt.Errorf("sync temporary artifact for %s: %w", outputPath, err) + } + + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temporary artifact for %s: %w", outputPath, err) + } + + if err := renameArtifactFile(tempPath, outputPath); err != nil { + return fmt.Errorf("replace %s with temporary artifact: %w", outputPath, err) + } + + cleanupTemp = false + + return nil +} + +func artifactTempPattern(outputPath string) string { + return "." + filepath.Base(outputPath) + ".tmp-*" +} diff --git a/pkg/build/build_test.go b/pkg/build/build_test.go new file mode 100644 index 0000000..3cb5cf9 --- /dev/null +++ b/pkg/build/build_test.go @@ -0,0 +1,439 @@ +package build + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" + "github.com/MontFerret/ferret/v2/pkg/compiler" + "github.com/MontFerret/ferret/v2/pkg/file" +) + +func TestPlanOutputs_DefaultOutputPath(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + + plan, err := PlanOutputs([]string{input}, "") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(plan.Targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(plan.Targets)) + } + + if plan.Targets[0].OutputPath != filepath.Join(dir, "query.fqlc") { + t.Fatalf("unexpected output path: %s", plan.Targets[0].OutputPath) + } +} + +func TestPlanOutputs_DefaultOutputPathsMultiFile(t *testing.T) { + dir := t.TempDir() + inputA := filepath.Join(dir, "first.fql") + inputB := filepath.Join(dir, "second.txt") + + plan, err := PlanOutputs([]string{inputA, inputB}, "") + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(plan.Targets) != 2 { + t.Fatalf("expected 2 targets, got %d", len(plan.Targets)) + } + + if plan.Targets[0].OutputPath != filepath.Join(dir, "first.fqlc") { + t.Fatalf("unexpected first output path: %s", plan.Targets[0].OutputPath) + } + + if plan.Targets[1].OutputPath != filepath.Join(dir, "second.fqlc") { + t.Fatalf("unexpected second output path: %s", plan.Targets[1].OutputPath) + } +} + +func TestPlanOutputs_DefaultOutputCollisionDifferentExtensions(t *testing.T) { + dir := t.TempDir() + inputA := filepath.Join(dir, "query.fql") + inputB := filepath.Join(dir, "query.txt") + + _, err := PlanOutputs([]string{inputA, inputB}, "") + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "output collision") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPlanOutputs_DefaultOutputCollisionExtensionlessAndExtensioned(t *testing.T) { + dir := t.TempDir() + inputA := filepath.Join(dir, "query") + inputB := filepath.Join(dir, "query.fql") + + _, err := PlanOutputs([]string{inputA, inputB}, "") + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "output collision") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPlanOutputs_SingleFileExplicitOutput(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "compiled.bin") + + plan, err := PlanOutputs([]string{input}, output) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if plan.Targets[0].OutputPath != output { + t.Fatalf("unexpected output path: %s", plan.Targets[0].OutputPath) + } +} + +func TestPlanOutputs_SingleFileOutputDirectory(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + outputDir := filepath.Join(dir, "dist") + + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatal(err) + } + + plan, err := PlanOutputs([]string{input}, outputDir) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if plan.OutputDir != outputDir { + t.Fatalf("unexpected output dir: %s", plan.OutputDir) + } + + if plan.Targets[0].OutputPath != filepath.Join(outputDir, "query.fqlc") { + t.Fatalf("unexpected output path: %s", plan.Targets[0].OutputPath) + } +} + +func TestPlanOutputs_MultiFileOutputDirectory(t *testing.T) { + dir := t.TempDir() + inputA := filepath.Join(dir, "first.fql") + inputB := filepath.Join(dir, "second") + outputDir := filepath.Join(dir, "dist") + + plan, err := PlanOutputs([]string{inputA, inputB}, outputDir) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(plan.Targets) != 2 { + t.Fatalf("expected 2 targets, got %d", len(plan.Targets)) + } + + if plan.Targets[0].OutputPath != filepath.Join(outputDir, "first.fqlc") { + t.Fatalf("unexpected first output path: %s", plan.Targets[0].OutputPath) + } + + if plan.Targets[1].OutputPath != filepath.Join(outputDir, "second.fqlc") { + t.Fatalf("unexpected second output path: %s", plan.Targets[1].OutputPath) + } +} + +func TestPlanOutputs_MultiFileOutputMustBeDirectory(t *testing.T) { + dir := t.TempDir() + inputA := filepath.Join(dir, "first.fql") + inputB := filepath.Join(dir, "second.fql") + output := filepath.Join(dir, "artifact.fqlc") + + if err := os.WriteFile(output, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + + _, err := PlanOutputs([]string{inputA, inputB}, output) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "--output must be a directory when building multiple files") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPlanOutputs_MultiFileOutputCollision(t *testing.T) { + dir := t.TempDir() + inputA := filepath.Join(dir, "one", "query.fql") + inputB := filepath.Join(dir, "two", "query.fql") + outputDir := filepath.Join(dir, "dist") + + _, err := PlanOutputs([]string{inputA, inputB}, outputDir) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "output collision") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWriteArtifact_RejectsOverwritingSource(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + query := "RETURN 42" + + writeQuery(t, input, query) + + err := WriteArtifact(compiler.New(), file.NewSource(input, query), input) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "would overwrite source file") { + t.Fatalf("unexpected error: %v", err) + } + + content, readErr := os.ReadFile(input) + if readErr != nil { + t.Fatal(readErr) + } + + if string(content) != query { + t.Fatalf("expected source file to remain unchanged, got %q", string(content)) + } +} + +func TestWriteArtifact_InvalidQueryDoesNotCreateArtifact(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "broken.fql") + output := filepath.Join(dir, "broken.fqlc") + + writeQuery(t, input, "FOR item IN") + + err := WriteArtifact(compiler.New(), file.NewSource(input, "FOR item IN"), output) + + if err == nil { + t.Fatal("expected error") + } + + if _, statErr := os.Stat(output); !os.IsNotExist(statErr) { + t.Fatalf("expected artifact to be absent, stat err=%v", statErr) + } +} + +func TestWriteArtifact_CreatesMissingParentDirectory(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "nested", "out", "query.fqlc") + + writeQuery(t, input, "RETURN 42") + + if err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 42"), output); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 42") +} + +func TestWriteArtifact_ReplacesExistingDestinationFile(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 1") + + if err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 1"), output); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 1") + + writeQuery(t, input, "RETURN 2") + + if err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 2"), output); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 2") +} + +func TestWriteArtifact_ReplacesExistingDestinationFileInNestedDirectory(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "nested", "out", "query.fqlc") + + writeQuery(t, input, "RETURN 1") + + if err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 1"), output); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 1") + + writeQuery(t, input, "RETURN 2") + + if err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 2"), output); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 2") +} + +func TestWriteArtifact_RenameFailurePreservesExistingDestinationAndCleansTemp(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 1") + + if err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 1"), output); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + writeQuery(t, input, "RETURN 2") + + restore := stubRenameArtifactFile(func(string, string) error { + return errors.New("rename failed") + }) + defer restore() + + err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 2"), output) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "replace "+output+" with temporary artifact") { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 1") + assertNoTempArtifacts(t, filepath.Dir(output), output) +} + +func TestWriteArtifact_RenameFailureDoesNotCreateDestinationAndCleansTemp(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 42") + + restore := stubRenameArtifactFile(func(string, string) error { + return errors.New("rename failed") + }) + defer restore() + + err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 42"), output) + + if err == nil { + t.Fatal("expected error") + } + + if _, statErr := os.Stat(output); !os.IsNotExist(statErr) { + t.Fatalf("expected artifact to be absent, stat err=%v", statErr) + } + + assertNoTempArtifacts(t, filepath.Dir(output), output) +} + +func TestWriteArtifact_ArtifactRoundTrip(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 42") + + if err := WriteArtifact(compiler.New(), file.NewSource(input, "RETURN 42"), output); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 42") +} + +func TestWriteArtifact_InvalidQueryDoesNotCreateArtifactInMissingParentDirectory(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "broken.fql") + output := filepath.Join(dir, "nested", "out", "broken.fqlc") + + writeQuery(t, input, "FOR item IN") + + err := WriteArtifact(compiler.New(), file.NewSource(input, "FOR item IN"), output) + + if err == nil { + t.Fatal("expected error") + } + + if _, statErr := os.Stat(output); !os.IsNotExist(statErr) { + t.Fatalf("expected artifact to be absent, stat err=%v", statErr) + } +} + +func writeQuery(t *testing.T, path, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func assertArtifactSource(t *testing.T, path, expected string) { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + program, err := artifact.Unmarshal(data) + if err != nil { + t.Fatalf("unmarshal artifact: %v", err) + } + + if program.Source == nil { + t.Fatal("expected serialized source") + } + + if program.Source.Content() != expected { + t.Fatalf("expected source %q, got %q", expected, program.Source.Content()) + } +} + +func assertNoTempArtifacts(t *testing.T, dir, outputPath string) { + t.Helper() + + matches, err := filepath.Glob(filepath.Join(dir, artifactTempPattern(outputPath))) + if err != nil { + t.Fatal(err) + } + + if len(matches) != 0 { + t.Fatalf("expected temporary artifacts to be cleaned up, got %v", matches) + } +} + +func stubRenameArtifactFile(fn func(string, string) error) func() { + previous := renameArtifactFile + renameArtifactFile = fn + + return func() { + renameArtifactFile = previous + } +} diff --git a/pkg/build/path.go b/pkg/build/path.go new file mode 100644 index 0000000..44a3788 --- /dev/null +++ b/pkg/build/path.go @@ -0,0 +1,70 @@ +package build + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" +) + +const artifactFileExtension = ".fqlc" + +func artifactFileName(path string) string { + base := filepath.Base(path) + ext := filepath.Ext(base) + + if ext == "" { + return base + artifactFileExtension + } + + return strings.TrimSuffix(base, ext) + artifactFileExtension +} + +func siblingArtifactPath(path string) string { + return filepath.Join(filepath.Dir(path), artifactFileName(path)) +} + +func canonicalPath(path string) (string, error) { + resolved, err := filepath.Abs(path) + + if err != nil { + return "", fmt.Errorf("resolve path %s: %w", path, err) + } + + return filepath.Clean(resolved), nil +} + +func samePath(left, right string) (bool, error) { + leftPath, err := canonicalPath(left) + + if err != nil { + return false, err + } + + rightPath, err := canonicalPath(right) + + if err != nil { + return false, err + } + + if leftPath == rightPath { + return true, nil + } + + leftInfo, err := os.Stat(leftPath) + if err != nil { + return false, fmt.Errorf("inspect %s: %w", left, err) + } + + rightInfo, err := os.Stat(rightPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + + return false, fmt.Errorf("inspect %s: %w", right, err) + } + + return os.SameFile(leftInfo, rightInfo), nil +} diff --git a/pkg/build/plan.go b/pkg/build/plan.go new file mode 100644 index 0000000..d8ed937 --- /dev/null +++ b/pkg/build/plan.go @@ -0,0 +1,117 @@ +package build + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +func PlanOutputs(inputs []string, output string) (Plan, error) { + if len(inputs) == 0 { + return Plan{}, fmt.Errorf("build requires at least one input file") + } + + if output == "" { + targets, err := planTargets(inputs, siblingArtifactPath) + + if err != nil { + return Plan{}, err + } + + return Plan{Targets: targets}, nil + } + + if len(inputs) == 1 { + return planSingleOutput(inputs[0], output) + } + + return planMultiOutput(inputs, output) +} + +func planSingleOutput(input, output string) (Plan, error) { + info, err := os.Stat(output) + + switch { + case err == nil && info.IsDir(): + return Plan{ + OutputDir: output, + Targets: []Target{ + { + SourcePath: input, + OutputPath: filepath.Join(output, artifactFileName(input)), + }, + }, + }, nil + case err == nil: + return Plan{ + Targets: []Target{ + { + SourcePath: input, + OutputPath: output, + }, + }, + }, nil + case errors.Is(err, os.ErrNotExist): + return Plan{ + Targets: []Target{ + { + SourcePath: input, + OutputPath: output, + }, + }, + }, nil + default: + return Plan{}, fmt.Errorf("inspect output %s: %w", output, err) + } +} + +func planMultiOutput(inputs []string, output string) (Plan, error) { + info, err := os.Stat(output) + + switch { + case err == nil && !info.IsDir(): + return Plan{}, fmt.Errorf("--output must be a directory when building multiple files") + case err != nil && !errors.Is(err, os.ErrNotExist): + return Plan{}, fmt.Errorf("inspect output %s: %w", output, err) + } + + targets, err := planTargets(inputs, func(input string) string { + return filepath.Join(output, artifactFileName(input)) + }) + + if err != nil { + return Plan{}, err + } + + return Plan{ + OutputDir: output, + Targets: targets, + }, nil +} + +func planTargets(inputs []string, outputPath func(string) string) ([]Target, error) { + targets := make([]Target, 0, len(inputs)) + seen := make(map[string]string, len(inputs)) + + for _, input := range inputs { + path := outputPath(input) + key, err := canonicalPath(path) + + if err != nil { + return nil, err + } + + if prev, exists := seen[key]; exists { + return nil, fmt.Errorf("output collision: %s and %s both map to %s", prev, input, path) + } + + seen[key] = input + targets = append(targets, Target{ + SourcePath: input, + OutputPath: path, + }) + } + + return targets, nil +} diff --git a/pkg/build/types.go b/pkg/build/types.go new file mode 100644 index 0000000..a321eb4 --- /dev/null +++ b/pkg/build/types.go @@ -0,0 +1,13 @@ +package build + +type ( + Target struct { + OutputPath string + SourcePath string + } + + Plan struct { + OutputDir string + Targets []Target + } +) diff --git a/pkg/run/execute.go b/pkg/run/execute.go new file mode 100644 index 0000000..9214413 --- /dev/null +++ b/pkg/run/execute.go @@ -0,0 +1,25 @@ +package run + +import ( + "context" + "fmt" + "io" + + cliruntime "github.com/MontFerret/cli/pkg/runtime" +) + +func Execute(ctx context.Context, opts cliruntime.Options, params map[string]any, input *Input) (io.ReadCloser, error) { + if input == nil { + return nil, fmt.Errorf("run input is nil") + } + + if len(input.Artifact) > 0 { + return cliruntime.RunArtifact(ctx, opts, input.Artifact, params) + } + + if input.Source == nil { + return nil, fmt.Errorf("run source is nil") + } + + return cliruntime.Run(ctx, opts, input.Source, params) +} diff --git a/pkg/run/resolve.go b/pkg/run/resolve.go new file mode 100644 index 0000000..1d3295f --- /dev/null +++ b/pkg/run/resolve.go @@ -0,0 +1,71 @@ +package run + +import ( + "bufio" + "fmt" + "io" + "os" + + "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" + "github.com/MontFerret/ferret/v2/pkg/file" +) + +func ResolveInput(eval string, args []string) (*Input, error) { + if eval != "" { + return &Input{ + Source: file.NewSource("", eval), + }, nil + } + + if len(args) > 1 { + return nil, fmt.Errorf("run accepts at most one file argument") + } + + if len(args) == 1 { + return resolveFile(args[0]) + } + + return resolveStdin() +} + +func resolveFile(path string) (*Input, error) { + data, err := os.ReadFile(path) + + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + return resolveData(path, data), nil +} + +func resolveStdin() (*Input, error) { + stat, err := os.Stdin.Stat() + + if err != nil { + return nil, fmt.Errorf("stat stdin: %w", err) + } + + if (stat.Mode() & os.ModeCharDevice) != 0 { + return nil, nil + } + + data, err := io.ReadAll(bufio.NewReader(os.Stdin)) + + if err != nil { + return nil, fmt.Errorf("reading stdin: %w", err) + } + + return resolveData("stdin", data), nil +} + +func resolveData(name string, data []byte) *Input { + if artifact.HasMagic(data) { + return &Input{ + Artifact: data, + } + } + + return &Input{ + Source: file.NewSource(name, string(data)), + } +} diff --git a/pkg/run/run_test.go b/pkg/run/run_test.go new file mode 100644 index 0000000..d0e2916 --- /dev/null +++ b/pkg/run/run_test.go @@ -0,0 +1,380 @@ +package run + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MontFerret/ferret/v2/pkg/compiler" + "github.com/MontFerret/ferret/v2/pkg/file" + + "github.com/MontFerret/cli/pkg/build" + cliruntime "github.com/MontFerret/cli/pkg/runtime" +) + +func TestResolveInput_SourceFile(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + + writeQuery(t, input, "RETURN 42") + + resolved, err := ResolveInput("", []string{input}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resolved == nil || resolved.Source == nil { + t.Fatal("expected source input") + } + + if resolved.Source.Content() != "RETURN 42" { + t.Fatalf("unexpected source content: %q", resolved.Source.Content()) + } +} + +func TestResolveInput_RejectsMultipleArgs(t *testing.T) { + resolved, err := ResolveInput("", []string{"first.fql", "second.fql"}) + + if err == nil { + t.Fatal("expected error") + } + + if resolved != nil { + t.Fatalf("expected nil input, got %#v", resolved) + } + + if !strings.Contains(err.Error(), "at most one file argument") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolveInput_StdinSource(t *testing.T) { + withStdinBytes(t, []byte("RETURN 42\n"), func() { + resolved, err := ResolveInput("", nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resolved == nil || resolved.Source == nil { + t.Fatal("expected source input") + } + + if resolved.Source.Name() != "stdin" { + t.Fatalf("unexpected source name: %q", resolved.Source.Name()) + } + + if resolved.Source.Content() != "RETURN 42\n" { + t.Fatalf("unexpected source content: %q", resolved.Source.Content()) + } + }) +} + +func TestResolveInput_StdinArtifact(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + outputPath := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 42") + buildArtifact(t, input, outputPath) + data := readFile(t, outputPath) + + withStdinBytes(t, data, func() { + resolved, err := ResolveInput("", nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resolved == nil || len(resolved.Artifact) == 0 { + t.Fatal("expected artifact input") + } + }) +} + +func TestExecute_CompiledArtifact(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + + writeQuery(t, input, "RETURN 42") + buildArtifact(t, input, filepath.Join(dir, "query.fqlc")) + + resolved, err := ResolveInput("", []string{filepath.Join(dir, "query.fqlc")}) + + if err != nil { + t.Fatalf("unexpected resolve error: %v", err) + } + + output, err := Execute(context.Background(), cliruntime.NewDefaultOptions(), nil, resolved) + + if err != nil { + t.Fatalf("unexpected execute error: %v", err) + } + + assertOutput(t, output, "42") +} + +func TestExecute_CompiledArtifactCustomName(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + outputPath := filepath.Join(dir, "compiled.bin") + + writeQuery(t, input, "RETURN 42") + buildArtifact(t, input, outputPath) + + resolved, err := ResolveInput("", []string{outputPath}) + + if err != nil { + t.Fatalf("unexpected resolve error: %v", err) + } + + output, err := Execute(context.Background(), cliruntime.NewDefaultOptions(), nil, resolved) + + if err != nil { + t.Fatalf("unexpected execute error: %v", err) + } + + assertOutput(t, output, "42") +} + +func TestExecute_CompiledArtifactWithParams(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + outputPath := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN @value") + buildArtifact(t, input, outputPath) + + resolved, err := ResolveInput("", []string{outputPath}) + + if err != nil { + t.Fatalf("unexpected resolve error: %v", err) + } + + output, err := Execute(context.Background(), cliruntime.NewDefaultOptions(), map[string]any{"value": float64(99)}, resolved) + + if err != nil { + t.Fatalf("unexpected execute error: %v", err) + } + + assertOutput(t, output, "99") +} + +func TestExecute_CompiledArtifactFromStdin(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + outputPath := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 42") + buildArtifact(t, input, outputPath) + data := readFile(t, outputPath) + + withStdinBytes(t, data, func() { + resolved, err := ResolveInput("", nil) + + if err != nil { + t.Fatalf("unexpected resolve error: %v", err) + } + + output, err := Execute(context.Background(), cliruntime.NewDefaultOptions(), nil, resolved) + + if err != nil { + t.Fatalf("unexpected execute error: %v", err) + } + + assertOutput(t, output, "42") + }) +} + +func TestResolveInput_PlainTextFQLCIsSource(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 7") + + resolved, err := ResolveInput("", []string{input}) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resolved == nil || resolved.Source == nil { + t.Fatal("expected source input") + } + + if resolved.Source.Content() != "RETURN 7" { + t.Fatalf("unexpected source content: %q", resolved.Source.Content()) + } +} + +func TestExecute_CorruptArtifactReturnsLoadError(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "broken.bin") + + if err := os.WriteFile(input, []byte("FBC2"), 0o644); err != nil { + t.Fatal(err) + } + + resolved, err := ResolveInput("", []string{input}) + + if err != nil { + t.Fatalf("unexpected resolve error: %v", err) + } + + _, err = Execute(context.Background(), cliruntime.NewDefaultOptions(), nil, resolved) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "bytecode artifact") { + t.Fatalf("expected artifact load error, got %v", err) + } +} + +func TestExecute_CorruptArtifactFromStdinReturnsLoadError(t *testing.T) { + withStdinBytes(t, []byte("FBC2"), func() { + resolved, err := ResolveInput("", nil) + + if err != nil { + t.Fatalf("unexpected resolve error: %v", err) + } + + _, err = Execute(context.Background(), cliruntime.NewDefaultOptions(), nil, resolved) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "bytecode artifact") { + t.Fatalf("expected artifact load error, got %v", err) + } + }) +} + +func TestResolveInput_NoStdinReturnsNil(t *testing.T) { + withDevNullStdin(t, func() { + resolved, err := ResolveInput("", nil) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resolved != nil { + t.Fatalf("expected nil input, got %#v", resolved) + } + }) +} + +func writeQuery(t *testing.T, path, content string) { + t.Helper() + + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func withStdinBytes(t *testing.T, data []byte, fn func()) { + t.Helper() + + original := os.Stdin + reader, writer, err := os.Pipe() + + if err != nil { + t.Fatal(err) + } + + if _, err := writer.Write(data); err != nil { + t.Fatal(err) + } + + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + os.Stdin = reader + defer func() { + os.Stdin = original + reader.Close() + }() + + fn() +} + +func withDevNullStdin(t *testing.T, fn func()) { + t.Helper() + + original := os.Stdin + stdin, err := os.Open(os.DevNull) + + if err != nil { + t.Fatal(err) + } + + os.Stdin = stdin + defer func() { + os.Stdin = original + stdin.Close() + }() + + fn() +} + +func buildArtifact(t *testing.T, inputPath, outputPath string) { + t.Helper() + + src := readSource(t, inputPath) + + if err := build.WriteArtifact(nilCompiler(), src, outputPath); err != nil { + t.Fatalf("build artifact: %v", err) + } +} + +func readSource(t *testing.T, path string) *file.Source { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + return file.NewSource(path, string(data)) +} + +func readFile(t *testing.T, path string) []byte { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + return data +} + +func assertOutput(t *testing.T, output io.ReadCloser, expected string) { + t.Helper() + + defer output.Close() + + data, err := io.ReadAll(output) + if err != nil { + t.Fatal(err) + } + + if strings.TrimSpace(string(data)) != expected { + t.Fatalf("expected %s, got %q", expected, string(data)) + } +} + +func nilCompiler() *compiler.Compiler { + return compiler.New() +} diff --git a/pkg/run/types.go b/pkg/run/types.go new file mode 100644 index 0000000..16e71d2 --- /dev/null +++ b/pkg/run/types.go @@ -0,0 +1,8 @@ +package run + +import "github.com/MontFerret/ferret/v2/pkg/file" + +type Input struct { + Artifact []byte + Source *file.Source +} diff --git a/pkg/runtime/builtin.go b/pkg/runtime/builtin.go index 8e0a22f..2ec8e7d 100644 --- a/pkg/runtime/builtin.go +++ b/pkg/runtime/builtin.go @@ -69,3 +69,39 @@ func (rt *Builtin) Run(ctx context.Context, query *file.Source, params map[strin return io.NopCloser(bytes.NewBuffer(res.Content)), nil } + +func (rt *Builtin) RunArtifact(ctx context.Context, data []byte, params map[string]any) (io.ReadCloser, error) { + parsedParams, err := runtime.NewParamsFrom(params) + + if err != nil { + return nil, err + } + + plan, err := rt.engine.Load(data) + + if err != nil { + return nil, err + } + + defer func() { + _ = plan.Close() + }() + + session, err := plan.NewSession(ctx, ferret.WithSessionParams(parsedParams)) + + if err != nil { + return nil, err + } + + defer func() { + _ = session.Close() + }() + + res, err := session.Run(ctx) + + if err != nil { + return nil, err + } + + return io.NopCloser(bytes.NewBuffer(res.Content)), nil +} diff --git a/pkg/runtime/errors.go b/pkg/runtime/errors.go new file mode 100644 index 0000000..bd717cd --- /dev/null +++ b/pkg/runtime/errors.go @@ -0,0 +1,6 @@ +package runtime + +import "errors" + +// ErrArtifactRequiresBuiltinRuntime indicates compiled artifacts can only run on the builtin runtime. +var ErrArtifactRequiresBuiltinRuntime = errors.New("compiled artifacts require the builtin runtime") diff --git a/pkg/runtime/remote.go b/pkg/runtime/remote.go index e91b56e..f8368f0 100644 --- a/pkg/runtime/remote.go +++ b/pkg/runtime/remote.go @@ -79,6 +79,10 @@ func (rt *Remote) Run(ctx context.Context, query *file.Source, params map[string return rt.makeRequest(ctx, "POST", "/", body) } +func (rt *Remote) RunArtifact(_ context.Context, _ []byte, _ map[string]any) (io.ReadCloser, error) { + return nil, ErrArtifactRequiresBuiltinRuntime +} + func (rt *Remote) createRequest(ctx context.Context, method, endpoint string, body []byte) (*http.Request, error) { var reader io.Reader diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 34cc3bc..864ac71 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -14,12 +14,13 @@ type Runtime interface { Version(ctx context.Context) (string, error) Run(ctx context.Context, query *file.Source, params map[string]any) (io.ReadCloser, error) + RunArtifact(ctx context.Context, data []byte, params map[string]any) (io.ReadCloser, error) } func New(opts Options) (Runtime, error) { - name := strings.ReplaceAll(strings.ToLower(opts.Type), " ", "") + name := normalizeRuntimeType(opts.Type) - if name == DefaultRuntime { + if IsBuiltinType(name) { return NewBuiltin(opts) } @@ -41,3 +42,21 @@ func Run(ctx context.Context, opts Options, query *file.Source, params map[strin return rt.Run(ctx, query, params) } + +func RunArtifact(ctx context.Context, opts Options, data []byte, params map[string]any) (io.ReadCloser, error) { + rt, err := New(opts) + + if err != nil { + return nil, err + } + + return rt.RunArtifact(ctx, data, params) +} + +func IsBuiltinType(name string) bool { + return normalizeRuntimeType(name) == DefaultRuntime +} + +func normalizeRuntimeType(name string) string { + return strings.ReplaceAll(strings.ToLower(strings.TrimSpace(name)), " ", "") +} diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go new file mode 100644 index 0000000..ea44145 --- /dev/null +++ b/pkg/runtime/runtime_test.go @@ -0,0 +1,19 @@ +package runtime + +import ( + "context" + "errors" + "testing" +) + +func TestRunArtifact_RemoteRuntimeRejected(t *testing.T) { + _, err := RunArtifact(context.Background(), Options{Type: "https://worker.example"}, []byte("FBC2"), nil) + + if err == nil { + t.Fatal("expected error") + } + + if !errors.Is(err, ErrArtifactRequiresBuiltinRuntime) { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/revive.toml b/revive.toml index 8e88b4b..1cf7d3d 100644 --- a/revive.toml +++ b/revive.toml @@ -13,7 +13,6 @@ warningCode = 0 [rule.error-naming] [rule.if-return] [rule.increment-decrement] -[rule.var-naming] [rule.var-declaration] [rule.range] [rule.receiver-naming]