cuelang.org/go@v0.10.1/internal/filetypes/filetypes.go (about)

     1  // Copyright 2020 CUE Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  package filetypes
    16  
    17  import (
    18  	"path/filepath"
    19  	"strings"
    20  
    21  	"cuelang.org/go/cue"
    22  	"cuelang.org/go/cue/build"
    23  	"cuelang.org/go/cue/errors"
    24  	"cuelang.org/go/cue/token"
    25  )
    26  
    27  // Mode indicate the base mode of operation and indicates a different set of
    28  // defaults.
    29  type Mode int
    30  
    31  const (
    32  	Input Mode = iota // The default
    33  	Export
    34  	Def
    35  	Eval
    36  )
    37  
    38  func (m Mode) String() string {
    39  	switch m {
    40  	default:
    41  		return "input"
    42  	case Eval:
    43  		return "eval"
    44  	case Export:
    45  		return "export"
    46  	case Def:
    47  		return "def"
    48  	}
    49  }
    50  
    51  // FileInfo defines the parsing plan for a file.
    52  type FileInfo struct {
    53  	*build.File
    54  
    55  	Definitions  bool `json:"definitions"`  // include/allow definition fields
    56  	Data         bool `json:"data"`         // include/allow regular fields
    57  	Optional     bool `json:"optional"`     // include/allow definition fields
    58  	Constraints  bool `json:"constraints"`  // include/allow constraints
    59  	References   bool `json:"references"`   // don't resolve/allow references
    60  	Cycles       bool `json:"cycles"`       // cycles are permitted
    61  	KeepDefaults bool `json:"keepDefaults"` // select/allow default values
    62  	Incomplete   bool `json:"incomplete"`   // permit incomplete values
    63  	Imports      bool `json:"imports"`      // don't expand/allow imports
    64  	Stream       bool `json:"stream"`       // permit streaming
    65  	Docs         bool `json:"docs"`         // show/allow docs
    66  	Attributes   bool `json:"attributes"`   // include/allow attributes
    67  }
    68  
    69  // TODO(mvdan): the funcs below make use of typesValue concurrently,
    70  // even though we clearly document that cue.Values are not safe for concurrent use.
    71  // It seems to be OK in practice, as otherwise we would run into `go test -race` failures.
    72  
    73  // FromFile return detailed file info for a given build file.
    74  // Encoding must be specified.
    75  // TODO: mode should probably not be necessary here.
    76  func FromFile(b *build.File, mode Mode) (*FileInfo, error) {
    77  	// Handle common case. This allows certain test cases to be analyzed in
    78  	// isolation without interference from evaluating these files.
    79  	if mode == Input &&
    80  		b.Encoding == build.CUE &&
    81  		b.Form == "" &&
    82  		b.Interpretation == "" {
    83  		return &FileInfo{
    84  			File: b,
    85  
    86  			Definitions:  true,
    87  			Data:         true,
    88  			Optional:     true,
    89  			Constraints:  true,
    90  			References:   true,
    91  			Cycles:       true,
    92  			KeepDefaults: true,
    93  			Incomplete:   true,
    94  			Imports:      true,
    95  			Stream:       true,
    96  			Docs:         true,
    97  			Attributes:   true,
    98  		}, nil
    99  	}
   100  
   101  	typesInit()
   102  	modeVal := typesValue.LookupPath(cue.MakePath(cue.Str("modes"), cue.Str(mode.String())))
   103  	fileVal := modeVal.LookupPath(cue.MakePath(cue.Str("FileInfo")))
   104  	fileVal = fileVal.FillPath(cue.Path{}, b)
   105  
   106  	if b.Encoding == "" {
   107  		ext := modeVal.LookupPath(cue.MakePath(cue.Str("extensions"), cue.Str(fileExt(b.Filename))))
   108  		if ext.Exists() {
   109  			fileVal = fileVal.Unify(ext)
   110  		}
   111  	}
   112  	var errs errors.Error
   113  
   114  	interpretation, _ := fileVal.LookupPath(cue.MakePath(cue.Str("interpretation"))).String()
   115  	if b.Form != "" {
   116  		fileVal, errs = unifyWith(errs, fileVal, typesValue, "forms", string(b.Form))
   117  		// may leave some encoding-dependent options open in data mode.
   118  	} else if interpretation != "" {
   119  		// always sets schema form.
   120  		fileVal, errs = unifyWith(errs, fileVal, typesValue, "interpretations", interpretation)
   121  	}
   122  	if interpretation == "" {
   123  		s, err := fileVal.LookupPath(cue.MakePath(cue.Str("encoding"))).String()
   124  		if err != nil {
   125  			return nil, err
   126  		}
   127  		fileVal, errs = unifyWith(errs, fileVal, modeVal, "encodings", s)
   128  	}
   129  
   130  	fi := &FileInfo{}
   131  	if err := fileVal.Decode(fi); err != nil {
   132  		return nil, errors.Wrapf(err, token.NoPos, "could not parse arguments")
   133  	}
   134  	return fi, errs
   135  }
   136  
   137  // unifyWith returns the equivalent of `v1 & v2[field][value]`.
   138  func unifyWith(errs errors.Error, v1, v2 cue.Value, field, value string) (cue.Value, errors.Error) {
   139  	v1 = v1.Unify(v2.LookupPath(cue.MakePath(cue.Str(field), cue.Str(value))))
   140  	if err := v1.Err(); err != nil {
   141  		errs = errors.Append(errs,
   142  			errors.Newf(token.NoPos, "unknown %s %s", field, value))
   143  	}
   144  	return v1, errs
   145  }
   146  
   147  // ParseArgs converts a sequence of command line arguments representing
   148  // files into a sequence of build file specifications.
   149  //
   150  // The arguments are of the form
   151  //
   152  //	file* (spec: file+)*
   153  //
   154  // where file is a filename and spec is itself of the form
   155  //
   156  //	tag[=value]('+'tag[=value])*
   157  //
   158  // A file type spec applies to all its following files and until a next spec
   159  // is found.
   160  //
   161  // Examples:
   162  //
   163  //	json: foo.data bar.data json+schema: bar.schema
   164  func ParseArgs(args []string) (files []*build.File, err error) {
   165  	typesInit()
   166  	var modeVal, fileVal cue.Value
   167  
   168  	qualifier := ""
   169  	hasFiles := false
   170  
   171  	for i, s := range args {
   172  		a := strings.Split(s, ":")
   173  		switch {
   174  		case len(a) == 1 || len(a[0]) == 1: // filename
   175  			if !fileVal.Exists() {
   176  				if len(a) == 1 && strings.HasSuffix(a[0], ".cue") {
   177  					// Handle majority case.
   178  					f := *fileForCUE
   179  					f.Filename = a[0]
   180  					files = append(files, &f)
   181  					hasFiles = true
   182  					continue
   183  				}
   184  
   185  				modeVal, fileVal, err = parseType("", Input)
   186  				if err != nil {
   187  					return nil, err
   188  				}
   189  			}
   190  			if s == "" {
   191  				return nil, errors.Newf(token.NoPos, "empty file name")
   192  			}
   193  			f, err := toFile(modeVal, fileVal, s)
   194  			if err != nil {
   195  				return nil, err
   196  			}
   197  			files = append(files, f)
   198  			hasFiles = true
   199  
   200  		case len(a) > 2 || a[0] == "":
   201  			return nil, errors.Newf(token.NoPos,
   202  				"unsupported file name %q: may not have ':'", s)
   203  
   204  		case a[1] != "":
   205  			return nil, errors.Newf(token.NoPos, "cannot combine scope with file")
   206  
   207  		default: // scope
   208  			switch {
   209  			case i == len(args)-1:
   210  				qualifier = a[0]
   211  				fallthrough
   212  			case qualifier != "" && !hasFiles:
   213  				return nil, errors.Newf(token.NoPos, "scoped qualifier %q without file", qualifier+":")
   214  			}
   215  			modeVal, fileVal, err = parseType(a[0], Input)
   216  			if err != nil {
   217  				return nil, err
   218  			}
   219  			qualifier = a[0]
   220  			hasFiles = false
   221  		}
   222  	}
   223  
   224  	return files, nil
   225  }
   226  
   227  // ParseFile parses a single-argument file specifier, such as when a file is
   228  // passed to a command line argument.
   229  //
   230  // Example:
   231  //
   232  //	cue eval -o yaml:foo.data
   233  func ParseFile(s string, mode Mode) (*build.File, error) {
   234  	scope := ""
   235  	file := s
   236  
   237  	if p := strings.LastIndexByte(s, ':'); p >= 0 {
   238  		scope = s[:p]
   239  		file = s[p+1:]
   240  		if scope == "" {
   241  			return nil, errors.Newf(token.NoPos, "unsupported file name %q: may not have ':", s)
   242  		}
   243  	}
   244  
   245  	if file == "" {
   246  		if s != "" {
   247  			return nil, errors.Newf(token.NoPos, "empty file name in %q", s)
   248  		}
   249  		return nil, errors.Newf(token.NoPos, "empty file name")
   250  	}
   251  
   252  	return ParseFileAndType(file, scope, mode)
   253  }
   254  
   255  // ParseFileAndType parses a file and type combo.
   256  func ParseFileAndType(file, scope string, mode Mode) (*build.File, error) {
   257  	// Quickly discard files which we aren't interested in.
   258  	// These cases are very common when loading `./...` in a large repository.
   259  	typesInit()
   260  	if scope == "" && file != "-" {
   261  		ext := fileExt(file)
   262  		if ext == "" {
   263  			return nil, errors.Newf(token.NoPos, "no encoding specified for file %q", file)
   264  		}
   265  		f, ok := fileForExt[ext]
   266  		if !ok {
   267  			return nil, errors.Newf(token.NoPos, "unknown file extension %s", ext)
   268  		}
   269  		if mode == Input {
   270  			f1 := *f
   271  			f1.Filename = file
   272  			return &f1, nil
   273  		}
   274  	}
   275  	modeVal, fileVal, err := parseType(scope, mode)
   276  	if err != nil {
   277  		return nil, err
   278  	}
   279  	return toFile(modeVal, fileVal, file)
   280  }
   281  
   282  func hasEncoding(v cue.Value) bool {
   283  	enc := v.LookupPath(cue.MakePath(cue.Str("encoding")))
   284  	d, _ := enc.Default()
   285  	return d.IsConcrete()
   286  }
   287  
   288  func toFile(modeVal, fileVal cue.Value, filename string) (*build.File, error) {
   289  	if !hasEncoding(fileVal) {
   290  		if filename == "-" {
   291  			fileVal = fileVal.Unify(modeVal.LookupPath(cue.MakePath(cue.Str("Default"))))
   292  		} else if ext := fileExt(filename); ext != "" {
   293  			extFile := modeVal.LookupPath(cue.MakePath(cue.Str("extensions"), cue.Str(ext)))
   294  			fileVal = fileVal.Unify(extFile)
   295  			if err := fileVal.Err(); err != nil {
   296  				return nil, errors.Newf(token.NoPos, "unknown file extension %s", ext)
   297  			}
   298  		} else {
   299  			return nil, errors.Newf(token.NoPos, "no encoding specified for file %q", filename)
   300  		}
   301  	}
   302  
   303  	// Note that the filename is only filled in the Go value, and not the CUE value.
   304  	// This makes no difference to the logic, but saves a non-trivial amount of evaluator work.
   305  	f := &build.File{Filename: filename}
   306  	if err := fileVal.Decode(&f); err != nil {
   307  		return nil, errors.Wrapf(err, token.NoPos,
   308  			"could not determine file type")
   309  	}
   310  	return f, nil
   311  }
   312  
   313  func parseType(scope string, mode Mode) (modeVal, fileVal cue.Value, _ error) {
   314  	modeVal = typesValue.LookupPath(cue.MakePath(cue.Str("modes"), cue.Str(mode.String())))
   315  	fileVal = modeVal.LookupPath(cue.MakePath(cue.Str("File")))
   316  
   317  	if scope != "" {
   318  		for _, tag := range strings.Split(scope, "+") {
   319  			tagName, tagVal, ok := strings.Cut(tag, "=")
   320  			if ok {
   321  				fileVal = fileVal.FillPath(cue.MakePath(cue.Str("tags"), cue.Str(tagName)), tagVal)
   322  			} else {
   323  				info := typesValue.LookupPath(cue.MakePath(cue.Str("tags"), cue.Str(tag)))
   324  				if !info.Exists() {
   325  					return cue.Value{}, cue.Value{}, errors.Newf(token.NoPos, "unknown filetype %s", tag)
   326  				}
   327  				fileVal = fileVal.Unify(info)
   328  			}
   329  		}
   330  	}
   331  
   332  	return modeVal, fileVal, nil
   333  }
   334  
   335  // fileExt is like filepath.Ext except we don't treat file names starting with "." as having an extension
   336  // unless there's also another . in the name.
   337  func fileExt(f string) string {
   338  	e := filepath.Ext(f)
   339  	if e == "" || e == filepath.Base(f) {
   340  		return ""
   341  	}
   342  	return e
   343  }