github.com/ActiveState/cli@v0.0.0-20240508170324-6801f60cd051/pkg/platform/runtime/buildscript/buildscript.go (about)

     1  package buildscript
     2  
     3  import (
     4  	"bytes"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"strconv"
     9  	"strings"
    10  
    11  	"golang.org/x/text/cases"
    12  	"golang.org/x/text/language"
    13  
    14  	"github.com/go-openapi/strfmt"
    15  	"github.com/thoas/go-funk"
    16  
    17  	"github.com/ActiveState/cli/internal/constants"
    18  	"github.com/ActiveState/cli/internal/errs"
    19  	"github.com/ActiveState/cli/internal/locale"
    20  	"github.com/ActiveState/cli/internal/multilog"
    21  	"github.com/ActiveState/cli/internal/rtutils/ptr"
    22  	"github.com/ActiveState/cli/pkg/platform/runtime/buildexpression"
    23  	"github.com/alecthomas/participle/v2"
    24  )
    25  
    26  // Script's tagged fields will be initially filled in by Participle.
    27  // expr will be constructed later and is this script's buildexpression. We keep a copy of the build
    28  // expression here with any changes that have been applied before either writing it to disk or
    29  // submitting it to the build planner. It's easier to operate on build expressions directly than to
    30  // modify or manually populate the Participle-produced fields and re-generate a build expression.
    31  type Script struct {
    32  	Assignments []*Assignment `parser:"@@+"`
    33  	AtTime      *strfmt.DateTime
    34  	Expr        *buildexpression.BuildExpression
    35  }
    36  
    37  type Assignment struct {
    38  	Key   string `parser:"@Ident '='"`
    39  	Value *Value `parser:"@@"`
    40  }
    41  
    42  type Value struct {
    43  	FuncCall *FuncCall `parser:"@@"`
    44  	List     *[]*Value `parser:"| '[' (@@ (',' @@)* ','?)? ']'"`
    45  	Str      *string   `parser:"| @String"`
    46  	Number   *float64  `parser:"| (@Float | @Int)"`
    47  	Null     *Null     `parser:"| @@"`
    48  
    49  	Assignment *Assignment    `parser:"| @@"`                        // only in FuncCall
    50  	Object     *[]*Assignment `parser:"| '{' @@ (',' @@)* ','? '}'"` // only in List
    51  	Ident      *string        `parser:"| @Ident"`                    // only in FuncCall or Assignment
    52  }
    53  
    54  type Null struct {
    55  	Null string `parser:"'null'"`
    56  }
    57  
    58  type FuncCall struct {
    59  	Name      string   `parser:"@Ident"`
    60  	Arguments []*Value `parser:"'(' @@ (',' @@)* ','? ')'"`
    61  }
    62  
    63  type In struct {
    64  	FuncCall *FuncCall `parser:"@@"`
    65  	Name     *string   `parser:"| @Ident"`
    66  }
    67  
    68  var (
    69  	reqFuncName = "Req"
    70  	eqFuncName  = "Eq"
    71  	neFuncName  = "Ne"
    72  	gtFuncName  = "Gt"
    73  	gteFuncName = "Gte"
    74  	ltFuncName  = "Lt"
    75  	lteFuncName = "Lte"
    76  	andFuncName = "And"
    77  )
    78  
    79  func New(data []byte) (*Script, error) {
    80  	parser, err := participle.Build[Script]()
    81  	if err != nil {
    82  		return nil, errs.Wrap(err, "Could not create parser for build script")
    83  	}
    84  
    85  	script, err := parser.ParseBytes(constants.BuildScriptFileName, data)
    86  	if err != nil {
    87  		var parseError participle.Error
    88  		if errors.As(err, &parseError) {
    89  			return nil, locale.WrapExternalError(err, "err_parse_buildscript_bytes", "Could not parse build script: {{.V0}}: {{.V1}}", parseError.Position().String(), parseError.Message())
    90  		}
    91  		return nil, locale.WrapError(err, "err_parse_buildscript_bytes", "Could not parse build script: {{.V0}}", err.Error())
    92  	}
    93  
    94  	// Construct the equivalent buildexpression.
    95  	bytes, err := json.Marshal(script)
    96  	if err != nil {
    97  		return nil, errs.Wrap(err, "Could not marshal build script to build expression")
    98  	}
    99  
   100  	expr, err := buildexpression.New(bytes)
   101  	if err != nil {
   102  		return nil, locale.WrapError(err, "err_parse_buildscript_bytes", "Could not construct build expression: {{.V0}}", errs.JoinMessage(err))
   103  	}
   104  	script.Expr = expr
   105  
   106  	return script, nil
   107  }
   108  
   109  func NewFromBuildExpression(atTime *strfmt.DateTime, expr *buildexpression.BuildExpression) (*Script, error) {
   110  	// Copy incoming build expression to keep any modifications local.
   111  	var err error
   112  	expr, err = expr.Copy()
   113  	if err != nil {
   114  		return nil, errs.Wrap(err, "Could not copy build expression")
   115  	}
   116  
   117  	// Update old expressions that bake in at_time as a timestamp instead of as a variable.
   118  	err = expr.MaybeSetDefaultTimestamp(atTime)
   119  	if err != nil {
   120  		return nil, errs.Wrap(err, "Could not set default timestamp")
   121  	}
   122  
   123  	return &Script{AtTime: atTime, Expr: expr}, nil
   124  }
   125  
   126  func indent(s string) string {
   127  	return fmt.Sprintf("\t%s", strings.ReplaceAll(s, "\n", "\n\t"))
   128  }
   129  
   130  func (s *Script) String() string {
   131  	buf := strings.Builder{}
   132  
   133  	if s.AtTime != nil {
   134  		buf.WriteString(assignmentString(&buildexpression.Var{
   135  			Name:  buildexpression.AtTimeKey,
   136  			Value: &buildexpression.Value{Str: ptr.To(s.AtTime.String())},
   137  		}))
   138  		buf.WriteString("\n")
   139  	}
   140  
   141  	for _, assignment := range s.Expr.Let.Assignments {
   142  		if assignment.Name == buildexpression.RequirementsKey {
   143  			assignment = transformRequirements(assignment)
   144  		}
   145  		buf.WriteString(assignmentString(assignment))
   146  		buf.WriteString("\n")
   147  	}
   148  
   149  	buf.WriteString("\n")
   150  	buf.WriteString("main = ")
   151  	switch {
   152  	case s.Expr.Let.In.FuncCall != nil:
   153  		buf.WriteString(apString(s.Expr.Let.In.FuncCall))
   154  	case s.Expr.Let.In.Name != nil:
   155  		buf.WriteString(*s.Expr.Let.In.Name)
   156  	}
   157  
   158  	return buf.String()
   159  }
   160  
   161  // transformRequirements transforms a buildexpression list of requirements in object form into a
   162  // list of requirements in function-call form, which is how requirements are represented in
   163  // buildscripts.
   164  // This is to avoid custom marshaling code and reuse existing marshaling code.
   165  func transformRequirements(reqs *buildexpression.Var) *buildexpression.Var {
   166  	newReqs := &buildexpression.Var{
   167  		Name: buildexpression.RequirementsKey,
   168  		Value: &buildexpression.Value{
   169  			List: &[]*buildexpression.Value{},
   170  		},
   171  	}
   172  
   173  	for _, req := range *reqs.Value.List {
   174  		*newReqs.Value.List = append(*newReqs.Value.List, transformRequirement(req))
   175  	}
   176  
   177  	return newReqs
   178  }
   179  
   180  // transformRequirement transforms a buildexpression requirement in object form into a requirement
   181  // in function-call form.
   182  // For example, transform something like
   183  //
   184  //	{"name": "<name>", "namespace": "<namespace>",
   185  //		"version_requirements": [{"comparator": "<op>", "version": "<version>"}]}
   186  //
   187  // into something like
   188  //
   189  //	Req(name = "<name>", namespace = "<namespace>", version = <op>(value = "<version>"))
   190  func transformRequirement(req *buildexpression.Value) *buildexpression.Value {
   191  	newReq := &buildexpression.Value{
   192  		Ap: &buildexpression.Ap{
   193  			Name:      reqFuncName,
   194  			Arguments: []*buildexpression.Value{},
   195  		},
   196  	}
   197  
   198  	for _, arg := range *req.Object {
   199  		name := arg.Name
   200  		value := arg.Value
   201  
   202  		// Transform the version value from the requirement object.
   203  		if name == buildexpression.RequirementVersionRequirementsKey {
   204  			name = buildexpression.RequirementVersionKey
   205  			value = &buildexpression.Value{Ap: transformVersion(arg)}
   206  		}
   207  
   208  		// Add the argument to the function transformation.
   209  		newReq.Ap.Arguments = append(newReq.Ap.Arguments, &buildexpression.Value{
   210  			Assignment: &buildexpression.Var{Name: name, Value: value},
   211  		})
   212  	}
   213  
   214  	return newReq
   215  }
   216  
   217  // transformVersion transforms a buildexpression version_requirements list in object form into
   218  // function-call form.
   219  // For example, transform something like
   220  //
   221  //	[{"comparator": "<op1>", "version": "<version1>"}, {"comparator": "<op2>", "version": "<version2>"}]
   222  //
   223  // into something like
   224  //
   225  //	And(<op1>(value = "<version1>"), <op2>(value = "<version2>"))
   226  func transformVersion(requirements *buildexpression.Var) *buildexpression.Ap {
   227  	var aps []*buildexpression.Ap
   228  	for _, constraint := range *requirements.Value.List {
   229  		ap := &buildexpression.Ap{}
   230  		for _, o := range *constraint.Object {
   231  			switch o.Name {
   232  			case buildexpression.RequirementVersionKey:
   233  				ap.Arguments = []*buildexpression.Value{{
   234  					Assignment: &buildexpression.Var{Name: "value", Value: &buildexpression.Value{Str: o.Value.Str}},
   235  				}}
   236  			case buildexpression.RequirementComparatorKey:
   237  				ap.Name = cases.Title(language.English).String(*o.Value.Str)
   238  			}
   239  		}
   240  		aps = append(aps, ap)
   241  	}
   242  
   243  	if len(aps) == 1 {
   244  		return aps[0] // e.g. Eq(value = "1.0")
   245  	}
   246  
   247  	// e.g. And(left = Gt(value = "1.0"), right = Lt(value = "3.0"))
   248  	// Iterate backwards over the requirements array and construct a binary tree of 'And()' functions.
   249  	// For example, given [Gt(value = "1.0"), Ne(value = "2.0"), Lt(value = "3.0")], produce:
   250  	//   And(left = Gt(value = "1.0"), right = And(left = Ne(value = "2.0"), right = Lt(value = "3.0")))
   251  	var ap *buildexpression.Ap
   252  	for i := len(aps) - 2; i >= 0; i-- {
   253  		right := &buildexpression.Value{Ap: aps[i+1]}
   254  		if ap != nil {
   255  			right = &buildexpression.Value{Ap: ap}
   256  		}
   257  		args := []*buildexpression.Value{
   258  			{Assignment: &buildexpression.Var{Name: "left", Value: &buildexpression.Value{Ap: aps[i]}}},
   259  			{Assignment: &buildexpression.Var{Name: "right", Value: right}},
   260  		}
   261  		ap = &buildexpression.Ap{Name: andFuncName, Arguments: args}
   262  	}
   263  	return ap
   264  }
   265  
   266  func assignmentString(a *buildexpression.Var) string {
   267  	if a.Name == buildexpression.RequirementsKey {
   268  		a = transformRequirements(a)
   269  	}
   270  	return fmt.Sprintf("%s = %s", a.Name, valueString(a.Value))
   271  }
   272  
   273  func valueString(v *buildexpression.Value) string {
   274  	switch {
   275  	case v.Ap != nil:
   276  		return apString(v.Ap)
   277  
   278  	case v.List != nil:
   279  		buf := bytes.Buffer{}
   280  		buf.WriteString("[\n")
   281  		for i, item := range *v.List {
   282  			buf.WriteString(indent(valueString(item)))
   283  			if i+1 < len(*v.List) {
   284  				buf.WriteString(",")
   285  			}
   286  			buf.WriteString("\n")
   287  		}
   288  		buf.WriteString("]")
   289  		return buf.String()
   290  
   291  	case v.Str != nil:
   292  		if strings.HasPrefix(*v.Str, "$") { // variable reference
   293  			return strings.TrimLeft(*v.Str, "$")
   294  		}
   295  		return strconv.Quote(*v.Str)
   296  
   297  	case v.Float != nil:
   298  		return strconv.FormatFloat(*v.Float, 'G', -1, 64) // 64-bit float with minimum digits on display
   299  
   300  	case v.Null != nil:
   301  		return "null"
   302  
   303  	case v.Assignment != nil:
   304  		return assignmentString(v.Assignment)
   305  
   306  	case v.Object != nil:
   307  		buf := bytes.Buffer{}
   308  		buf.WriteString("{\n")
   309  		for i, pair := range *v.Object {
   310  			buf.WriteString(indent(assignmentString(pair)))
   311  			if i+1 < len(*v.Object) {
   312  				buf.WriteString(",")
   313  			}
   314  			buf.WriteString("\n")
   315  		}
   316  		buf.WriteString("}")
   317  		return buf.String()
   318  
   319  	case v.Ident != nil:
   320  		return *v.Ident
   321  	}
   322  
   323  	return "[\n]" // participle does not create v.List if it's empty
   324  }
   325  
   326  // inlineFunctions contains buildscript function names whose arguments should all be written on a
   327  // single line. By default, function arguments are written one per line.
   328  var inlineFunctions = []string{
   329  	reqFuncName,
   330  	eqFuncName, neFuncName,
   331  	gtFuncName, gteFuncName,
   332  	ltFuncName, lteFuncName,
   333  	andFuncName,
   334  }
   335  
   336  func apString(f *buildexpression.Ap) string {
   337  	var (
   338  		newline = "\n"
   339  		comma   = ","
   340  		indent  = indent
   341  	)
   342  
   343  	if funk.Contains(inlineFunctions, f.Name) {
   344  		newline = ""
   345  		comma = ", "
   346  		indent = func(s string) string {
   347  			return s
   348  		}
   349  	}
   350  
   351  	buf := bytes.Buffer{}
   352  	buf.WriteString(fmt.Sprintf("%s(%s", f.Name, newline))
   353  
   354  	for i, argument := range f.Arguments {
   355  		buf.WriteString(indent(valueString(argument)))
   356  
   357  		if i+1 < len(f.Arguments) {
   358  			buf.WriteString(comma)
   359  		}
   360  
   361  		buf.WriteString(newline)
   362  	}
   363  
   364  	buf.WriteString(")")
   365  	return buf.String()
   366  }
   367  
   368  func (s *Script) Equals(other *Script) bool {
   369  	// Compare top-level at_time.
   370  	switch {
   371  	case s.AtTime != nil && other.AtTime != nil && s.AtTime.String() != other.AtTime.String():
   372  		return false
   373  	case (s.AtTime == nil) != (other.AtTime == nil):
   374  		return false
   375  	}
   376  
   377  	// Compare buildexpression JSON.
   378  	myJson, err := json.Marshal(s.Expr)
   379  	if err != nil {
   380  		multilog.Error("Unable to marshal this buildscript to JSON: %v", err)
   381  		return false
   382  	}
   383  	otherJson, err := json.Marshal(other.Expr)
   384  	if err != nil {
   385  		multilog.Error("Unable to marshal other buildscript to JSON: %v", err)
   386  		return false
   387  	}
   388  	return string(myJson) == string(otherJson)
   389  }