From 78854a267268cb7450e4acfb333a6f80b3115d70 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 11:02:17 -0400 Subject: [PATCH 01/15] Added `build` command to compile FQL scripts into bytecode artifacts, updated dependencies, and enhanced documentation. --- README.md | 15 +++ cmd/build.go | 281 +++++++++++++++++++++++++++++++++++++++++ cmd/build_test.go | 313 ++++++++++++++++++++++++++++++++++++++++++++++ ferret/main.go | 1 + go.mod | 2 +- go.sum | 7 +- 6 files changed, 615 insertions(+), 4 deletions(-) create mode 100644 cmd/build.go create mode 100644 cmd/build_test.go diff --git a/README.md b/README.md index a8a71cb..08ae501 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 @@ -244,6 +245,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 for a single input, or output directory for multiple 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..e192289 --- /dev/null +++ b/cmd/build.go @@ -0,0 +1,281 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" + "github.com/MontFerret/ferret/v2/pkg/compiler" + "github.com/MontFerret/ferret/v2/pkg/file" + + "github.com/MontFerret/cli/pkg/config" + "github.com/MontFerret/cli/pkg/source" +) + +const artifactFileExtension = ".fqlc" + +type ( + buildTarget struct { + OutputPath string + SourcePath string + } + + buildPlan struct { + OutputDir string + Targets []buildTarget + } +) + +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 file path (single input) or directory (multiple inputs)") + + return cmd +} + +func runBuild(args []string, output string) error { + plan, err := planBuild(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 := buildSource(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 +} + +func planBuild(inputs []string, output string) (buildPlan, error) { + if len(inputs) == 0 { + return buildPlan{}, fmt.Errorf("build requires at least one input file") + } + + if output == "" { + targets := make([]buildTarget, 0, len(inputs)) + + for _, input := range inputs { + targets = append(targets, buildTarget{ + SourcePath: input, + OutputPath: siblingArtifactPath(input), + }) + } + + return buildPlan{Targets: targets}, nil + } + + if len(inputs) == 1 { + return planSingleBuild(inputs[0], output) + } + + return planMultiBuild(inputs, output) +} + +func planSingleBuild(input, output string) (buildPlan, error) { + info, err := os.Stat(output) + + switch { + case err == nil && info.IsDir(): + return buildPlan{ + OutputDir: output, + Targets: []buildTarget{ + { + SourcePath: input, + OutputPath: filepath.Join(output, artifactFileName(input)), + }, + }, + }, nil + case err == nil: + return buildPlan{ + Targets: []buildTarget{ + { + SourcePath: input, + OutputPath: output, + }, + }, + }, nil + case errors.Is(err, os.ErrNotExist): + return buildPlan{ + Targets: []buildTarget{ + { + SourcePath: input, + OutputPath: output, + }, + }, + }, nil + default: + return buildPlan{}, fmt.Errorf("inspect output %s: %w", output, err) + } +} + +func planMultiBuild(inputs []string, output string) (buildPlan, error) { + info, err := os.Stat(output) + + switch { + case err == nil && !info.IsDir(): + return buildPlan{}, fmt.Errorf("--output must be a directory when building multiple files") + case err != nil && !errors.Is(err, os.ErrNotExist): + return buildPlan{}, fmt.Errorf("inspect output %s: %w", output, err) + } + + targets := make([]buildTarget, 0, len(inputs)) + seen := make(map[string]string, len(inputs)) + + for _, input := range inputs { + outputPath := filepath.Join(output, artifactFileName(input)) + key, err := canonicalPath(outputPath) + + if err != nil { + return buildPlan{}, err + } + + if prev, exists := seen[key]; exists { + return buildPlan{}, fmt.Errorf("output collision: %s and %s both map to %s", prev, input, outputPath) + } + + seen[key] = input + targets = append(targets, buildTarget{ + SourcePath: input, + OutputPath: outputPath, + }) + } + + return buildPlan{ + OutputDir: output, + Targets: targets, + }, nil +} + +func buildSource(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) + } + + if err := os.WriteFile(outputPath, data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", outputPath, err) + } + + return nil +} + +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 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 +} + +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 +} diff --git a/cmd/build_test.go b/cmd/build_test.go new file mode 100644 index 0000000..bbf4be5 --- /dev/null +++ b/cmd/build_test.go @@ -0,0 +1,313 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" +) + +func TestRunBuild_DefaultOutputPath(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + + writeQuery(t, input, "RETURN 42") + + stderr, err := captureStderr(t, func() error { + return runBuild([]string{input}, "") + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if stderr != "" { + t.Fatalf("expected no stderr output, got %q", stderr) + } + + output := filepath.Join(dir, "query.fqlc") + assertArtifactSource(t, output, "RETURN 42") +} + +func TestRunBuild_SingleFileExplicitOutput(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "compiled.bin") + + writeQuery(t, input, "RETURN 42") + + if _, err := captureStderr(t, func() error { + return runBuild([]string{input}, output) + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 42") + + if _, err := os.Stat(filepath.Join(dir, "query.fqlc")); !os.IsNotExist(err) { + t.Fatalf("expected sibling artifact to be absent, stat err=%v", err) + } +} + +func TestRunBuild_SingleFileOutputDirectory(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + outputDir := filepath.Join(dir, "dist") + + writeQuery(t, input, "RETURN 42") + + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatal(err) + } + + if _, err := captureStderr(t, func() error { + return runBuild([]string{input}, outputDir) + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, filepath.Join(outputDir, "query.fqlc"), "RETURN 42") +} + +func TestRunBuild_MultiFileOutputDirectory(t *testing.T) { + dir := t.TempDir() + inputA := filepath.Join(dir, "first.fql") + inputB := filepath.Join(dir, "second") + outputDir := filepath.Join(dir, "dist") + + writeQuery(t, inputA, "RETURN 1") + writeQuery(t, inputB, "RETURN 2") + + if _, err := captureStderr(t, func() error { + return runBuild([]string{inputA, inputB}, outputDir) + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, filepath.Join(outputDir, "first.fqlc"), "RETURN 1") + assertArtifactSource(t, filepath.Join(outputDir, "second.fqlc"), "RETURN 2") +} + +func TestRunBuild_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") + + 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) + } +} + +func TestRunBuild_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") + + writeQuery(t, inputA, "RETURN 1") + writeQuery(t, inputB, "RETURN 2") + + _, err := captureStderr(t, func() error { + return runBuild([]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 TestRunBuild_RejectsOverwritingSource(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + query := "RETURN 42" + + writeQuery(t, input, query) + + stderr, err := captureStderr(t, func() error { + return runBuild([]string{input}, input) + }) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "1 of 1 scripts failed to build") { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(stderr, "would overwrite source file") { + t.Fatalf("expected overwrite message, got %q", stderr) + } + + 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 TestRunBuild_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 := captureStderr(t, func() error { + return runBuild([]string{input}, "") + }) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "1 of 1 scripts failed to build") { + t.Fatalf("unexpected error: %v", err) + } + + if _, statErr := os.Stat(output); !os.IsNotExist(statErr) { + t.Fatalf("expected artifact to be absent, stat err=%v", statErr) + } +} + +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) + } + + assertArtifactSource(t, filepath.Join(outputDir, "valid.fqlc"), "RETURN 1") + + if _, statErr := os.Stat(filepath.Join(outputDir, "invalid.fqlc")); !os.IsNotExist(statErr) { + t.Fatalf("expected invalid artifact to be absent, stat err=%v", statErr) + } +} + +func TestRunBuild_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 := captureStderr(t, func() error { + return runBuild([]string{input}, "") + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 1") + + writeQuery(t, input, "RETURN 2") + + if _, err := captureStderr(t, func() error { + return runBuild([]string{input}, "") + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertArtifactSource(t, output, "RETURN 2") +} + +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 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 +} 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..4b086d5 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.1.0.20260327121926-6fafc61cbb9f 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..1d0df6a 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.1.0.20260327121926-6fafc61cbb9f h1:LRjlSLmrM1F/MXX0W6v9h38n4PDjUftH350RYFBmsxw= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.1.0.20260327121926-6fafc61cbb9f/go.mod h1:G49K5xQ5UfgRf0SmkHYqODrXqBeMwcCxvrj8nANA8eU= 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= From 5b57ca83483e9f429016eebbb68034840bcaaad4 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 12:00:01 -0400 Subject: [PATCH 02/15] Added `runArtifact` support to execute compiled FQL artifacts, updated dependencies, and expanded tests and documentation. --- README.md | 4 +- cmd/run.go | 122 +++++++++++++++---- cmd/run_test.go | 236 ++++++++++++++++++++++++++++++++++++ go.mod | 2 +- go.sum | 4 +- pkg/runtime/builtin.go | 36 ++++++ pkg/runtime/remote.go | 4 + pkg/runtime/runtime.go | 23 +++- pkg/runtime/runtime_test.go | 19 +++ 9 files changed, 423 insertions(+), 27 deletions(-) create mode 100644 cmd/run_test.go create mode 100644 pkg/runtime/runtime_test.go diff --git a/README.md b/README.md index 08ae501..3e2f764 100644 --- a/README.md +++ b/README.md @@ -208,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. When called with no arguments, `ferret` launches the REPL. ```bash ferret run [script] @@ -227,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, so files 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. diff --git a/cmd/run.go b/cmd/run.go index 13d358c..2cd0662 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -7,6 +7,8 @@ import ( "github.com/spf13/cobra" + "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" + "github.com/MontFerret/ferret/v2/pkg/file" "github.com/MontFerret/cli/pkg/browser" @@ -15,12 +17,17 @@ import ( "github.com/MontFerret/cli/pkg/source" ) +type runInput struct { + Artifact []byte + Source *file.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,35 +55,45 @@ func RunCommand(store *config.Store) *cobra.Command { } store := config.From(cmd.Context()) - rtOpts := store.GetRuntimeOptions() + return executeRun(cmd, store.GetRuntimeOptions(), store.GetBrowserOptions(), params, eval, args) + }, + } - cleanup, err := browser.EnsureBrowser(cmd.Context(), rtOpts, store.GetBrowserOptions()) + addEvalFlag(cmd) + addParamFlags(cmd) + addRuntimeFlags(cmd) - if err != nil { - return err - } + return cmd +} - defer cleanup() +func executeRun(cmd *cobra.Command, rtOpts cliruntime.Options, brOpts browser.Options, params map[string]interface{}, eval string, args []string) error { + input, err := resolveRunInput(eval, args) - sources, err := source.Resolve(source.Input{Eval: eval, Args: args}) + if err != nil { + return err + } - if err != nil { - return err - } + if input == nil { + return cmd.Help() + } - if sources == nil { - return cmd.Help() - } + if len(input.Artifact) > 0 && !cliruntime.IsBuiltinType(rtOpts.Type) { + return fmt.Errorf("compiled artifacts require the builtin runtime") + } - return runScript(cmd, rtOpts, params, sources[0]) - }, + cleanup, err := browser.EnsureBrowser(cmd.Context(), rtOpts, brOpts) + + if err != nil { + return err } - addEvalFlag(cmd) - addParamFlags(cmd) - addRuntimeFlags(cmd) + defer cleanup() - return cmd + if len(input.Artifact) > 0 { + return runArtifact(cmd, rtOpts, params, input.Artifact) + } + + return runScript(cmd, rtOpts, params, input.Source) } func runScript(cmd *cobra.Command, opts cliruntime.Options, params map[string]interface{}, query *file.Source) error { @@ -93,3 +110,66 @@ func runScript(cmd *cobra.Command, opts cliruntime.Options, params map[string]in return err } + +func runArtifact(cmd *cobra.Command, opts cliruntime.Options, params map[string]interface{}, artifactData []byte) error { + out, err := cliruntime.RunArtifact(cmd.Context(), opts, artifactData, params) + + if err != nil { + printError(err) + return err + } + + defer out.Close() + + _, err = io.Copy(os.Stdout, out) + + return err +} + +func resolveRunInput(eval string, args []string) (*runInput, error) { + if eval != "" { + return &runInput{ + Source: file.NewSource("", eval), + }, nil + } + + if len(args) == 1 { + return resolveRunFile(args[0]) + } + + sources, err := source.Resolve(source.Input{}) + + if err != nil { + return nil, err + } + + if sources == nil { + return nil, nil + } + + return &runInput{ + Source: sources[0], + }, nil +} + +func resolveRunFile(path string) (*runInput, error) { + data, err := os.ReadFile(path) + + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + if isArtifactData(data) { + return &runInput{ + Artifact: data, + }, nil + } + + return &runInput{ + Source: file.NewSource(path, string(data)), + }, nil +} + +func isArtifactData(data []byte) bool { + return artifact.HasMagic(data) +} diff --git a/cmd/run_test.go b/cmd/run_test.go new file mode 100644 index 0000000..9992833 --- /dev/null +++ b/cmd/run_test.go @@ -0,0 +1,236 @@ +package cmd + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/MontFerret/cli/pkg/browser" + "github.com/MontFerret/cli/pkg/config" + cliruntime "github.com/MontFerret/cli/pkg/runtime" +) + +func TestExecuteRun_SourceFile(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + + writeQuery(t, input, "RETURN 42") + + stdout, err := captureStdout(t, func() error { + return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{input}) + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.TrimSpace(stdout) != "42" { + t.Fatalf("expected 42, got %q", stdout) + } +} + +func TestExecuteRun_CompiledArtifact(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + + writeQuery(t, input, "RETURN 42") + + if _, err := captureStderr(t, func() error { + return runBuild([]string{input}, "") + }); err != nil { + t.Fatalf("unexpected build error: %v", err) + } + + stdout, err := captureStdout(t, func() error { + return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{siblingArtifactPath(input)}) + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.TrimSpace(stdout) != "42" { + t.Fatalf("expected 42, got %q", stdout) + } +} + +func TestExecuteRun_CompiledArtifactCustomName(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + output := filepath.Join(dir, "compiled.bin") + + writeQuery(t, input, "RETURN 42") + + if _, err := captureStderr(t, func() error { + return runBuild([]string{input}, output) + }); err != nil { + t.Fatalf("unexpected build error: %v", err) + } + + stdout, err := captureStdout(t, func() error { + return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{output}) + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.TrimSpace(stdout) != "42" { + t.Fatalf("expected 42, got %q", stdout) + } +} + +func TestExecuteRun_CompiledArtifactWithParams(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + + writeQuery(t, input, "RETURN @value") + + if _, err := captureStderr(t, func() error { + return runBuild([]string{input}, "") + }); err != nil { + t.Fatalf("unexpected build error: %v", err) + } + + stdout, err := captureStdout(t, func() error { + return executeRun( + newTestCommand(), + cliruntime.NewDefaultOptions(), + browser.Options{}, + map[string]interface{}{"value": float64(99)}, + "", + []string{siblingArtifactPath(input)}, + ) + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.TrimSpace(stdout) != "99" { + t.Fatalf("expected 99, got %q", stdout) + } +} + +func TestExecuteRun_PlainTextFQLCIsSource(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fqlc") + + writeQuery(t, input, "RETURN 7") + + stdout, err := captureStdout(t, func() error { + return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{input}) + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if strings.TrimSpace(stdout) != "7" { + t.Fatalf("expected 7, got %q", stdout) + } +} + +func TestExecuteRun_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) + } + + _, err := captureStderr(t, func() error { + return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{input}) + }) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "bytecode artifact") { + t.Fatalf("expected artifact load error, got %v", err) + } +} + +func TestExecuteRun_ArtifactRemoteRuntimeRejected(t *testing.T) { + dir := t.TempDir() + input := filepath.Join(dir, "query.fql") + + writeQuery(t, input, "RETURN 42") + + if _, err := captureStderr(t, func() error { + return runBuild([]string{input}, "") + }); err != nil { + t.Fatalf("unexpected build error: %v", err) + } + + _, err := captureStdout(t, func() error { + return executeRun( + newTestCommand(), + cliruntime.Options{Type: "https://worker.example"}, + browser.Options{}, + nil, + "", + []string{siblingArtifactPath(input)}, + ) + }) + + if err == nil { + t.Fatal("expected error") + } + + if !strings.Contains(err.Error(), "compiled artifacts require the builtin runtime") { + 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 newTestCommand() *cobra.Command { + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + return cmd +} + +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 +} diff --git a/go.mod b/go.mod index 4b086d5..0558634 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.0.20260327121926-6fafc61cbb9f + github.com/MontFerret/ferret/v2 v2.0.0-alpha.2 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 1d0df6a..64bd7a9 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.0.20260327121926-6fafc61cbb9f h1:LRjlSLmrM1F/MXX0W6v9h38n4PDjUftH350RYFBmsxw= -github.com/MontFerret/ferret/v2 v2.0.0-alpha.1.0.20260327121926-6fafc61cbb9f/go.mod h1:G49K5xQ5UfgRf0SmkHYqODrXqBeMwcCxvrj8nANA8eU= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.2 h1:a+06KsJPU9a/NF84x90xDG4a7GXNxEZWhGt+rNtOrZU= +github.com/MontFerret/ferret/v2 v2.0.0-alpha.2/go.mod h1:G49K5xQ5UfgRf0SmkHYqODrXqBeMwcCxvrj8nANA8eU= 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= 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/remote.go b/pkg/runtime/remote.go index e91b56e..a315ed8 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, fmt.Errorf("compiled artifacts require the builtin runtime") +} + 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..1503616 --- /dev/null +++ b/pkg/runtime/runtime_test.go @@ -0,0 +1,19 @@ +package runtime + +import ( + "context" + "strings" + "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 !strings.Contains(err.Error(), "compiled artifacts require the builtin runtime") { + t.Fatalf("unexpected error: %v", err) + } +} From 0c552d0ede154aae17ab6e7a71df1998fc635530 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 12:24:38 -0400 Subject: [PATCH 03/15] Refactored `build` command: extracted planning and artifact writing logic to `pkg/build`, added comprehensive tests. --- cmd/build.go | 213 +---------------------------- cmd/build_test.go | 292 +++------------------------------------- cmd/run_test.go | 49 ++++++- pkg/build/artifact.go | 40 ++++++ pkg/build/build_test.go | 249 ++++++++++++++++++++++++++++++++++ pkg/build/path.go | 70 ++++++++++ pkg/build/plan.go | 108 +++++++++++++++ pkg/build/types.go | 13 ++ 8 files changed, 544 insertions(+), 490 deletions(-) create mode 100644 pkg/build/artifact.go create mode 100644 pkg/build/build_test.go create mode 100644 pkg/build/path.go create mode 100644 pkg/build/plan.go create mode 100644 pkg/build/types.go diff --git a/cmd/build.go b/cmd/build.go index e192289..6c1029e 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -1,36 +1,18 @@ package cmd import ( - "errors" "fmt" "os" - "path/filepath" - "strings" "github.com/spf13/cobra" - "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" "github.com/MontFerret/ferret/v2/pkg/compiler" - "github.com/MontFerret/ferret/v2/pkg/file" + "github.com/MontFerret/cli/pkg/build" "github.com/MontFerret/cli/pkg/config" "github.com/MontFerret/cli/pkg/source" ) -const artifactFileExtension = ".fqlc" - -type ( - buildTarget struct { - OutputPath string - SourcePath string - } - - buildPlan struct { - OutputDir string - Targets []buildTarget - } -) - func BuildCommand(store *config.Store) *cobra.Command { cmd := &cobra.Command{ Use: "build [files...]", @@ -56,7 +38,7 @@ func BuildCommand(store *config.Store) *cobra.Command { } func runBuild(args []string, output string) error { - plan, err := planBuild(args, output) + plan, err := build.PlanOutputs(args, output) if err != nil { return err @@ -78,7 +60,7 @@ func runBuild(args []string, output string) error { failed := 0 for i, src := range sources { - if err := buildSource(c, src, plan.Targets[i].OutputPath); err != nil { + if err := build.WriteArtifact(c, src, plan.Targets[i].OutputPath); err != nil { printError(err) failed++ } @@ -90,192 +72,3 @@ func runBuild(args []string, output string) error { return nil } - -func planBuild(inputs []string, output string) (buildPlan, error) { - if len(inputs) == 0 { - return buildPlan{}, fmt.Errorf("build requires at least one input file") - } - - if output == "" { - targets := make([]buildTarget, 0, len(inputs)) - - for _, input := range inputs { - targets = append(targets, buildTarget{ - SourcePath: input, - OutputPath: siblingArtifactPath(input), - }) - } - - return buildPlan{Targets: targets}, nil - } - - if len(inputs) == 1 { - return planSingleBuild(inputs[0], output) - } - - return planMultiBuild(inputs, output) -} - -func planSingleBuild(input, output string) (buildPlan, error) { - info, err := os.Stat(output) - - switch { - case err == nil && info.IsDir(): - return buildPlan{ - OutputDir: output, - Targets: []buildTarget{ - { - SourcePath: input, - OutputPath: filepath.Join(output, artifactFileName(input)), - }, - }, - }, nil - case err == nil: - return buildPlan{ - Targets: []buildTarget{ - { - SourcePath: input, - OutputPath: output, - }, - }, - }, nil - case errors.Is(err, os.ErrNotExist): - return buildPlan{ - Targets: []buildTarget{ - { - SourcePath: input, - OutputPath: output, - }, - }, - }, nil - default: - return buildPlan{}, fmt.Errorf("inspect output %s: %w", output, err) - } -} - -func planMultiBuild(inputs []string, output string) (buildPlan, error) { - info, err := os.Stat(output) - - switch { - case err == nil && !info.IsDir(): - return buildPlan{}, fmt.Errorf("--output must be a directory when building multiple files") - case err != nil && !errors.Is(err, os.ErrNotExist): - return buildPlan{}, fmt.Errorf("inspect output %s: %w", output, err) - } - - targets := make([]buildTarget, 0, len(inputs)) - seen := make(map[string]string, len(inputs)) - - for _, input := range inputs { - outputPath := filepath.Join(output, artifactFileName(input)) - key, err := canonicalPath(outputPath) - - if err != nil { - return buildPlan{}, err - } - - if prev, exists := seen[key]; exists { - return buildPlan{}, fmt.Errorf("output collision: %s and %s both map to %s", prev, input, outputPath) - } - - seen[key] = input - targets = append(targets, buildTarget{ - SourcePath: input, - OutputPath: outputPath, - }) - } - - return buildPlan{ - OutputDir: output, - Targets: targets, - }, nil -} - -func buildSource(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) - } - - if err := os.WriteFile(outputPath, data, 0o644); err != nil { - return fmt.Errorf("writing %s: %w", outputPath, err) - } - - return nil -} - -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 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 -} - -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 -} diff --git a/cmd/build_test.go b/cmd/build_test.go index bbf4be5..a685eea 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -1,313 +1,51 @@ package cmd import ( - "io" - "os" - "path/filepath" "strings" "testing" - - "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" ) -func TestRunBuild_DefaultOutputPath(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fql") - - writeQuery(t, input, "RETURN 42") - - stderr, err := captureStderr(t, func() error { - return runBuild([]string{input}, "") - }) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if stderr != "" { - t.Fatalf("expected no stderr output, got %q", stderr) - } - - output := filepath.Join(dir, "query.fqlc") - assertArtifactSource(t, output, "RETURN 42") -} - -func TestRunBuild_SingleFileExplicitOutput(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fql") - output := filepath.Join(dir, "compiled.bin") - - writeQuery(t, input, "RETURN 42") - - if _, err := captureStderr(t, func() error { - return runBuild([]string{input}, output) - }); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - assertArtifactSource(t, output, "RETURN 42") - - if _, err := os.Stat(filepath.Join(dir, "query.fqlc")); !os.IsNotExist(err) { - t.Fatalf("expected sibling artifact to be absent, stat err=%v", err) - } -} - -func TestRunBuild_SingleFileOutputDirectory(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fql") - outputDir := filepath.Join(dir, "dist") - - writeQuery(t, input, "RETURN 42") - - if err := os.MkdirAll(outputDir, 0o755); err != nil { - t.Fatal(err) - } - - if _, err := captureStderr(t, func() error { - return runBuild([]string{input}, outputDir) - }); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - assertArtifactSource(t, filepath.Join(outputDir, "query.fqlc"), "RETURN 42") -} - -func TestRunBuild_MultiFileOutputDirectory(t *testing.T) { - dir := t.TempDir() - inputA := filepath.Join(dir, "first.fql") - inputB := filepath.Join(dir, "second") - outputDir := filepath.Join(dir, "dist") - - writeQuery(t, inputA, "RETURN 1") - writeQuery(t, inputB, "RETURN 2") - - if _, err := captureStderr(t, func() error { - return runBuild([]string{inputA, inputB}, outputDir) - }); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - assertArtifactSource(t, filepath.Join(outputDir, "first.fqlc"), "RETURN 1") - assertArtifactSource(t, filepath.Join(outputDir, "second.fqlc"), "RETURN 2") -} - -func TestRunBuild_MultiFileOutputMustBeDirectory(t *testing.T) { +func TestRunBuild_MixedMultiFileBuildContinues(t *testing.T) { dir := t.TempDir() - inputA := filepath.Join(dir, "first.fql") - inputB := filepath.Join(dir, "second.fql") - output := filepath.Join(dir, "artifact.fqlc") + valid := dir + "/valid.fql" + invalid := dir + "/invalid.fql" + outputDir := dir + "/dist" - writeQuery(t, inputA, "RETURN 1") - writeQuery(t, inputB, "RETURN 2") - writeQuery(t, output, "not a directory") + writeQuery(t, valid, "RETURN 1") + writeQuery(t, invalid, "FOR item IN") _, err := captureStderr(t, func() error { - return runBuild([]string{inputA, inputB}, output) + return runBuild([]string{valid, invalid}, outputDir) }) if err == nil { t.Fatal("expected error") } - if !strings.Contains(err.Error(), "--output must be a directory when building multiple files") { + if !strings.Contains(err.Error(), "1 of 2 scripts failed to build") { t.Fatalf("unexpected error: %v", err) } } -func TestRunBuild_MultiFileOutputCollision(t *testing.T) { +func TestRunBuild_PlanErrorReturned(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") + inputA := dir + "/first.fql" + inputB := dir + "/second.fql" + output := 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}, outputDir) - }) - - if err == nil { - t.Fatal("expected error") - } - - if !strings.Contains(err.Error(), "output collision") { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestRunBuild_RejectsOverwritingSource(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fql") - query := "RETURN 42" - - writeQuery(t, input, query) - - stderr, err := captureStderr(t, func() error { - return runBuild([]string{input}, input) - }) - - if err == nil { - t.Fatal("expected error") - } - - if !strings.Contains(err.Error(), "1 of 1 scripts failed to build") { - t.Fatalf("unexpected error: %v", err) - } - - if !strings.Contains(stderr, "would overwrite source file") { - t.Fatalf("expected overwrite message, got %q", stderr) - } - - 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 TestRunBuild_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 := captureStderr(t, func() error { - return runBuild([]string{input}, "") - }) - - if err == nil { - t.Fatal("expected error") - } - - if !strings.Contains(err.Error(), "1 of 1 scripts failed to build") { - t.Fatalf("unexpected error: %v", err) - } - - if _, statErr := os.Stat(output); !os.IsNotExist(statErr) { - t.Fatalf("expected artifact to be absent, stat err=%v", statErr) - } -} - -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) + return runBuild([]string{inputA, inputB}, output) }) 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) - } - - assertArtifactSource(t, filepath.Join(outputDir, "valid.fqlc"), "RETURN 1") - - if _, statErr := os.Stat(filepath.Join(outputDir, "invalid.fqlc")); !os.IsNotExist(statErr) { - t.Fatalf("expected invalid artifact to be absent, stat err=%v", statErr) - } -} - -func TestRunBuild_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 := captureStderr(t, func() error { - return runBuild([]string{input}, "") - }); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - assertArtifactSource(t, output, "RETURN 1") - - writeQuery(t, input, "RETURN 2") - - if _, err := captureStderr(t, func() error { - return runBuild([]string{input}, "") - }); err != nil { + if !strings.Contains(err.Error(), "--output must be a directory when building multiple files") { t.Fatalf("unexpected error: %v", err) } - - assertArtifactSource(t, output, "RETURN 2") -} - -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 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 } diff --git a/cmd/run_test.go b/cmd/run_test.go index 9992833..49062f7 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -47,7 +47,7 @@ func TestExecuteRun_CompiledArtifact(t *testing.T) { } stdout, err := captureStdout(t, func() error { - return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{siblingArtifactPath(input)}) + return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{filepath.Join(filepath.Dir(input), "query.fqlc")}) }) if err != nil { @@ -104,7 +104,7 @@ func TestExecuteRun_CompiledArtifactWithParams(t *testing.T) { browser.Options{}, map[string]interface{}{"value": float64(99)}, "", - []string{siblingArtifactPath(input)}, + []string{filepath.Join(filepath.Dir(input), "query.fqlc")}, ) }) @@ -176,7 +176,7 @@ func TestExecuteRun_ArtifactRemoteRuntimeRejected(t *testing.T) { browser.Options{}, nil, "", - []string{siblingArtifactPath(input)}, + []string{filepath.Join(filepath.Dir(input), "query.fqlc")}, ) }) @@ -197,6 +197,49 @@ func TestRunCommand_RejectsMultiplePositionalArgs(t *testing.T) { } } +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 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 newTestCommand() *cobra.Command { cmd := &cobra.Command{} cmd.SetContext(context.Background()) diff --git a/pkg/build/artifact.go b/pkg/build/artifact.go new file mode 100644 index 0000000..632dd0a --- /dev/null +++ b/pkg/build/artifact.go @@ -0,0 +1,40 @@ +package build + +import ( + "fmt" + "os" + + "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" + "github.com/MontFerret/ferret/v2/pkg/compiler" + "github.com/MontFerret/ferret/v2/pkg/file" +) + +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) + } + + if err := os.WriteFile(outputPath, data, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", outputPath, err) + } + + return nil +} diff --git a/pkg/build/build_test.go b/pkg/build/build_test.go new file mode 100644 index 0000000..8f7a0d9 --- /dev/null +++ b/pkg/build/build_test.go @@ -0,0 +1,249 @@ +package build + +import ( + "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_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_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_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 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()) + } +} 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..58af6c9 --- /dev/null +++ b/pkg/build/plan.go @@ -0,0 +1,108 @@ +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 := make([]Target, 0, len(inputs)) + + for _, input := range inputs { + targets = append(targets, Target{ + SourcePath: input, + OutputPath: siblingArtifactPath(input), + }) + } + + 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 := make([]Target, 0, len(inputs)) + seen := make(map[string]string, len(inputs)) + + for _, input := range inputs { + outputPath := filepath.Join(output, artifactFileName(input)) + key, err := canonicalPath(outputPath) + + if err != nil { + return Plan{}, err + } + + if prev, exists := seen[key]; exists { + return Plan{}, fmt.Errorf("output collision: %s and %s both map to %s", prev, input, outputPath) + } + + seen[key] = input + targets = append(targets, Target{ + SourcePath: input, + OutputPath: outputPath, + }) + } + + return Plan{ + OutputDir: output, + Targets: 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 + } +) From 061f53940e672fee206f3191c81eee0c6589bcad Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 12:32:57 -0400 Subject: [PATCH 04/15] Refactored `run` command: introduced `pkg/run` package, added `Execute` and `ResolveInput` methods, updated tests and dependencies. --- cmd/run.go | 86 +------------- cmd/run_test.go | 237 +++++---------------------------------- cmd/test_helpers_test.go | 82 ++++++++++++++ pkg/run/execute.go | 25 +++++ pkg/run/resolve.go | 55 +++++++++ pkg/run/run_test.go | 203 +++++++++++++++++++++++++++++++++ pkg/run/types.go | 8 ++ 7 files changed, 402 insertions(+), 294 deletions(-) create mode 100644 cmd/test_helpers_test.go create mode 100644 pkg/run/execute.go create mode 100644 pkg/run/resolve.go create mode 100644 pkg/run/run_test.go create mode 100644 pkg/run/types.go diff --git a/cmd/run.go b/cmd/run.go index 2cd0662..210143a 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -7,21 +7,12 @@ import ( "github.com/spf13/cobra" - "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" - - "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" ) -type runInput struct { - Artifact []byte - Source *file.Source -} - func RunCommand(store *config.Store) *cobra.Command { cmd := &cobra.Command{ Use: "run [script]", @@ -67,7 +58,7 @@ func RunCommand(store *config.Store) *cobra.Command { } func executeRun(cmd *cobra.Command, rtOpts cliruntime.Options, brOpts browser.Options, params map[string]interface{}, eval string, args []string) error { - input, err := resolveRunInput(eval, args) + input, err := clirun.ResolveInput(eval, args) if err != nil { return err @@ -89,30 +80,7 @@ func executeRun(cmd *cobra.Command, rtOpts cliruntime.Options, brOpts browser.Op defer cleanup() - if len(input.Artifact) > 0 { - return runArtifact(cmd, rtOpts, params, input.Artifact) - } - - return runScript(cmd, rtOpts, params, input.Source) -} - -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) - - if err != nil { - printError(err) - return err - } - - defer out.Close() - - _, err = io.Copy(os.Stdout, out) - - return err -} - -func runArtifact(cmd *cobra.Command, opts cliruntime.Options, params map[string]interface{}, artifactData []byte) error { - out, err := cliruntime.RunArtifact(cmd.Context(), opts, artifactData, params) + out, err := clirun.Execute(cmd.Context(), rtOpts, params, input) if err != nil { printError(err) @@ -125,51 +93,3 @@ func runArtifact(cmd *cobra.Command, opts cliruntime.Options, params map[string] return err } - -func resolveRunInput(eval string, args []string) (*runInput, error) { - if eval != "" { - return &runInput{ - Source: file.NewSource("", eval), - }, nil - } - - if len(args) == 1 { - return resolveRunFile(args[0]) - } - - sources, err := source.Resolve(source.Input{}) - - if err != nil { - return nil, err - } - - if sources == nil { - return nil, nil - } - - return &runInput{ - Source: sources[0], - }, nil -} - -func resolveRunFile(path string) (*runInput, error) { - data, err := os.ReadFile(path) - - if err != nil { - return nil, fmt.Errorf("reading %s: %w", path, err) - } - - if isArtifactData(data) { - return &runInput{ - Artifact: data, - }, nil - } - - return &runInput{ - Source: file.NewSource(path, string(data)), - }, nil -} - -func isArtifactData(data []byte) bool { - return artifact.HasMagic(data) -} diff --git a/cmd/run_test.go b/cmd/run_test.go index 49062f7..a8b62cc 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -2,171 +2,29 @@ package cmd import ( "context" - "io" "os" "path/filepath" - "strings" "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_SourceFile(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fql") - - writeQuery(t, input, "RETURN 42") - - stdout, err := captureStdout(t, func() error { - return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{input}) - }) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if strings.TrimSpace(stdout) != "42" { - t.Fatalf("expected 42, got %q", stdout) - } -} - -func TestExecuteRun_CompiledArtifact(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fql") - - writeQuery(t, input, "RETURN 42") - - if _, err := captureStderr(t, func() error { - return runBuild([]string{input}, "") - }); err != nil { - t.Fatalf("unexpected build error: %v", err) - } - - stdout, err := captureStdout(t, func() error { - return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{filepath.Join(filepath.Dir(input), "query.fqlc")}) - }) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if strings.TrimSpace(stdout) != "42" { - t.Fatalf("expected 42, got %q", stdout) - } -} - -func TestExecuteRun_CompiledArtifactCustomName(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fql") - output := filepath.Join(dir, "compiled.bin") - - writeQuery(t, input, "RETURN 42") - - if _, err := captureStderr(t, func() error { - return runBuild([]string{input}, output) - }); err != nil { - t.Fatalf("unexpected build error: %v", err) - } - - stdout, err := captureStdout(t, func() error { - return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{output}) - }) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if strings.TrimSpace(stdout) != "42" { - t.Fatalf("expected 42, got %q", stdout) - } -} - -func TestExecuteRun_CompiledArtifactWithParams(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fql") - - writeQuery(t, input, "RETURN @value") - - if _, err := captureStderr(t, func() error { - return runBuild([]string{input}, "") - }); err != nil { - t.Fatalf("unexpected build error: %v", err) - } - - stdout, err := captureStdout(t, func() error { - return executeRun( - newTestCommand(), - cliruntime.NewDefaultOptions(), - browser.Options{}, - map[string]interface{}{"value": float64(99)}, - "", - []string{filepath.Join(filepath.Dir(input), "query.fqlc")}, - ) - }) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if strings.TrimSpace(stdout) != "99" { - t.Fatalf("expected 99, got %q", stdout) - } -} - -func TestExecuteRun_PlainTextFQLCIsSource(t *testing.T) { - dir := t.TempDir() - input := filepath.Join(dir, "query.fqlc") - - writeQuery(t, input, "RETURN 7") - - stdout, err := captureStdout(t, func() error { - return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{input}) - }) - - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if strings.TrimSpace(stdout) != "7" { - t.Fatalf("expected 7, got %q", stdout) - } -} - -func TestExecuteRun_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) - } - - _, err := captureStderr(t, func() error { - return executeRun(newTestCommand(), cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", []string{input}) - }) - - if err == nil { - t.Fatal("expected error") - } - - if !strings.Contains(err.Error(), "bytecode artifact") { - t.Fatalf("expected artifact load error, got %v", err) - } -} - 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 := captureStderr(t, func() error { - return runBuild([]string{input}, "") - }); err != nil { - t.Fatalf("unexpected build error: %v", err) + 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 { @@ -176,7 +34,7 @@ func TestExecuteRun_ArtifactRemoteRuntimeRejected(t *testing.T) { browser.Options{}, nil, "", - []string{filepath.Join(filepath.Dir(input), "query.fqlc")}, + []string{artifactPath}, ) }) @@ -184,7 +42,7 @@ func TestExecuteRun_ArtifactRemoteRuntimeRejected(t *testing.T) { t.Fatal("expected error") } - if !strings.Contains(err.Error(), "compiled artifacts require the builtin runtime") { + if err.Error() != "compiled artifacts require the builtin runtime" { t.Fatalf("unexpected error: %v", err) } } @@ -197,47 +55,35 @@ func TestRunCommand_RejectsMultiplePositionalArgs(t *testing.T) { } } -func writeQuery(t *testing.T, path, content string) { - t.Helper() +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") - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } + err := cmd.RunE(cmd, []string{"query.fql"}) - if err := os.WriteFile(path, []byte(content), 0o644); err != nil { - t.Fatal(err) + if err == nil { + t.Fatal("expected error") } } -func captureStderr(t *testing.T, fn func() error) (string, error) { - t.Helper() - - original := os.Stderr - reader, writer, err := os.Pipe() +func TestExecuteRun_NoInputShowsHelp(t *testing.T) { + cmd := newTestCommand() + original := os.Stdin + stdin, err := os.Open(os.DevNull) if err != nil { t.Fatal(err) } + defer stdin.Close() - 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) - } + os.Stdin = stdin + defer func() { + os.Stdin = original + }() - if closeErr := reader.Close(); closeErr != nil { - t.Fatal(closeErr) + if err := executeRun(cmd, cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", nil); err != nil { + t.Fatalf("unexpected error: %v", err) } - - return string(data), runErr } func newTestCommand() *cobra.Command { @@ -246,34 +92,3 @@ func newTestCommand() *cobra.Command { return cmd } - -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 -} diff --git a/cmd/test_helpers_test.go b/cmd/test_helpers_test.go new file mode 100644 index 0000000..499024a --- /dev/null +++ b/cmd/test_helpers_test.go @@ -0,0 +1,82 @@ +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 +} 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..892600b --- /dev/null +++ b/pkg/run/resolve.go @@ -0,0 +1,55 @@ +package run + +import ( + "fmt" + "os" + + "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" + "github.com/MontFerret/ferret/v2/pkg/file" + + "github.com/MontFerret/cli/pkg/source" +) + +func ResolveInput(eval string, args []string) (*Input, error) { + if eval != "" { + return &Input{ + Source: file.NewSource("", eval), + }, nil + } + + if len(args) == 1 { + return resolveFile(args[0]) + } + + sources, err := source.Resolve(source.Input{}) + + if err != nil { + return nil, err + } + + if sources == nil { + return nil, nil + } + + return &Input{ + Source: sources[0], + }, nil +} + +func resolveFile(path string) (*Input, error) { + data, err := os.ReadFile(path) + + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + if artifact.HasMagic(data) { + return &Input{ + Artifact: data, + }, nil + } + + return &Input{ + Source: file.NewSource(path, string(data)), + }, nil +} diff --git a/pkg/run/run_test.go b/pkg/run/run_test.go new file mode 100644 index 0000000..4536b77 --- /dev/null +++ b/pkg/run/run_test.go @@ -0,0 +1,203 @@ +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 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 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 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 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 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 +} From 09881ab70bf35d9e0cf01edf38adb86faa2f41ee Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 12:56:51 -0400 Subject: [PATCH 05/15] Introduced `ErrArtifactRequiresBuiltinRuntime` for clearer error handling of compiled artifact runtime checks; updated usage across package and tests. --- cmd/run.go | 2 +- cmd/run_test.go | 3 ++- pkg/runtime/errors.go | 6 ++++++ pkg/runtime/remote.go | 2 +- pkg/runtime/runtime_test.go | 4 ++-- 5 files changed, 12 insertions(+), 5 deletions(-) create mode 100644 pkg/runtime/errors.go diff --git a/cmd/run.go b/cmd/run.go index 210143a..1a72e50 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -69,7 +69,7 @@ func executeRun(cmd *cobra.Command, rtOpts cliruntime.Options, brOpts browser.Op } if len(input.Artifact) > 0 && !cliruntime.IsBuiltinType(rtOpts.Type) { - return fmt.Errorf("compiled artifacts require the builtin runtime") + return cliruntime.ErrArtifactRequiresBuiltinRuntime } cleanup, err := browser.EnsureBrowser(cmd.Context(), rtOpts, brOpts) diff --git a/cmd/run_test.go b/cmd/run_test.go index a8b62cc..9d4d4cf 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "errors" "os" "path/filepath" "testing" @@ -42,7 +43,7 @@ func TestExecuteRun_ArtifactRemoteRuntimeRejected(t *testing.T) { t.Fatal("expected error") } - if err.Error() != "compiled artifacts require the builtin runtime" { + if !errors.Is(err, cliruntime.ErrArtifactRequiresBuiltinRuntime) { t.Fatalf("unexpected error: %v", err) } } 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 a315ed8..f8368f0 100644 --- a/pkg/runtime/remote.go +++ b/pkg/runtime/remote.go @@ -80,7 +80,7 @@ func (rt *Remote) Run(ctx context.Context, query *file.Source, params map[string } func (rt *Remote) RunArtifact(_ context.Context, _ []byte, _ map[string]any) (io.ReadCloser, error) { - return nil, fmt.Errorf("compiled artifacts require the builtin runtime") + return nil, ErrArtifactRequiresBuiltinRuntime } func (rt *Remote) createRequest(ctx context.Context, method, endpoint string, body []byte) (*http.Request, error) { diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index 1503616..ea44145 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -2,7 +2,7 @@ package runtime import ( "context" - "strings" + "errors" "testing" ) @@ -13,7 +13,7 @@ func TestRunArtifact_RemoteRuntimeRejected(t *testing.T) { t.Fatal("expected error") } - if !strings.Contains(err.Error(), "compiled artifacts require the builtin runtime") { + if !errors.Is(err, ErrArtifactRequiresBuiltinRuntime) { t.Fatalf("unexpected error: %v", err) } } From 2d6980dda683af3a270ab34ce9405317deeb42fc Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 13:02:02 -0400 Subject: [PATCH 06/15] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e2f764..903fadf 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ Use "ferret [command] --help" for more information about a command. ### run / exec -Run a FQL script, a compiled artifact file, or an inline expression. When called with no arguments, `ferret` launches the REPL. +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] From c6228eba518864e025017dc6b86eb1c2454aec67 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 13:02:19 -0400 Subject: [PATCH 07/15] Update cmd/build.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/build.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/build.go b/cmd/build.go index 6c1029e..4e67def 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -32,7 +32,7 @@ func BuildCommand(store *config.Store) *cobra.Command { }, } - cmd.Flags().StringP("output", "o", "", "Output file path (single input) or directory (multiple inputs)") + cmd.Flags().StringP("output", "o", "", "Output path: file (for single input) or directory (for single or multiple inputs)") return cmd } From bfbe726cb37e72edcf000518f2ef6e6bc91bdc72 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 13:11:53 -0400 Subject: [PATCH 08/15] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 903fadf..61e9d98 100644 --- a/README.md +++ b/README.md @@ -257,7 +257,7 @@ ferret build [files...] | Flag | Short | Description | |------|-------|-------------| -| `--output` | `-o` | Output file path for a single input, or output directory for multiple inputs | +| `--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. From e049a7af78d16f736d3239ccbc553ce70e71788c Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 13:33:16 -0400 Subject: [PATCH 09/15] Added stdin support for `ResolveInput` and `Execute`, expanded test coverage, and updated related helpers. --- README.md | 2 +- cmd/run_test.go | 60 +++++++++++---- cmd/test_helpers_test.go | 44 +++++++++++ pkg/run/resolve.go | 44 +++++++---- pkg/run/run_test.go | 161 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3e2f764..a82002d 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ 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, so files produced by `ferret build` work even when they do not use a `.fqlc` filename. Artifact execution currently requires the builtin runtime. +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 diff --git a/cmd/run_test.go b/cmd/run_test.go index 9d4d4cf..19026a6 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -48,6 +48,46 @@ func TestExecuteRun_ArtifactRemoteRuntimeRejected(t *testing.T) { } } +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)) @@ -70,21 +110,11 @@ func TestRunCommand_RejectsEvalWithFileArgs(t *testing.T) { func TestExecuteRun_NoInputShowsHelp(t *testing.T) { cmd := newTestCommand() - original := os.Stdin - stdin, err := os.Open(os.DevNull) - if err != nil { - t.Fatal(err) - } - defer stdin.Close() - - os.Stdin = stdin - defer func() { - os.Stdin = original - }() - - if err := executeRun(cmd, cliruntime.NewDefaultOptions(), browser.Options{}, nil, "", nil); err != nil { - t.Fatalf("unexpected error: %v", err) - } + 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 { diff --git a/cmd/test_helpers_test.go b/cmd/test_helpers_test.go index 499024a..9da3c90 100644 --- a/cmd/test_helpers_test.go +++ b/cmd/test_helpers_test.go @@ -80,3 +80,47 @@ func captureStderr(t *testing.T, fn func() error) (string, error) { 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/pkg/run/resolve.go b/pkg/run/resolve.go index 892600b..de8e2af 100644 --- a/pkg/run/resolve.go +++ b/pkg/run/resolve.go @@ -1,13 +1,13 @@ package run import ( + "bufio" "fmt" + "io" "os" "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" "github.com/MontFerret/ferret/v2/pkg/file" - - "github.com/MontFerret/cli/pkg/source" ) func ResolveInput(eval string, args []string) (*Input, error) { @@ -21,35 +21,47 @@ func ResolveInput(eval string, args []string) (*Input, error) { return resolveFile(args[0]) } - sources, err := source.Resolve(source.Input{}) + return resolveStdin() +} + +func resolveFile(path string) (*Input, error) { + data, err := os.ReadFile(path) if err != nil { - return nil, err + return nil, fmt.Errorf("reading %s: %w", path, err) } - if sources == nil { - return nil, nil + 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) } - return &Input{ - Source: sources[0], - }, nil -} + if (stat.Mode() & os.ModeCharDevice) != 0 { + return nil, nil + } -func resolveFile(path string) (*Input, error) { - data, err := os.ReadFile(path) + data, err := io.ReadAll(bufio.NewReader(os.Stdin)) if err != nil { - return nil, fmt.Errorf("reading %s: %w", path, err) + 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, - }, nil + } } return &Input{ - Source: file.NewSource(path, string(data)), - }, nil + Source: file.NewSource(name, string(data)), + } } diff --git a/pkg/run/run_test.go b/pkg/run/run_test.go index 4536b77..f6b5382 100644 --- a/pkg/run/run_test.go +++ b/pkg/run/run_test.go @@ -36,6 +36,50 @@ func TestResolveInput_SourceFile(t *testing.T) { } } +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") @@ -104,6 +148,32 @@ func TestExecute_CompiledArtifactWithParams(t *testing.T) { 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") @@ -150,6 +220,40 @@ func TestExecute_CorruptArtifactReturnsLoadError(t *testing.T) { } } +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() @@ -162,6 +266,52 @@ func writeQuery(t *testing.T, path, content string) { } } +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() @@ -183,6 +333,17 @@ func readSource(t *testing.T, path string) *file.Source { 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() From 877718ec38659a32f575f1e2a73a2842e734c42b Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 13:42:21 -0400 Subject: [PATCH 10/15] Refactored `openProcess` function to simplify logic, updated related browser implementations, and adjusted linting rules. --- cmd/repl.go | 2 +- pkg/browser/browser_darwin.go | 2 +- pkg/browser/browser_linux.go | 2 +- pkg/browser/process.go | 10 ++-------- revive.toml | 1 - 5 files changed, 5 insertions(+), 12 deletions(-) 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/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/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] From 8fea4169fce58ecdd6e7203868980c9bacfb153a Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 14:00:46 -0400 Subject: [PATCH 11/15] Enhanced `WriteArtifact` to ensure missing parent directories are created, refactored related test cases for improved coverage. --- cmd/build_test.go | 13 +++++----- pkg/build/artifact.go | 7 ++++++ pkg/build/build_test.go | 54 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/cmd/build_test.go b/cmd/build_test.go index a685eea..d8bbb6c 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -1,15 +1,16 @@ package cmd import ( + "path/filepath" "strings" "testing" ) func TestRunBuild_MixedMultiFileBuildContinues(t *testing.T) { dir := t.TempDir() - valid := dir + "/valid.fql" - invalid := dir + "/invalid.fql" - outputDir := dir + "/dist" + 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") @@ -29,9 +30,9 @@ func TestRunBuild_MixedMultiFileBuildContinues(t *testing.T) { func TestRunBuild_PlanErrorReturned(t *testing.T) { dir := t.TempDir() - inputA := dir + "/first.fql" - inputB := dir + "/second.fql" - output := dir + "/artifact.fqlc" + 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") diff --git a/pkg/build/artifact.go b/pkg/build/artifact.go index 632dd0a..05dfea7 100644 --- a/pkg/build/artifact.go +++ b/pkg/build/artifact.go @@ -3,6 +3,7 @@ package build import ( "fmt" "os" + "path/filepath" "github.com/MontFerret/ferret/v2/pkg/bytecode/artifact" "github.com/MontFerret/ferret/v2/pkg/compiler" @@ -32,6 +33,12 @@ func WriteArtifact(c *compiler.Compiler, src *file.Source, outputPath string) er 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) + } + if err := os.WriteFile(outputPath, data, 0o644); err != nil { return fmt.Errorf("writing %s: %w", outputPath, err) } diff --git a/pkg/build/build_test.go b/pkg/build/build_test.go index 8f7a0d9..ed53735 100644 --- a/pkg/build/build_test.go +++ b/pkg/build/build_test.go @@ -178,6 +178,20 @@ func TestWriteArtifact_InvalidQueryDoesNotCreateArtifact(t *testing.T) { } } +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") @@ -200,6 +214,28 @@ func TestWriteArtifact_ReplacesExistingDestinationFile(t *testing.T) { 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_ArtifactRoundTrip(t *testing.T) { dir := t.TempDir() input := filepath.Join(dir, "query.fql") @@ -214,6 +250,24 @@ func TestWriteArtifact_ArtifactRoundTrip(t *testing.T) { 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() From 173c148726daa47b887092f866896d64bbdc1da8 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 14:13:24 -0400 Subject: [PATCH 12/15] Improved `WriteArtifact` to use atomic file writes with temporary files, added error handling for rename failures, and enhanced test coverage to validate behavior. --- pkg/build/artifact.go | 45 +++++++++++++++++++++-- pkg/build/build_test.go | 80 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/pkg/build/artifact.go b/pkg/build/artifact.go index 05dfea7..54c9a85 100644 --- a/pkg/build/artifact.go +++ b/pkg/build/artifact.go @@ -10,6 +10,8 @@ import ( "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) @@ -39,9 +41,48 @@ func WriteArtifact(c *compiler.Compiler, src *file.Source, outputPath string) er return fmt.Errorf("create output directory %s: %w", outputDir, err) } - if err := os.WriteFile(outputPath, data, 0o644); err != nil { - return fmt.Errorf("writing %s: %w", outputPath, 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 index ed53735..905b143 100644 --- a/pkg/build/build_test.go +++ b/pkg/build/build_test.go @@ -1,6 +1,7 @@ package build import ( + "errors" "os" "path/filepath" "strings" @@ -236,6 +237,63 @@ func TestWriteArtifact_ReplacesExistingDestinationFileInNestedDirectory(t *testi 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") @@ -301,3 +359,25 @@ func assertArtifactSource(t *testing.T, path, expected string) { 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 + } +} From 93fc34da4271837666f6b00a1805aa97753bf056 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 15:46:50 -0400 Subject: [PATCH 13/15] Refactored `PlanOutputs` to simplify target planning logic, introduced `planTargets` helper for reusability, and added new tests for default output paths and collision handling. --- pkg/build/build_test.go | 56 +++++++++++++++++++++++++++++++++++++++++ pkg/build/plan.go | 39 +++++++++++++++++----------- 2 files changed, 80 insertions(+), 15 deletions(-) diff --git a/pkg/build/build_test.go b/pkg/build/build_test.go index 905b143..3cb5cf9 100644 --- a/pkg/build/build_test.go +++ b/pkg/build/build_test.go @@ -31,6 +31,62 @@ func TestPlanOutputs_DefaultOutputPath(t *testing.T) { } } +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") diff --git a/pkg/build/plan.go b/pkg/build/plan.go index 58af6c9..d8ed937 100644 --- a/pkg/build/plan.go +++ b/pkg/build/plan.go @@ -13,13 +13,10 @@ func PlanOutputs(inputs []string, output string) (Plan, error) { } if output == "" { - targets := make([]Target, 0, len(inputs)) + targets, err := planTargets(inputs, siblingArtifactPath) - for _, input := range inputs { - targets = append(targets, Target{ - SourcePath: input, - OutputPath: siblingArtifactPath(input), - }) + if err != nil { + return Plan{}, err } return Plan{Targets: targets}, nil @@ -79,30 +76,42 @@ func planMultiOutput(inputs []string, output string) (Plan, error) { 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 { - outputPath := filepath.Join(output, artifactFileName(input)) - key, err := canonicalPath(outputPath) + path := outputPath(input) + key, err := canonicalPath(path) if err != nil { - return Plan{}, err + return nil, err } if prev, exists := seen[key]; exists { - return Plan{}, fmt.Errorf("output collision: %s and %s both map to %s", prev, input, outputPath) + 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: outputPath, + OutputPath: path, }) } - return Plan{ - OutputDir: output, - Targets: targets, - }, nil + return targets, nil } From 79eb9996c1ee0eca47f53ecaa1982ae69ef35eb4 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 16:44:29 -0400 Subject: [PATCH 14/15] Added error handling for multiple file arguments in `ResolveInput`, updated logic and added test case to ensure limitation enforcement. --- pkg/run/resolve.go | 4 ++++ pkg/run/run_test.go | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/pkg/run/resolve.go b/pkg/run/resolve.go index de8e2af..1d3295f 100644 --- a/pkg/run/resolve.go +++ b/pkg/run/resolve.go @@ -17,6 +17,10 @@ func ResolveInput(eval string, args []string) (*Input, error) { }, nil } + if len(args) > 1 { + return nil, fmt.Errorf("run accepts at most one file argument") + } + if len(args) == 1 { return resolveFile(args[0]) } diff --git a/pkg/run/run_test.go b/pkg/run/run_test.go index f6b5382..d0e2916 100644 --- a/pkg/run/run_test.go +++ b/pkg/run/run_test.go @@ -36,6 +36,22 @@ func TestResolveInput_SourceFile(t *testing.T) { } } +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) From 299b6ec724c196d886f65ee5a891ea4dde765b01 Mon Sep 17 00:00:00 2001 From: Tim Voronov Date: Fri, 27 Mar 2026 17:27:22 -0400 Subject: [PATCH 15/15] Updated `go.mod` and `go.sum` to bump `ferret/v2` dependency from `v2.0.0-alpha.2` to `v2.0.0-alpha.3`. --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0558634..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.2 + 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 64bd7a9..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.2 h1:a+06KsJPU9a/NF84x90xDG4a7GXNxEZWhGt+rNtOrZU= -github.com/MontFerret/ferret/v2 v2.0.0-alpha.2/go.mod h1:G49K5xQ5UfgRf0SmkHYqODrXqBeMwcCxvrj8nANA8eU= +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=