Skip to content

Commit

Permalink
Merge pull request #106 from ckganesan/back-tick-operation-issues
Browse files Browse the repository at this point in the history
Resolved issues when using backtick literals
  • Loading branch information
skx committed Dec 7, 2023
2 parents 7b31b27 + 8a20fff commit eb82b58
Show file tree
Hide file tree
Showing 4 changed files with 125 additions and 85 deletions.
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -529,19 +529,17 @@ The update-operators work with integers and doubles by default, when it comes to
## 2.11 Command Execution

As with many scripting languages commands may be executed via the backtick
operator (`\``).
operator (``).

let uptime = `/usr/bin/uptime`;

if ( uptime ) {
if ( uptime["exitCode"] == 0 ) {
puts( "STDOUT: ", uptime["stdout"].trim() , "\n");
puts( "STDERR: ", uptime["stderr"].trim() , "\n");
} else {
puts( "Failed to run command\n");
puts( "An error occurred while running the command: ", uptime["stderr"].trim(), "\n");
}

The output will be a hash with two keys `stdout` and `stderr`. NULL is
returned if the execution fails. This can be seen in [examples/exec.mon](examples/exec.mon).
The output will be a hash containing the keys `stdout`, `stderr`, and `exitCode`, as demonstrated in [examples/exec.mon](examples/exec.mon).



Expand Down
173 changes: 100 additions & 73 deletions evaluator/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ package evaluator
import (
"bytes"
"context"
"errors"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"unicode"

"github.com/skx/monkey/ast"
"github.com/skx/monkey/object"
Expand Down Expand Up @@ -48,13 +51,13 @@ func EvalContext(ctx context.Context, node ast.Node, env *object.Environment) ob

switch node := node.(type) {

//Statements
// Statements
case *ast.Program:
return evalProgram(ctx, node, env)
case *ast.ExpressionStatement:
return EvalContext(ctx, node.Expression, env)

//Expressions
// Expressions
case *ast.IntegerLiteral:
return &object.Integer{Value: node.Value}
case *ast.FloatLiteral:
Expand Down Expand Up @@ -1006,9 +1009,8 @@ func evalExpression(ctx context.Context, exps []ast.Expression, env *object.Envi
return result
}

// Split a line of text into tokens, but keep anything "quoted"
// together..
//
// parseCommandLine takes a command string and splits it into individual arguments,
// respecting quotes and escaping within the command.
// So this input:
//
// /bin/sh -c "ls /etc"
Expand All @@ -1018,96 +1020,121 @@ func evalExpression(ctx context.Context, exps []ast.Expression, env *object.Envi
// /bin/sh
// -c
// ls /etc
func splitCommand(input string) []string {

//
// This does the split into an array
//
r := regexp.MustCompile(`[^\s"']+|"([^"]*)"|'([^']*)`)
res := r.FindAllString(input, -1)

//
// However the resulting pieces might be quoted.
// So we have to remove them, if present.
//
var result []string
for _, e := range res {
result = append(result, trimQuotes(e, '"'))
func parseCommandLine(command string) ([]string, error) {
var args []string
var current strings.Builder
inQuotes := false
var quoteChar rune

// flush appends the current argument to the args slice and resets the current string builder.
flush := func() {
if current.Len() > 0 {
args = append(args, current.String())
current.Reset()
}
}
return (result)
}

// Remove balanced characters around a string.
func trimQuotes(in string, c byte) string {
if len(in) >= 2 {
if in[0] == c && in[len(in)-1] == c {
return in[1 : len(in)-1]
// Iterate through each character in the command string.
for _, c := range command {
switch {
case unicode.IsSpace(c) && !inQuotes:
// If a space is encountered outside of quotes, flush the current argument.
flush()
case (c == '\'' || c == '"') && !inQuotes:
// If a single or double quote is encountered and we're not inside quotes,
// mark the start of quoted text and record the quote character.
inQuotes = true
quoteChar = c
case c == quoteChar && inQuotes:
// If the matching closing quote is found while inside quotes, mark the end of quoted text
// and flush the current argument.
inQuotes = false
flush()
default:
// Otherwise, append the character to the current argument.
current.WriteRune(c)
}
}
return in
}

// Run a command and return a hash containing the result.
// `stderr`, `stdout`, and `error` will be the fields
func backTickOperation(command string) object.Object {

command = strings.TrimSpace(command)
if command == "" {
return newError("empty command")
// If still inside quotes at the end of parsing, return an error for unclosed quotes.
if inQuotes {
return nil, fmt.Errorf("unclosed quote in command line: %s", command)
}

// default arguments, if none are found
args := []string{}
// Flush any remaining argument and return the parsed arguments.
flush()
return args, nil
}

// split the command
toExec := splitCommand(command)
// backTickOperation executes a shell command and returns a hash object containing the result.
// The hash includes 'stdout', 'stderr', and 'code' fields.
// If the command is empty or parsing fails, an error hash is returned.
func backTickOperation(command string) object.Object {
var (
args []string
err error
)

// Trim leading and trailing whitespace from the command.
if command = strings.TrimSpace(command); command != "" {
// Split the command into arguments.
if args, err = parseCommandLine(command); err != nil {
// Return an error hash for parsing failure.
return createCommandExecHash(&object.String{Value: ""}, &object.String{Value: "parse error: " + err.Error()},
&object.Integer{Value: -1})
}
}

// Did that work?
// Check if the command is empty after parsing.
if len(args) == 0 {
return newError("error - empty command")
// Return an error hash for an empty command.
return createCommandExecHash(&object.String{Value: ""}, &object.String{Value: "no command"},
&object.Integer{Value: -1})
}

// Use the real args if we got any
if len(args) > 1 {
args = toExec[1:]
}
// Run the command.
cmd := exec.Command(filepath.Clean(args[0]), args[1:]...)

// Run the ocmmand.
cmd := exec.Command(toExec[0], args...)
// Capture the command's stdout and stderr.
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

// get the result
var outb, errb bytes.Buffer
cmd.Stdout = &outb
cmd.Stderr = &errb
err := cmd.Run()
var exitCode int64 = 0

// If the command exits with a non-zero exit-code it
// is regarded as a failure. Here we test for ExitError
// to regard that as a non-failure.
if err != nil && err != err.(*exec.ExitError) {
fmt.Printf("Failed to run '%s' -> %s\n", command, err.Error())
return NULL
// Execute the command and handle errors.
err = cmd.Run()
if err != nil {
var exitError *exec.ExitError
if !errors.As(err, &exitError) {
// Handle non-ExitError errors (e.g., command not found).
return createCommandExecHash(&object.String{Value: ""}, &object.String{Value: fmt.Sprintf("Failed to run '%s' -> %s\n", command, err.Error())},
&object.Integer{Value: -1})
}
exitCode = int64(exitError.ExitCode())
}

//
// The result-objects to store in our hash.
//
stdout := &object.String{Value: outb.String()}
stderr := &object.String{Value: errb.String()}
// Create a hash with 'stdout', 'stderr', and 'code' fields.
return createCommandExecHash(&object.String{Value: stdout.String()}, &object.String{Value: stderr.String()},
&object.Integer{Value: exitCode})
}

// Create keys
// createCommandExecHash Create a hash with 'stdout', 'stderr', and 'code' fields.
func createCommandExecHash(stdoutObj, stderrObj, errorObj object.Object) object.Object {
// Create keys for the hash.
stdoutKey := &object.String{Value: "stdout"}
stdoutHash := object.HashPair{Key: stdoutKey, Value: stdout}

stderrKey := &object.String{Value: "stderr"}
stderrHash := object.HashPair{Key: stderrKey, Value: stderr}
exitCodeKey := &object.String{Value: "exitCode"}

// Make a new hash, and populate it
newHash := make(map[object.HashKey]object.HashPair)
newHash[stdoutKey.HashKey()] = stdoutHash
newHash[stderrKey.HashKey()] = stderrHash
// Populate the hash with key-value pairs.
hashPairs := map[object.HashKey]object.HashPair{
stdoutKey.HashKey(): {Key: stdoutKey, Value: stdoutObj},
stderrKey.HashKey(): {Key: stderrKey, Value: stderrObj},
exitCodeKey.HashKey(): {Key: exitCodeKey, Value: errorObj},
}

return &object.Hash{Pairs: newHash}
// Create and return the hash object.
return &object.Hash{Pairs: hashPairs}
}

func evalIndexExpression(left, index object.Object) object.Object {
Expand Down
17 changes: 17 additions & 0 deletions evaluator/evaluator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -771,3 +771,20 @@ func TestRangeOperator(t *testing.T) {
}
}
}

func TestBackTickOperation(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"``", "no command"},
{"`/bin/sh -c \"ls /etc`", `parse error: unclosed quote in command line: /bin/sh -c "ls /etc`},
{"`/usr/bin/noexe`", "Failed to run"},
}
for _, tt := range tests {
evaluated := testEval(tt.input)
if !strings.Contains(evaluated.Inspect(), tt.expected) {
t.Fatalf("unexpected output for back tick operation, got %s for input %s", evaluated.Inspect(), tt.input)
}
}
}
10 changes: 4 additions & 6 deletions examples/exec.mon
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,18 @@
//
let uptime = `/usr/bin/uptime`;

if ( uptime ) {
if ( uptime["exitCode"] == 0 ) {
puts( "STDOUT: ", uptime["stdout"].trim() , "\n");
puts( "STDERR: ", uptime["stderr"].trim() , "\n");
} else {
puts( "Failed to run command\n");
puts( "An error occurred while running the command: ", uptime["stderr"].trim() , "\n");
}

//
// Now something more complex
//
let ls = `/bin/sh -c "/bin/ls /etc /missing-path"`;
if ( ls ) {
if ( ls["exitCode"] == 0 ) {
puts( "STDOUT: ", ls["stdout"].trim() , "\n");
puts( "STDERR: ", ls["stderr"].trim() , "\n");
} else {
puts( "Failed to run command\n");
puts( "An error occurred while running the command: ", ls["stderr"].trim() , "\n");
}

0 comments on commit eb82b58

Please sign in to comment.