github.com/replit/upm@v0.0.0-20240423230255-9ce4fc3ea24c/internal/store/store.go (about)

     1  // Package store handles reading and writing the .upm/store.json file.
     2  // This file is used to cache several things for performance reasons,
     3  // as described in the README.
     4  package store
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"os"
    10  	"path/filepath"
    11  	"strings"
    12  
    13  	"github.com/replit/upm/internal/api"
    14  	"github.com/replit/upm/internal/util"
    15  	"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer"
    16  )
    17  
    18  // st is a global object representing the store data. All functions in
    19  // this file read and write to it. Only one store is supported.
    20  var st *store
    21  
    22  // currentVersion is the current store schema version. See the Version
    23  // field in the store struct.
    24  const currentVersion = 2
    25  
    26  // getStoreLocation returns the file path of the JSON store.
    27  func getStoreLocation() string {
    28  	loc, ok := os.LookupEnv("UPM_STORE")
    29  	if ok {
    30  		return loc
    31  	} else {
    32  		return ".upm/store.json"
    33  	}
    34  }
    35  
    36  // read reads the store from disk and writes it into the global
    37  // variable st. If there is an error, it terminates the process.
    38  func read() {
    39  	st = &store{}
    40  	defer func() {
    41  		st.Version = currentVersion
    42  	}()
    43  
    44  	filename := getStoreLocation()
    45  	bytes, err := os.ReadFile(filename)
    46  
    47  	if err != nil {
    48  		if os.IsNotExist(err) {
    49  			return
    50  		}
    51  		util.DieIO("%s: %s", filename, err)
    52  	}
    53  
    54  	if len(strings.TrimSpace(string(bytes))) > 0 {
    55  		if err = json.Unmarshal(bytes, st); err != nil {
    56  			util.DieProtocol("%s: %s", filename, err)
    57  		}
    58  	}
    59  
    60  	if st.Version != currentVersion {
    61  		st = &store{}
    62  	}
    63  }
    64  
    65  // readMaybe reads the store if it hasn't been read yet.
    66  func readMaybe() {
    67  	if st == nil {
    68  		read()
    69  	}
    70  }
    71  
    72  // initLanguage creates an entry in the store for the given language,
    73  // if necessary. (A language is just the name of a backend.)
    74  func initLanguage(language, languageAlias string) {
    75  	if st.Languages == nil {
    76  		st.Languages = map[string]*storeLanguage{}
    77  	}
    78  	if st.Languages[language] == nil && (languageAlias == "" || st.Languages[languageAlias] == nil) {
    79  		st.Languages[language] = &storeLanguage{}
    80  	}
    81  }
    82  
    83  func getLanguageCache(language, languageAlias string) *storeLanguage {
    84  	if st.Languages == nil {
    85  		return nil
    86  	}
    87  	if st.Languages[language] != nil {
    88  		return st.Languages[language]
    89  	}
    90  	if languageAlias != "" && st.Languages[languageAlias] != nil {
    91  		return st.Languages[languageAlias]
    92  	}
    93  	return nil
    94  }
    95  
    96  // Write writes the current contents of the store from memory back to
    97  // disk. If there is an error, it terminates the process.
    98  func Write(ctx context.Context) {
    99  	//nolint:ineffassign,wastedassign,staticcheck
   100  	span, ctx := tracer.StartSpanFromContext(ctx, "store.Write")
   101  	defer span.Finish()
   102  	filename := getStoreLocation()
   103  
   104  	filename, err := filepath.Abs(filename)
   105  	if err != nil {
   106  		util.DieIO("%s: %s", filename, err)
   107  	}
   108  
   109  	directory, _ := filepath.Split(filename)
   110  	if err := os.MkdirAll(directory, 0o777); err != nil {
   111  		util.DieIO("%s: %s", directory, err)
   112  	}
   113  
   114  	content, err := json.Marshal(st)
   115  	if err != nil {
   116  		util.Panicf("Store.Write: %s", err)
   117  	}
   118  	content = append(content, '\n')
   119  
   120  	util.TryWriteAtomic(filename, content)
   121  }
   122  
   123  // HasSpecfileChanged returns false if the specfile exists and has not
   124  // changed since the last time UpdateFileHashes was called, or if it
   125  // doesn't exist and it didn't exist last time either. Otherwise, it
   126  // returns true.
   127  func HasSpecfileChanged(b api.LanguageBackend) bool {
   128  	readMaybe()
   129  	initLanguage(b.Name, b.Alias)
   130  	return hashFile(b.Specfile) != getLanguageCache(b.Name, b.Alias).SpecfileHash
   131  }
   132  
   133  // HasLockfileChanged returns false if the lockfile exists and has not
   134  // changed since the last time UpdateFileHashes was called, or if it
   135  // doesn't exist and it didn't exist last time either. Otherwise, it
   136  // returns true.
   137  func HasLockfileChanged(b api.LanguageBackend) bool {
   138  	readMaybe()
   139  	initLanguage(b.Name, b.Alias)
   140  	return hashFile(b.Lockfile) != getLanguageCache(b.Name, b.Alias).LockfileHash
   141  }
   142  
   143  // GuessWithCache returns b.Guess(), but re-uses a cached return value
   144  // if possible. The cache is used if the matches of b.GuessRegexps
   145  // against b.FilenamePatterns has not changed since the last time
   146  // GuessWithCache was invoked. (This is only possible if the backend
   147  // specifies b.GuessRegexps, which is not always the case. If the
   148  // backend does specify b.GuessRegexps, then the return value of this
   149  // function is cached.) If forceGuess is true, then write to but do
   150  // not read from the cache.
   151  func GuessWithCache(ctx context.Context, b api.LanguageBackend, forceGuess bool) map[string][]api.PkgName {
   152  	span, ctx := tracer.StartSpanFromContext(ctx, "GuessWithCache")
   153  	defer span.Finish()
   154  	readMaybe()
   155  	initLanguage(b.Name, b.Alias)
   156  	cache := getLanguageCache(b.Name, b.Alias)
   157  	old := cache.GuessedImportsHash
   158  	var new hash = "n/a"
   159  	// If no regexps, then we can't hash imports. Skip reading and
   160  	// writing the hash.
   161  	if len(b.GuessRegexps) > 0 {
   162  		new = hashImports(b)
   163  		cache.GuessedImportsHash = new
   164  	}
   165  	if forceGuess || new != old {
   166  		var pkgs map[string][]api.PkgName
   167  		success := true
   168  		if new != "" {
   169  			pkgs, success = b.Guess(ctx)
   170  		} else {
   171  			// If new is the empty string, that means
   172  			// (according to the interface of hashImports)
   173  			// that there were no regexp matches. In that
   174  			// case we shouldn't have any packages
   175  			// returned by the bare imports search. Might
   176  			// as well just skip the search, right?
   177  			pkgs = map[string][]api.PkgName{}
   178  		}
   179  		if !success {
   180  			// If bare imports search is not successful,
   181  			// e.g. due to syntax error, then don't update
   182  			// the hash. This will force the search to be
   183  			// redone next time.
   184  			cache.GuessedImportsHash = old
   185  		}
   186  		// Only cache result if we are going to use the cache,
   187  		// and skip caching if bare imports search was not
   188  		// successful (e.g. due to syntax error). Also, avoid
   189  		// updating the imports cache if search was not
   190  		// successful. You might think this condition isn't
   191  		// needed, but it actually is for the case where you
   192  		// run guess on import A, then on import B with syntax
   193  		// error, then on import A again.
   194  		if len(b.GuessRegexps) > 0 && success {
   195  			guessed := []string{}
   196  			for name := range pkgs {
   197  				guessed = append(guessed, string(name))
   198  			}
   199  			cache.GuessedImports = guessed
   200  		}
   201  		return pkgs
   202  	} else {
   203  		pkgs := map[string][]api.PkgName{}
   204  		for _, name := range cache.GuessedImports {
   205  			pkgs[name] = []api.PkgName{api.PkgName(name)}
   206  		}
   207  		return pkgs
   208  	}
   209  }
   210  
   211  func ClearGuesses(ctx context.Context, b api.LanguageBackend) {
   212  	//nolint:ineffassign,wastedassign,staticcheck
   213  	span, ctx := tracer.StartSpanFromContext(ctx, "ClearGuesses")
   214  	defer span.Finish()
   215  
   216  	cache := getLanguageCache(b.Name, b.Alias)
   217  
   218  	cache.GuessedImports = nil
   219  	cache.GuessedImportsHash = ""
   220  }
   221  
   222  func Read(ctx context.Context, b api.LanguageBackend) {
   223  	//nolint:ineffassign,wastedassign,staticcheck
   224  	span, ctx := tracer.StartSpanFromContext(ctx, "store.Read")
   225  	defer span.Finish()
   226  	readMaybe()
   227  	initLanguage(b.Name, b.Alias)
   228  }
   229  
   230  // UpdateFileHashes caches the current states of the specfile and
   231  // lockfile. Neither file need exist.
   232  func UpdateFileHashes(ctx context.Context, b api.LanguageBackend) {
   233  	//nolint:ineffassign,wastedassign,staticcheck
   234  	span, ctx := tracer.StartSpanFromContext(ctx, "store.UpdateFileHashes")
   235  	defer span.Finish()
   236  	cache := getLanguageCache(b.Name, b.Alias)
   237  	cache.SpecfileHash = hashFile(b.Specfile)
   238  	cache.LockfileHash = hashFile(b.Lockfile)
   239  }