Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions inputs.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Specs",
"type": "object",
"additionalProperties": false,
"properties": {
"$schema": { "type": "string" },
"balances": {
"$ref": "#/definitions/Balances"
},
"variables": {
"$ref": "#/definitions/VariablesMap"
},
"metadata": {
"$ref": "#/definitions/AccountsMetadata"
},
"featureFlags": {
"type": "array",
"items": { "type": "string" }
}
},
"definitions": {
"Balances": {
"type": "object",
"description": "Map of account names to asset balances",
"additionalProperties": false,
"patternProperties": {
"^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": {
"type": "object",
"additionalProperties": false,
"patternProperties": {
"^([A-Z]+(/[0-9]+)?)$": {
"type": "number"
}
}
}
}
},

"VariablesMap": {
"type": "object",
"description": "Map of variable name to variable stringified value",
"additionalProperties": false,
"patternProperties": {
"^[a-z_]+$": { "type": "string" }
}
},

"AccountsMetadata": {
"type": "object",
"description": "Map of an account metadata to the account's metadata",
"additionalProperties": false,
"patternProperties": {
"^([a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*)$": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
},

"TxMetadata": {
"type": "object",
"description": "Map from a metadata's key to the transaction's metadata stringied value",
"additionalProperties": { "type": "string" }
}
}
}
9 changes: 8 additions & 1 deletion internal/cmd/lsp.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ var lspCmd = &cobra.Command{
Long: "Run the lsp server. This command is usually meant to be used for editors integration.",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
return lsp.RunServer()
err := lsp.RunServer()
if err != nil {
cmd.SilenceErrors = true
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussion on the merit of Run vs RunE and Cobra error handling: https://numary.slack.com/archives/C096FH98AKC/p1753358540338769

cmd.SilenceUsage = true
return err
}

return nil
},
}
176 changes: 52 additions & 124 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"

"github.com/formancehq/numscript/internal/flags"
"github.com/formancehq/numscript/internal/interpreter"
"github.com/formancehq/numscript/internal/parser"

Expand All @@ -20,132 +17,62 @@ const (
OutputFormatJson = "json"
)

type runArgs struct {
VariablesOpt string
BalancesOpt string
MetaOpt string
RawOpt string
StdinFlag bool
OutFormatOpt string
Flags []string
type InputsFile struct {
FeatureFlags []string `json:"featureFlags"`
Variables map[string]string `json:"variables"`
Meta interpreter.AccountsMetadata `json:"metadata"`
Balances interpreter.Balances `json:"balances"`
}

type inputOpts struct {
Script string `json:"script"`
Variables map[string]string `json:"variables"`
Meta interpreter.AccountsMetadata `json:"metadata"`
Balances interpreter.Balances `json:"balances"`
type RunArgs struct {
InputsPath string
OutFormatOpt string
}

func (o *inputOpts) fromRaw(opts runArgs) error {
if opts.RawOpt == "" {
return nil
}

err := json.Unmarshal([]byte(opts.RawOpt), o)
func run(scriptPath string, opts RunArgs) error {
numscriptContent, err := os.ReadFile(scriptPath)
if err != nil {
return fmt.Errorf("invalid raw input JSON: %w", err)
return err
}
return nil
}

func (o *inputOpts) fromStdin(opts runArgs) error {
if !opts.StdinFlag {
return nil
parseResult := parser.Parse(string(numscriptContent))
if len(parseResult.Errors) != 0 {
fmt.Fprint(os.Stderr, parser.ParseErrorsToString(parseResult.Errors, string(numscriptContent)))
return fmt.Errorf("parsing failed")
}

bytes, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("error reading from stdin: %w", err)
inputsPath := opts.InputsPath
if inputsPath == "" {
inputsPath = scriptPath + ".inputs.json"
}

err = json.Unmarshal(bytes, o)
inputsContent, err := os.ReadFile(inputsPath)
if err != nil {
return fmt.Errorf("invalid stdin JSON: %w", err)
}
return nil
}

func (o *inputOpts) fromOptions(path string, opts runArgs) error {
if path != "" {
numscriptContent, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("error reading script file: %w", err)
}
o.Script = string(numscriptContent)
}

if opts.BalancesOpt != "" {
content, err := os.ReadFile(opts.BalancesOpt)
if err != nil {
return fmt.Errorf("error reading balances file: %w", err)
}
if err := json.Unmarshal(content, &o.Balances); err != nil {
return fmt.Errorf("invalid balances JSON: %w", err)
}
}

if opts.MetaOpt != "" {
content, err := os.ReadFile(opts.MetaOpt)
if err != nil {
return fmt.Errorf("error reading metadata file: %w", err)
}
if err := json.Unmarshal(content, &o.Meta); err != nil {
return fmt.Errorf("invalid metadata JSON: %w", err)
}
}

if opts.VariablesOpt != "" {
content, err := os.ReadFile(opts.VariablesOpt)
if err != nil {
return fmt.Errorf("error reading variables file: %w", err)
}
if err := json.Unmarshal(content, &o.Variables); err != nil {
return fmt.Errorf("invalid variables JSON: %w", err)
}
}
return nil
}

func run(path string, opts runArgs) error {
opt := inputOpts{
Variables: make(map[string]string),
Meta: make(interpreter.AccountsMetadata),
Balances: make(interpreter.Balances),
}

if err := opt.fromRaw(opts); err != nil {
return err
}
if err := opt.fromOptions(path, opts); err != nil {
return err
}
if err := opt.fromStdin(opts); err != nil {
return err
}

parseResult := parser.Parse(opt.Script)
if len(parseResult.Errors) != 0 {
fmt.Fprint(os.Stderr, parser.ParseErrorsToString(parseResult.Errors, opt.Script))
return fmt.Errorf("parsing failed")
var inputs InputsFile
err = json.Unmarshal(inputsContent, &inputs)
if err != nil {
return fmt.Errorf("failed to parse inputs file '%s' as JSON: %w", inputsPath, err)
}

featureFlags := map[string]struct{}{}
for _, flag := range opts.Flags {
for _, flag := range inputs.FeatureFlags {
featureFlags[flag] = struct{}{}
}

result, err := interpreter.RunProgram(context.Background(), parseResult.Value, opt.Variables, interpreter.StaticStore{
Balances: opt.Balances,
Meta: opt.Meta,
result, iErr := interpreter.RunProgram(context.Background(), parseResult.Value, inputs.Variables, interpreter.StaticStore{
Balances: inputs.Balances,
Meta: inputs.Meta,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: JSON Parsing Initializes Maps Incorrectly

The InputsFile struct's Variables, Meta, and Balances map fields are no longer explicitly initialized. Unlike the previous implementation, these fields are now nil if missing from the JSON input file. This can lead to nil pointer dereferences or unexpected behavior when passed to interpreter.RunProgram(), which likely expects non-nil maps.

Locations (1)

Fix in CursorFix in Web

}, featureFlags)

if err != nil {
rng := err.GetRange()
fmt.Fprint(os.Stderr, err.Error())
if iErr != nil {
rng := iErr.GetRange()
fmt.Fprint(os.Stderr, iErr.Error())
if rng.Start != rng.End {
fmt.Fprint(os.Stderr, "\n")
fmt.Fprint(os.Stderr, err.GetRange().ShowOnSource(parseResult.Source))
fmt.Fprint(os.Stderr, iErr.GetRange().ShowOnSource(parseResult.Source))
}
return fmt.Errorf("execution failed")
}
Expand Down Expand Up @@ -183,35 +110,36 @@ func showPretty(result *interpreter.ExecutionResult) error {
}

func getRunCmd() *cobra.Command {
opts := runArgs{}
opts := RunArgs{}

cmd := cobra.Command{
Use: "run",
Short: "Evaluate a numscript file",
Long: "Evaluate a numscript file, using the balances, the current metadata and the variables values as input.",
Long: `Evaluate a numscript file, taking as inputs a json file containing balances, variables and metadata.

The inputs file has to have the same name as the numscript file plus a ".inputs.json" suffix, for example:
run folder/my-script.num
will expect a 'folder/my-script.num.inputs.json' file where to read inputs from.

You can use explicitly specify where the inputs file should be using the optional --inputs argument.
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var path string
if len(args) > 0 {
path = args[0]
path := args[0]

err := run(path, opts)
if err != nil {
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return err
}
return run(path, opts)

return nil
},
}

// Input args
cmd.Flags().StringVarP(&opts.VariablesOpt, "variables", "v", "", "Path of a json file containing the variables")
cmd.Flags().StringVarP(&opts.BalancesOpt, "balances", "b", "", "Path of a json file containing the balances")
cmd.Flags().StringVarP(&opts.MetaOpt, "meta", "m", "", "Path of a json file containing the accounts metadata")
cmd.Flags().StringVarP(&opts.RawOpt, "raw", "r", "", "Raw json input containing script, variables, balances, metadata")
cmd.Flags().BoolVar(&opts.StdinFlag, "stdin", false, "Take input from stdin (same format as the --raw option)")

// Feature flag
cmd.Flags().StringSliceVar(&opts.Flags, "flags", nil, fmt.Sprintf("the feature flags to pass to the interpreter. Currently available flags: %s",
strings.Join(flags.AllFlags, ", "),
))

// Output options
cmd.Flags().StringVar(&opts.OutFormatOpt, "output-format", OutputFormatPretty, "Set the output format. Available options: pretty, json.")
cmd.Flags().StringVar(&opts.InputsPath, "inputs", "", "Path of a json file containing the inputs")
cmd.Flags().StringVarP(&opts.OutFormatOpt, "output-format", "o", OutputFormatPretty, "Set the output format. Available options: pretty, json.")

return &cmd
}
Loading