Skip to content

Commit

Permalink
feat(projects): Allow multiple getters, and other new features.
Browse files Browse the repository at this point in the history
Allows each "getter" in a project to be a single getter or an array
of getters.  This allows use to do things like check for `volta` when
trying to get the node verson, and falling back to `node --version`
if volta is not installed.  We also allow specifying `cache.files` in
a getter, to have a list of files we should check have not changed.

Allow styles in default project config.  These can be overridden by
the "project" module.
  • Loading branch information
jwalton committed Feb 5, 2022
1 parent 6d45ce5 commit 05a8c69
Show file tree
Hide file tree
Showing 20 changed files with 477 additions and 434 deletions.
29 changes: 27 additions & 2 deletions docs/docs/projects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The "project" module works out what kind of project the current folder represent
```yaml
projectTypes:
- name: node
style: brightYellow
conditions:
ifFiles: ["package.json"]
toolSymbol: Node
Expand All @@ -32,11 +33,14 @@ projectTypes:
Here we can see that each item in the the "projectTypes" list contains:
- `name` - the name of this project type.
- `style` - a default style to apply to the project. This can be overridden in the "project" module.
- `conditions` - [conditions](./reference/conditions.mdx) for when to activate this project.
- `toolSymbol` - the default symbol to show for this project type in the "project" module.
- `toolVersion` - the "getter" to use to get the version of the tool.
- `toolVersion` - the "getter" (or a list of getters) to use to get the version of the tool. If this is a list, then each item in the list will be executed until one succeeds, or until all fail.
- `pacakgeManagerSymbol` (optional) - the name of the package manager used (e.g. "npm").
- `packageManagerVersion` - the "getter" (or a list of getters) to use to get the package manager version for this project type.
- `packageManagerSymbol` and `packageVersion` (optional) - "getters" for the package manager version and the package version.
- `packageVersion` - the "getter" (or a list of getters) to use to get the package version for this project.

A given folder may be ambiguous in terms of project type - for example if a folder contains a "package.json" and a "go.mod", should we treat it as a node project or as a go project? The "project" module will go through the list of project types, and will return the first one that matches - the order in `projectTypes` defines the precedence.

Expand Down Expand Up @@ -81,7 +85,7 @@ For any getter, you can also specify:
- `as:` - how to interpret the retrieved value - one of `text`, `json`, `toml`, or `yaml`. For `json`, `toml`, or `yaml`, the file will be parsed and the results passed to the `valueTemplate`. For `text`, the valueTemplate will get a `{ Text }` object. If `as` is specified and `valueTemplate` is not, then this getter will never return a value.
- `valueTemplate` - A template used to render the value. Note that the valueTemplate is passed the parsed object from "as" - `.Globals` are not available in this template, nor are style functions.
- `regex` - A regular expression used to extract a value - if there are any capturing groups in the regex, then the first capturing group will be returned. Otherwise, the matched text will be returned. If `regex` is specified, then `as` will be ignored. If both `regex` and `valueTemplate` are specified, then `valueTemplate` will be run after the regex.
- `cache` - Caching only applies to "custom" getters. If `cache.enabled` is true, then the getter will resolve the full path of the executable (following any sym-links), and then use the full path, the last modified date, the size of the command, and the arguments as a cache key. Caches are written to the "cache" subfolder in your configuration directory. Caching means that, if we're interested in what version of npm is installed, we only need to run `npm --version` if and when the `npm` executable changes (which is good, because `npm version` takes almost half a second, which would add unacceptable delay to the command prompt).
- `cache` - Caching only applies to "custom" getters. Thisis an `{ enabled, files }` object.

An example of the `regex` option can be seen in this example to fetch the version from the `go version` command. This will print a value like "go version go1.17.1 darwin/amd64", and the regex will extract the "1.17.1" part:

Expand All @@ -98,3 +102,24 @@ projectTypes:
```

If the "tool" getter returns an empty value or an error, the project will not be selected, even if it otherwise would have been. For security reasons, a "custom" getter will ignore "." in the PATH. For example, if the command is "npm --version", and there is an "npm" command in the current working directory, then "custom" will not run `./npm` even if "." is in the PATH.

### Caching

Caching is used to cache the results of running a command between executions. For example, in node.js, running `npm --version` takes about half a second - if we ran this command every time we wanted to show the prompt, then showing the prompt would take about half a second, which would be unacceptably slow. To get around this we cache the output of `npm --version`, but we need to know when to invalidate the cache, and that's where the `cache` section comes in.

If `cache.enabled` is true on a getter, then the getter will resolve the full path of the executable (following any sym-links), and then use the full path, the last modified date, the size of the command, and the arguments to the command as a cache key. If any of these values change, we'll invalidate the command. If `cache.files` is specified, then the full path, last modified date, and size of each of the specified files will be added to the cache key as well. File names in `cache.files` may start with "~", which will be replaced with the user's home directory, and may contain `${VAR}` environment variable substitutions.

A good example of when you would need to specify `cache.files` is where you're using a shim-based version manager. For example, again in node.js, Volta is a popular tool for managing multiple versions of node.js, but it uses a "shim" for each executable, so when we try to lookup the location of `npm`, we will find it in `~/.volta/bin/npm`. This executable never changes - when you run it, it works out which version of npm needs to be run and then goes and runs it. If we tried to use the Volta `npm` has a cache key, we'd never invalidate the cache. Instead, we can use something like this for volta:

```yaml
- type: custom
from: "volta which npm"
regex: "image/npm/(\d+\.\d+\.\d+)/bin"
cache:
enabled: true
files:
- "./package.json"
- "${VOLTA_HOME}/tools/user/platform.json
```

Caches are written to the "cache" subfolder in your configuration directory.
6 changes: 5 additions & 1 deletion docs/docs/reference/modules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,14 @@ Outputs:

The project module works out what kind of project the current folder represents, and displays the current tooling versions. This is done through the ["projects" top-level configuration item](../projects.mdx) in `${configdir}/kitsch.yaml`.

The default output of the project module will be something like "w/[email protected]" or "w/[email protected]".

Each project has a unique style associated with it; the style comes from the `style` field in the `projects` map in this module. If none is specified, then it falls back to the `defaultProjectStyle` in this module. If that is also unspecified, then the style will be taken from teh top-level `projects` configuration.

Configuration:

- `projects` is a map where keys are project names, and values are `{ style, toolSymbol, packageManagerSymbol }` objects, which can be used to provide a custom style and symbols for existing projects on a theme-by-theme basis.
- `defaultProjectStyle` is the style to use if no project-specific style is specified.
- `defaultProjectStyle` is the style to use if no project-specific style is specified in `projects`. If this is also unspecified, we will fall back to the style specified in the top level `projects` configuration.

Outputs:

Expand Down
2 changes: 1 addition & 1 deletion internal/kitsch/config/jsonschema.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"definitions": {
"ModulesList": {
"type": "array",
"items": { "$ref": "#/definitions/module" }
"items": { "$ref": "#/definitions/module" }
},
{{ .Definitions }}
},
Expand Down
114 changes: 91 additions & 23 deletions internal/kitsch/getters/customGetter.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (

"github.com/BurntSushi/toml"
"github.com/Masterminds/sprig/v3"
"github.com/jwalton/kitsch/internal/cache"
"github.com/jwalton/kitsch/internal/fileutils"
"github.com/jwalton/kitsch/internal/kitsch/modtemplate"
"github.com/mattn/go-shellwords"
Expand Down Expand Up @@ -113,6 +112,12 @@ type CacheSettings struct {
// makes it so we will cache the output of a command instead of re-running that
// command.
Enabled bool `yaml:"enabled"`
// Files is the path to one or more files to use as the key for the cache. Each file's
// full path (following any symlinks), size, and last modified time will all
// form part of the cache key. For "custom" getters, the executable is implicitly
// used as a file - if this is specified, then both the executable and these
// files will be used.
Files []string `yaml:"file"`
}

// CustomGetter is a getter that can be configured from a YAML file.
Expand Down Expand Up @@ -144,11 +149,10 @@ func (getter CustomGetter) GetValue(context GetterContext) (interface{}, error)
var result interface{}

folder := context.GetWorkingDirectory()
valueCache := context.GetValueCache()

switch getter.Type {
case TypeCustom:
bytesValue, err = getter.getCustomValue(folder, valueCache, getter.From)
bytesValue, err = getter.getCustomValue(context, getter.From)
case TypeFile:
bytesValue, err = fs.ReadFile(folder.FileSystem(), getter.From)
case TypeAncestorFile:
Expand Down Expand Up @@ -214,11 +218,80 @@ func (getter CustomGetter) GetValue(context GetterContext) (interface{}, error)
return result, nil
}

func (getter CustomGetter) getCacheKeyForFile(file string) (string, error) {
var err error
origFile := file

file, err = filepath.Abs(file)
if err != nil {
return "", fmt.Errorf("could not get absolute path for file: \"%s\": %w", origFile, err)
}

// If the file is a symlink, resolve it.
file, err = filepath.EvalSymlinks(file)
if err != nil {
return "E_NOEXIST", nil
}

fileDetails, err := os.Stat(file)
if err != nil {
return "E_NOEXIST", nil
}

return fmt.Sprintf(
"%s:%d:%d",
file,
fileDetails.ModTime().Unix(),
fileDetails.Size(),
), nil
}

var variableRegExp = regexp.MustCompile(`\$\{?([a-zA-Z_]+[a-zA-Z0-9_]*)\}?`)

// ResolveFile resolve a file to an absolute path. This will take care of paths that
// start with "~" or which contain "${VAR}"iables.
func resolveFile(context GetterContext, file string) string {
if strings.HasPrefix(file, "~") {
file = filepath.Join(context.GetHomeDirectoryPath(), file[1:])
}

for match := variableRegExp.FindStringSubmatchIndex(file); match != nil; match = variableRegExp.FindStringSubmatchIndex(file) {
varName := file[match[2]:match[3]]
varValue := context.Getenv(varName)
file = file[:match[0]] + varValue + file[match[1]:]
}

return file
}

func (getter CustomGetter) getCacheKeyForFiles(context GetterContext, executable string) (string, error) {
result := ""

if executable != "" {
exeKey, err := getter.getCacheKeyForFile(executable)
if err != nil {
return "", err
}
result += "exe:" + exeKey
}

for _, file := range getter.Cache.Files {
fileKey, err := getter.getCacheKeyForFile(resolveFile(context, file))
if err != nil {
return "", err
}
result += ":file=" + fileKey
}

return result, nil
}

func (getter CustomGetter) getCustomValue(
projectFolder fileutils.Directory,
valueCache cache.Cache,
context GetterContext,
command string,
) ([]byte, error) {
valueCache := context.GetValueCache()

commandParts, err := shellwords.Parse(command)
if err != nil {
return nil, fmt.Errorf("invalid command: \"%s\": %w", command, err)
Expand All @@ -240,31 +313,26 @@ func (getter CustomGetter) getCustomValue(
return nil, fmt.Errorf("could not resolve executable: \"%s\": %w", commandParts[0], err)
}

executableDetails, err := os.Stat(executable)
if err != nil {
return nil, fmt.Errorf("could not stat executable: \"%s\": %w", commandParts[0], err)
}

var cacheKey string

// Try to get the value from the cache.
var cacheKey string
if getter.Cache.Enabled {
cacheKey = fmt.Sprintf(
"%s %s -- %d/%d",
executable,
strings.Join(commandParts[1:], " "),
executableDetails.ModTime().Unix(),
executableDetails.Size(),
)

if value := valueCache.Get(cacheKey); value != nil {
return value, nil
cacheKey, err = getter.getCacheKeyForFiles(context, executable)
if err != nil {
cacheKey = ""
} else {
cacheKey += ":args=" + strings.Join(commandParts[1:], " ")
}

if cacheKey != "" {
if value := valueCache.Get(cacheKey); value != nil {
return value, nil
}
}
}

// If that fails, run the command.
cmd := exec.Command(executable, commandParts[1:]...)
cmd.Dir = projectFolder.Path()
cmd.Dir = context.GetWorkingDirectory().Path()
result, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("error running command: \"%s\": %w", executable, err)
Expand Down
3 changes: 2 additions & 1 deletion internal/kitsch/getters/customGetter_schema.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 9 additions & 1 deletion internal/kitsch/getters/customGetter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,24 @@ import (

type testGetterContext struct {
directory fileutils.Directory
home string
cache cache.Cache
env map[string]string
}

// GetWorkingDirectory returns the current working directory.
func (context *testGetterContext) GetWorkingDirectory() fileutils.Directory {
return context.directory
}

// GetHomeDirectoryPath returns the path to the user's home directory.
func (context *testGetterContext) GetHomeDirectoryPath() string {
return context.home
}

// Getenv returns the value of the specified environment variable.
func (context *testGetterContext) Getenv(key string) string {
return ""
return context.env[key]
}

// GetValueCache returns the value cache.
Expand All @@ -32,6 +39,7 @@ func (context *testGetterContext) GetValueCache() cache.Cache {
func makeTestGetterContext(fsys fstest.MapFS) *testGetterContext {
return &testGetterContext{
directory: fileutils.NewDirectoryTestFS("/foo/bar", fsys),
home: "/users/jwalton",
cache: cache.NewMemoryCache(),
}
}
Expand Down
3 changes: 3 additions & 0 deletions internal/kitsch/getters/getter.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type GetterContext interface {
// GetWorkingDirectory returns the current working directory.
GetWorkingDirectory() fileutils.Directory

// GetHomeDirectoryPath returns the path to the user's home directory.
GetHomeDirectoryPath() string

// Getenv returns the value of the specified environment variable.
Getenv(key string) string

Expand Down
19 changes: 19 additions & 0 deletions internal/kitsch/getters/resolveFile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package getters

import (
"testing"
"testing/fstest"

"github.com/stretchr/testify/assert"
)

func TestResolveFile(t *testing.T) {
context := makeTestGetterContext(fstest.MapFS{})
context.env = map[string]string{
"VAR1": "foo",
"VAR2": "bar",
}

result := resolveFile(context, "~/${VAR1}/$VAR2/baz")
assert.Equal(t, "/users/jwalton/foo/bar/baz", result)
}
5 changes: 5 additions & 0 deletions internal/kitsch/modules/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ func (context *Context) GetWorkingDirectory() fileutils.Directory {
return context.Directory
}

// GetHomeDirectoryPath returns the full path to the user's home directory.
func (context *Context) GetHomeDirectoryPath() string {
return context.Globals.Home
}

// Getenv returns the value of the specified environment variable.
func (context *Context) Getenv(key string) string {
return context.Environment.Getenv(key)
Expand Down
19 changes: 11 additions & 8 deletions internal/kitsch/modules/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,24 +68,27 @@ func (mod ProjectModule) Execute(context *Context) ModuleResult {
overrides = ProjectConfig{}
}

projectStyleString := overrides.Style
if projectStyleString == "" {
projectStyleString = mod.DefaultProjectStyle
}
if projectStyleString == "" {
projectStyleString = projectInfo.Style
}
projectStyle := context.GetStyle(projectStyleString)

data := projectModuleData{
projectInfo: *projectInfo,
Name: projectInfo.Name,
ToolSymbol: defaultString(overrides.ToolSymbol, projectInfo.ToolSymbol),
ToolVersion: projectInfo.ToolVersion,
PackageManagerSymbol: defaultString(overrides.PackageManagerSymbol, projectInfo.PackageManagerSymbol),
ProjectStyle: overrides.Style,
}

projectStyleString := data.ProjectStyle
if projectStyleString == "" {
projectStyleString = mod.DefaultProjectStyle
ProjectStyle: projectStyleString,
}
projectStyle := context.GetStyle(projectStyleString)

text := ""
if data.ToolVersion != "" {
text = "via " + projectStyle.Apply(data.ToolSymbol+"@"+data.ToolVersion)
text = "w/" + projectStyle.Apply(data.ToolSymbol+"@"+data.ToolVersion)
}

return ModuleResult{DefaultText: text, Data: data}
Expand Down
6 changes: 3 additions & 3 deletions internal/kitsch/modules/project_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ func TestProject(t *testing.T) {
Conditions: condition.Conditions{IfExtensions: []string{"txt"}},
ToolSymbol: "txt",
PackageManagerSymbol: "txt",
ToolVersion: getters.CustomGetter{Type: getters.TypeFile, From: "tool.txt"},
PackageManagerVersion: getters.CustomGetter{Type: getters.TypeFile, From: "pm.txt"},
PackageVersion: getters.CustomGetter{Type: getters.TypeFile, From: "package.txt"},
ToolVersion: []getters.Getter{getters.CustomGetter{Type: getters.TypeFile, From: "tool.txt"}},
PackageManagerVersion: []getters.Getter{getters.CustomGetter{Type: getters.TypeFile, From: "pm.txt"}},
PackageVersion: []getters.Getter{getters.CustomGetter{Type: getters.TypeFile, From: "package.txt"}},
},
}

Expand Down
Loading

0 comments on commit 05a8c69

Please sign in to comment.