mirror of
https://github.com/cheat/cheat.git
synced 2025-09-04 11:08:29 +02:00
Re-wrote from scratch in Golang
- Re-implemented the project in Golang, and deprecated Python entirely - Implemented several new, long-requested features - Refactored cheatsheets into a separate repository
This commit is contained in:
9
internal/cheatpath/cheatpath.go
Normal file
9
internal/cheatpath/cheatpath.go
Normal file
@ -0,0 +1,9 @@
|
||||
package cheatpath
|
||||
|
||||
// Cheatpath encapsulates cheatsheet path information
|
||||
type Cheatpath struct {
|
||||
Name string `yaml:name`
|
||||
Path string `yaml:path`
|
||||
ReadOnly bool `yaml:readonly`
|
||||
Tags []string `yaml:tags`
|
||||
}
|
19
internal/cheatpath/filter.go
Normal file
19
internal/cheatpath/filter.go
Normal file
@ -0,0 +1,19 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Filter filters all cheatpaths that are not named `name`
|
||||
func Filter(paths []Cheatpath, name string) ([]Cheatpath, error) {
|
||||
|
||||
// if a path of the given name exists, return it
|
||||
for _, path := range paths {
|
||||
if path.Name == name {
|
||||
return []Cheatpath{path}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, return an error
|
||||
return []Cheatpath{}, fmt.Errorf("cheatpath does not exist: %s", name)
|
||||
}
|
53
internal/cheatpath/filter_test.go
Normal file
53
internal/cheatpath/filter_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFilterSuccess asserts that the proper cheatpath is returned when the
|
||||
// requested cheatpath exists
|
||||
func TestFilterSuccess(t *testing.T) {
|
||||
|
||||
// init cheatpaths
|
||||
paths := []Cheatpath{
|
||||
Cheatpath{Name: "foo"},
|
||||
Cheatpath{Name: "bar"},
|
||||
Cheatpath{Name: "baz"},
|
||||
}
|
||||
|
||||
// filter the paths
|
||||
paths, err := Filter(paths, "bar")
|
||||
if err != nil {
|
||||
t.Errorf("failed to filter paths: %v", err)
|
||||
}
|
||||
|
||||
// assert that the expected path was returned
|
||||
if len(paths) != 1 {
|
||||
t.Errorf(
|
||||
"failed to return correct path count: want: 1, got: %d",
|
||||
len(paths),
|
||||
)
|
||||
}
|
||||
|
||||
if paths[0].Name != "bar" {
|
||||
t.Errorf("failed to return correct path: want: bar, got: %s", paths[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterFailure asserts that an error is returned when a non-existent
|
||||
// cheatpath is requested
|
||||
func TestFilterFailure(t *testing.T) {
|
||||
|
||||
// init cheatpaths
|
||||
paths := []Cheatpath{
|
||||
Cheatpath{Name: "foo"},
|
||||
Cheatpath{Name: "bar"},
|
||||
Cheatpath{Name: "baz"},
|
||||
}
|
||||
|
||||
// filter the paths
|
||||
paths, err := Filter(paths, "qux")
|
||||
if err == nil {
|
||||
t.Errorf("failed to return an error on non-existent cheatpath")
|
||||
}
|
||||
}
|
18
internal/cheatpath/validate.go
Normal file
18
internal/cheatpath/validate.go
Normal file
@ -0,0 +1,18 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Validate returns an error if the cheatpath is invalid
|
||||
func (c *Cheatpath) Validate() error {
|
||||
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("invalid cheatpath: name must be specified")
|
||||
}
|
||||
if c.Path == "" {
|
||||
return fmt.Errorf("invalid cheatpath: path must be specified")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
56
internal/cheatpath/validate_test.go
Normal file
56
internal/cheatpath/validate_test.go
Normal file
@ -0,0 +1,56 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateValid asserts that valid cheatpaths validate successfully
|
||||
func TestValidateValid(t *testing.T) {
|
||||
|
||||
// initialize a valid cheatpath
|
||||
cheatpath := Cheatpath{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := cheatpath.Validate(); err != nil {
|
||||
t.Errorf("failed to validate valid cheatpath: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMissingName asserts that paths that are missing a name fail to
|
||||
// validate
|
||||
func TestValidateMissingName(t *testing.T) {
|
||||
|
||||
// initialize a valid cheatpath
|
||||
cheatpath := Cheatpath{
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := cheatpath.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate cheatpath without name")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMissingPath asserts that paths that are missing a path fail to
|
||||
// validate
|
||||
func TestValidateMissingPath(t *testing.T) {
|
||||
|
||||
// initialize a valid cheatpath
|
||||
cheatpath := Cheatpath{
|
||||
Name: "foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := cheatpath.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate cheatpath without path")
|
||||
}
|
||||
}
|
24
internal/cheatpath/writeable.go
Normal file
24
internal/cheatpath/writeable.go
Normal file
@ -0,0 +1,24 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Writeable returns a writeable Cheatpath
|
||||
func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
|
||||
|
||||
// iterate backwards over the cheatpaths
|
||||
// NB: we're going backwards because we assume that the most "local"
|
||||
// cheatpath will be specified last in the configs
|
||||
for i := len(cheatpaths) - 1; i >= 0; i-- {
|
||||
|
||||
// if the cheatpath is not read-only, it is writeable, and thus returned
|
||||
if cheatpaths[i].ReadOnly == false {
|
||||
return cheatpaths[i], nil
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// otherwise, return an error
|
||||
return Cheatpath{}, fmt.Errorf("no writeable cheatpaths found")
|
||||
}
|
50
internal/cheatpath/writeable_test.go
Normal file
50
internal/cheatpath/writeable_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package cheatpath
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWriteableOK asserts that Writeable returns the appropriate cheatpath
|
||||
// when a writeable cheatpath exists
|
||||
func TestWriteableOK(t *testing.T) {
|
||||
|
||||
// initialize some cheatpaths
|
||||
cheatpaths := []Cheatpath{
|
||||
Cheatpath{Path: "/foo", ReadOnly: true},
|
||||
Cheatpath{Path: "/bar", ReadOnly: false},
|
||||
Cheatpath{Path: "/baz", ReadOnly: true},
|
||||
}
|
||||
|
||||
// get the writeable cheatpath
|
||||
got, err := Writeable(cheatpaths)
|
||||
|
||||
// assert that no errors were returned
|
||||
if err != nil {
|
||||
t.Errorf("failed to get cheatpath: %v", err)
|
||||
}
|
||||
|
||||
// assert that the path is correct
|
||||
if got.Path != "/bar" {
|
||||
t.Errorf("incorrect cheatpath returned: got: %s", got.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteableOK asserts that Writeable returns an error when no writeable
|
||||
// cheatpaths exist
|
||||
func TestWriteableNotOK(t *testing.T) {
|
||||
|
||||
// initialize some cheatpaths
|
||||
cheatpaths := []Cheatpath{
|
||||
Cheatpath{Path: "/foo", ReadOnly: true},
|
||||
Cheatpath{Path: "/bar", ReadOnly: true},
|
||||
Cheatpath{Path: "/baz", ReadOnly: true},
|
||||
}
|
||||
|
||||
// get the writeable cheatpath
|
||||
_, err := Writeable(cheatpaths)
|
||||
|
||||
// assert that no errors were returned
|
||||
if err == nil {
|
||||
t.Errorf("failed to return an error when no writeable paths found")
|
||||
}
|
||||
}
|
74
internal/config/config.go
Normal file
74
internal/config/config.go
Normal file
@ -0,0 +1,74 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
||||
cp "github.com/cheat/cheat/internal/cheatpath"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
// Config encapsulates configuration parameters
|
||||
type Config struct {
|
||||
Colorize bool `yaml:colorize`
|
||||
Editor string `yaml:editor`
|
||||
Cheatpaths []cp.Cheatpath `yaml:cheatpaths`
|
||||
Style string `yaml:style`
|
||||
Formatter string `yaml:formatter`
|
||||
}
|
||||
|
||||
// New returns a new Config struct
|
||||
func New(opts map[string]interface{}, confPath string) (Config, error) {
|
||||
|
||||
// read the config file
|
||||
buf, err := ioutil.ReadFile(confPath)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("could not read config file: %v", err)
|
||||
}
|
||||
|
||||
// initialize a config object
|
||||
conf := Config{}
|
||||
|
||||
// unmarshal the yaml
|
||||
err = yaml.UnmarshalStrict(buf, &conf)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
|
||||
}
|
||||
|
||||
// expand ~ in config paths
|
||||
for i, cheatpath := range conf.Cheatpaths {
|
||||
|
||||
expanded, err := homedir.Expand(cheatpath.Path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
|
||||
}
|
||||
|
||||
conf.Cheatpaths[i].Path = expanded
|
||||
}
|
||||
|
||||
// if an editor was not provided in the configs, look to envvars
|
||||
if conf.Editor == "" {
|
||||
if os.Getenv("VISUAL") != "" {
|
||||
conf.Editor = os.Getenv("VISUAL")
|
||||
} else if os.Getenv("EDITOR") != "" {
|
||||
conf.Editor = os.Getenv("EDITOR")
|
||||
} else {
|
||||
return Config{}, fmt.Errorf("no editor set")
|
||||
}
|
||||
}
|
||||
|
||||
// if a chroma style was not provided, set a default
|
||||
if conf.Style == "" {
|
||||
conf.Style = "bw"
|
||||
}
|
||||
|
||||
// if a chroma formatter was not provided, set a default
|
||||
if conf.Formatter == "" {
|
||||
conf.Formatter = "terminal16m"
|
||||
}
|
||||
|
||||
return conf, nil
|
||||
}
|
111
internal/config/config_test.go
Normal file
111
internal/config/config_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/mitchellh/go-homedir"
|
||||
|
||||
"github.com/cheat/cheat/internal/cheatpath"
|
||||
"github.com/cheat/cheat/internal/mock"
|
||||
)
|
||||
|
||||
// TestConfig asserts that the configs are loaded correctly
|
||||
func TestConfigSuccessful(t *testing.T) {
|
||||
|
||||
// initialize a config
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"))
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse config file: %v", err)
|
||||
}
|
||||
|
||||
// assert that the expected values were returned
|
||||
if conf.Editor != "vim" {
|
||||
t.Errorf("failed to set editor: want: vim, got: %s", conf.Editor)
|
||||
}
|
||||
if !conf.Colorize {
|
||||
t.Errorf("failed to set colorize: want: true, got: %t", conf.Colorize)
|
||||
}
|
||||
|
||||
// get the user's home directory (with ~ expanded)
|
||||
home, err := homedir.Dir()
|
||||
if err != nil {
|
||||
t.Errorf("failed to get homedir: %v", err)
|
||||
}
|
||||
|
||||
// assert that the cheatpaths are correct
|
||||
want := []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Path: filepath.Join(home, ".dotfiles/cheat/community"),
|
||||
ReadOnly: true,
|
||||
Tags: []string{"community"},
|
||||
},
|
||||
cheatpath.Cheatpath{
|
||||
Path: filepath.Join(home, ".dotfiles/cheat/work"),
|
||||
ReadOnly: false,
|
||||
Tags: []string{"work"},
|
||||
},
|
||||
cheatpath.Cheatpath{
|
||||
Path: filepath.Join(home, ".dotfiles/cheat/personal"),
|
||||
ReadOnly: false,
|
||||
Tags: []string{"personal"},
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(conf.Cheatpaths, want) {
|
||||
t.Errorf(
|
||||
"failed to return expected results: want:\n%s, got:\n%s",
|
||||
spew.Sdump(want),
|
||||
spew.Sdump(conf.Cheatpaths),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFailure asserts that an error is returned if the config file
|
||||
// cannot be read.
|
||||
func TestConfigFailure(t *testing.T) {
|
||||
|
||||
// attempt to read a non-existent config file
|
||||
_, err := New(map[string]interface{}{}, "/does-not-exit")
|
||||
if err == nil {
|
||||
t.Errorf("failed to error on unreadable config")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyEditor asserts that envvars are respected if an editor is not
|
||||
// specified in the configs
|
||||
func TestEmptyEditor(t *testing.T) {
|
||||
|
||||
// clear the environment variables
|
||||
os.Setenv("VISUAL", "")
|
||||
os.Setenv("EDITOR", "")
|
||||
|
||||
// initialize a config
|
||||
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"))
|
||||
if err == nil {
|
||||
t.Errorf("failed to return an error on empty editor")
|
||||
}
|
||||
|
||||
// set editor, and assert that it is respected
|
||||
os.Setenv("EDITOR", "foo")
|
||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"))
|
||||
if err != nil {
|
||||
t.Errorf("failed to init configs: %v", err)
|
||||
}
|
||||
if conf.Editor != "foo" {
|
||||
t.Errorf("failed to respect editor: want: foo, got: %s", conf.Editor)
|
||||
}
|
||||
|
||||
// set visual, and assert that it overrides editor
|
||||
os.Setenv("VISUAL", "bar")
|
||||
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"))
|
||||
if err != nil {
|
||||
t.Errorf("failed to init configs: %v", err)
|
||||
}
|
||||
if conf.Editor != "bar" {
|
||||
t.Errorf("failed to respect editor: want: bar, got: %s", conf.Editor)
|
||||
}
|
||||
}
|
68
internal/config/path.go
Normal file
68
internal/config/path.go
Normal file
@ -0,0 +1,68 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/mitchellh/go-homedir"
|
||||
)
|
||||
|
||||
// Path returns the config file path
|
||||
func Path(sys string) (string, error) {
|
||||
|
||||
var paths []string
|
||||
|
||||
// if CHEAT_CONFIG_PATH is set, return it
|
||||
if os.Getenv("CHEAT_CONFIG_PATH") != "" {
|
||||
|
||||
// expand ~
|
||||
expanded, err := homedir.Expand(os.Getenv("CHEAT_CONFIG_PATH"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to expand ~: %v", err)
|
||||
}
|
||||
|
||||
return expanded, nil
|
||||
|
||||
// OSX config paths
|
||||
} else if sys == "darwin" {
|
||||
|
||||
paths = []string{
|
||||
path.Join(os.Getenv("XDG_CONFIG_HOME"), "/cheat/conf.yml"),
|
||||
path.Join(os.Getenv("HOME"), ".config/cheat/conf.yml"),
|
||||
path.Join(os.Getenv("HOME"), ".cheat/conf.yml"),
|
||||
}
|
||||
|
||||
// Linux config paths
|
||||
} else if sys == "linux" {
|
||||
|
||||
paths = []string{
|
||||
path.Join(os.Getenv("XDG_CONFIG_HOME"), "/cheat/conf.yml"),
|
||||
path.Join(os.Getenv("HOME"), ".config/cheat/conf.yml"),
|
||||
path.Join(os.Getenv("HOME"), ".cheat/conf.yml"),
|
||||
"/etc/cheat/conf.yml",
|
||||
}
|
||||
|
||||
// Windows config paths
|
||||
} else if sys == "windows" {
|
||||
|
||||
paths = []string{
|
||||
fmt.Sprintf("%s/cheat/conf.yml", os.Getenv("APPDATA")),
|
||||
fmt.Sprintf("%s/cheat/conf.yml", os.Getenv("PROGRAMDATA")),
|
||||
}
|
||||
|
||||
// Unsupported platforms
|
||||
} else {
|
||||
return "", fmt.Errorf("unsupported os: %s", sys)
|
||||
}
|
||||
|
||||
// check if the config file exists on any paths
|
||||
for _, p := range paths {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
|
||||
// we can't find the config file if we make it this far
|
||||
return "", fmt.Errorf("could not locate config file")
|
||||
}
|
64
internal/config/validate.go
Normal file
64
internal/config/validate.go
Normal file
@ -0,0 +1,64 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Validate returns an error if the config is invalid
|
||||
func (c *Config) Validate() error {
|
||||
|
||||
// assert that an editor was specified
|
||||
if c.Editor == "" {
|
||||
return fmt.Errorf("config error: editor unspecified")
|
||||
}
|
||||
|
||||
// assert that at least one cheatpath was specified
|
||||
if len(c.Cheatpaths) == 0 {
|
||||
return fmt.Errorf("config error: no cheatpaths specified")
|
||||
}
|
||||
|
||||
// assert that each path and name is unique
|
||||
names := make(map[string]bool)
|
||||
paths := make(map[string]bool)
|
||||
|
||||
// assert that each cheatpath is valid
|
||||
for _, cheatpath := range c.Cheatpaths {
|
||||
|
||||
// assert that the cheatpath is valid
|
||||
if err := cheatpath.Validate(); err != nil {
|
||||
return fmt.Errorf("config error: %v", err)
|
||||
}
|
||||
|
||||
// assert that the name is unique
|
||||
if _, ok := names[cheatpath.Name]; ok {
|
||||
return fmt.Errorf(
|
||||
"config error: cheatpath name is not unique: %s",
|
||||
cheatpath.Name,
|
||||
)
|
||||
}
|
||||
names[cheatpath.Name] = true
|
||||
|
||||
// assert that the path is unique
|
||||
if _, ok := paths[cheatpath.Path]; ok {
|
||||
return fmt.Errorf(
|
||||
"config error: cheatpath path is not unique: %s",
|
||||
cheatpath.Path,
|
||||
)
|
||||
}
|
||||
paths[cheatpath.Path] = true
|
||||
}
|
||||
|
||||
// TODO: assert valid styles?
|
||||
|
||||
// assert that the formatter is valid
|
||||
formatters := map[string]bool{
|
||||
"terminal": true,
|
||||
"terminal256": true,
|
||||
"terminal16m": true,
|
||||
}
|
||||
if _, ok := formatters[c.Formatter]; !ok {
|
||||
return fmt.Errorf("config error: formatter is invalid: %s", c.Formatter)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
150
internal/config/validate_test.go
Normal file
150
internal/config/validate_test.go
Normal file
@ -0,0 +1,150 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/cheatpath"
|
||||
)
|
||||
|
||||
// TestValidateCorrect asserts that valid configs are validated successfully
|
||||
func TestValidateCorrect(t *testing.T) {
|
||||
|
||||
// mock a config
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "terminal16m",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := conf.Validate(); err != nil {
|
||||
t.Errorf("failed to validate valid config: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidateMissingEditor asserts that configs with unspecified editors
|
||||
// are invalidated
|
||||
func TestInvalidateMissingEditor(t *testing.T) {
|
||||
|
||||
// mock a config
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Formatter: "terminal16m",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := conf.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate config with unspecified editor")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidateMissingCheatpaths asserts that configs without cheatpaths are
|
||||
// invalidated
|
||||
func TestInvalidateMissingCheatpaths(t *testing.T) {
|
||||
|
||||
// mock a config
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "terminal16m",
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := conf.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate config without cheatpaths")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingInvalidFormatters asserts that configs which contain invalid
|
||||
// formatters are invalidated
|
||||
func TestMissingInvalidFormatters(t *testing.T) {
|
||||
|
||||
// mock a config
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := conf.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate config without formatter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidateDuplicateCheatpathNames asserts that configs which contain
|
||||
// cheatpaths with duplcated names are invalidated
|
||||
func TestInvalidateDuplicateCheatpathNames(t *testing.T) {
|
||||
|
||||
// mock a config
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "terminal16m",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
cheatpath.Cheatpath{
|
||||
Name: "foo",
|
||||
Path: "/bar",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := conf.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate config with cheatpaths with duplicate names")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidateDuplicateCheatpathPaths asserts that configs which contain
|
||||
// cheatpaths with duplcated paths are invalidated
|
||||
func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
|
||||
|
||||
// mock a config
|
||||
conf := Config{
|
||||
Colorize: true,
|
||||
Editor: "vim",
|
||||
Formatter: "terminal16m",
|
||||
Cheatpaths: []cheatpath.Cheatpath{
|
||||
cheatpath.Cheatpath{
|
||||
Name: "foo",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
cheatpath.Cheatpath{
|
||||
Name: "bar",
|
||||
Path: "/foo",
|
||||
ReadOnly: false,
|
||||
Tags: []string{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// assert that no errors are returned
|
||||
if err := conf.Validate(); err == nil {
|
||||
t.Errorf("failed to invalidate config with cheatpaths with duplicate paths")
|
||||
}
|
||||
}
|
29
internal/mock/path.go
Normal file
29
internal/mock/path.go
Normal file
@ -0,0 +1,29 @@
|
||||
package mock
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Path returns the absolute path to the specified mock file.
|
||||
func Path(filename string) string {
|
||||
|
||||
// determine the path of this file during runtime
|
||||
_, thisfile, _, _ := runtime.Caller(0)
|
||||
|
||||
// compute the config path
|
||||
file, err := filepath.Abs(
|
||||
path.Join(
|
||||
filepath.Dir(thisfile),
|
||||
"../../mocks",
|
||||
filename,
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to resolve config path: %v", err))
|
||||
}
|
||||
|
||||
return file
|
||||
}
|
51
internal/sheet/copy.go
Normal file
51
internal/sheet/copy.go
Normal file
@ -0,0 +1,51 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
// Copy copies a cheatsheet to a new location
|
||||
func (s *Sheet) Copy(dest string) error {
|
||||
|
||||
// NB: while the `infile` has already been loaded and parsed into a `sheet`
|
||||
// struct, we're going to read it again here. This is a bit wasteful, but
|
||||
// necessary if we want the "raw" file contents (including the front-matter).
|
||||
// This is because the frontmatter is parsed and then discarded when the file
|
||||
// is loaded via `sheets.Load`.
|
||||
infile, err := os.Open(s.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open cheatsheet: %s, %v", s.Path, err)
|
||||
}
|
||||
defer infile.Close()
|
||||
|
||||
// create any necessary subdirectories
|
||||
dirs := path.Dir(dest)
|
||||
if dirs != "." {
|
||||
if err := os.MkdirAll(dirs, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %s, %v", dirs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// create the outfile
|
||||
outfile, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create outfile: %s, %v", dest, err)
|
||||
}
|
||||
defer outfile.Close()
|
||||
|
||||
// copy file contents
|
||||
_, err = io.Copy(outfile, infile)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to copy file: infile: %s, outfile: %s, err: %v",
|
||||
s.Path,
|
||||
dest,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
102
internal/sheet/copy_test.go
Normal file
102
internal/sheet/copy_test.go
Normal file
@ -0,0 +1,102 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCopyFlat asserts that Copy correctly copies files at a single level of
|
||||
// depth
|
||||
func TestCopyFlat(t *testing.T) {
|
||||
|
||||
// mock a cheatsheet file
|
||||
text := "this is the cheatsheet text"
|
||||
src, err := ioutil.TempFile("", "foo-src")
|
||||
if err != nil {
|
||||
t.Errorf("failed to mock cheatsheet: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
defer os.Remove(src.Name())
|
||||
|
||||
if _, err := src.WriteString(text); err != nil {
|
||||
t.Errorf("failed to write to mock cheatsheet: %v", err)
|
||||
}
|
||||
|
||||
// mock a cheatsheet struct
|
||||
sheet, err := New("foo", src.Name(), []string{}, false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to init cheatsheet: %v", err)
|
||||
}
|
||||
|
||||
// compute the outfile's path
|
||||
outpath := path.Join(os.TempDir(), sheet.Title)
|
||||
defer os.Remove(outpath)
|
||||
|
||||
// attempt to copy the cheatsheet
|
||||
err = sheet.Copy(outpath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to copy cheatsheet: %v", err)
|
||||
}
|
||||
|
||||
// assert that the destination file contains the correct text
|
||||
got, err := ioutil.ReadFile(outpath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read destination file: %v", err)
|
||||
}
|
||||
if string(got) != text {
|
||||
t.Errorf(
|
||||
"destination file contained wrong text: want: '%s', got: '%s'",
|
||||
text,
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCopyDeep asserts that Copy correctly copies files at several levels of
|
||||
// depth
|
||||
func TestCopyDeep(t *testing.T) {
|
||||
|
||||
// mock a cheatsheet file
|
||||
text := "this is the cheatsheet text"
|
||||
src, err := ioutil.TempFile("", "foo-src")
|
||||
if err != nil {
|
||||
t.Errorf("failed to mock cheatsheet: %v", err)
|
||||
}
|
||||
defer src.Close()
|
||||
defer os.Remove(src.Name())
|
||||
|
||||
if _, err := src.WriteString(text); err != nil {
|
||||
t.Errorf("failed to write to mock cheatsheet: %v", err)
|
||||
}
|
||||
|
||||
// mock a cheatsheet struct
|
||||
sheet, err := New("/cheat-tests/alpha/bravo/foo", src.Name(), []string{}, false)
|
||||
if err != nil {
|
||||
t.Errorf("failed to init cheatsheet: %v", err)
|
||||
}
|
||||
|
||||
// compute the outfile's path
|
||||
outpath := path.Join(os.TempDir(), sheet.Title)
|
||||
defer os.RemoveAll(path.Join(os.TempDir(), "cheat-tests"))
|
||||
|
||||
// attempt to copy the cheatsheet
|
||||
err = sheet.Copy(outpath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to copy cheatsheet: %v", err)
|
||||
}
|
||||
|
||||
// assert that the destination file contains the correct text
|
||||
got, err := ioutil.ReadFile(outpath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read destination file: %v", err)
|
||||
}
|
||||
if string(got) != text {
|
||||
t.Errorf(
|
||||
"destination file contained wrong text: want: '%s', got: '%s'",
|
||||
text,
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
7
internal/sheet/match.go
Normal file
7
internal/sheet/match.go
Normal file
@ -0,0 +1,7 @@
|
||||
package sheet
|
||||
|
||||
// Match encapsulates search matches within cheatsheets
|
||||
type Match struct {
|
||||
Line int
|
||||
Text string
|
||||
}
|
45
internal/sheet/search.go
Normal file
45
internal/sheet/search.go
Normal file
@ -0,0 +1,45 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/mgutz/ansi"
|
||||
)
|
||||
|
||||
// Search searches for regexp matches in a cheatsheet's text, and optionally
|
||||
// colorizes matching strings.
|
||||
func (s *Sheet) Search(reg *regexp.Regexp, colorize bool) []Match {
|
||||
|
||||
// record matches
|
||||
matches := []Match{}
|
||||
|
||||
// search through the cheatsheet's text line by line
|
||||
// TODO: searching line-by-line is surely the "naive" approach. Revisit this
|
||||
// later with an eye for performance improvements.
|
||||
for linenum, line := range strings.Split(s.Text, "\n") {
|
||||
|
||||
// exit early if the line doesn't match the regex
|
||||
if !reg.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
|
||||
// init the match
|
||||
m := Match{
|
||||
Line: linenum + 1,
|
||||
Text: strings.TrimSpace(line),
|
||||
}
|
||||
|
||||
// colorize the matching text if so configured
|
||||
if colorize {
|
||||
m.Text = reg.ReplaceAllStringFunc(m.Text, func(matched string) string {
|
||||
return ansi.Color(matched, "red+b")
|
||||
})
|
||||
}
|
||||
|
||||
// record the match
|
||||
matches = append(matches, m)
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
185
internal/sheet/search_test.go
Normal file
185
internal/sheet/search_test.go
Normal file
@ -0,0 +1,185 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
)
|
||||
|
||||
// TestSearchNoMatch ensures that the expected output is returned when no
|
||||
// matches are found
|
||||
func TestSearchNoMatch(t *testing.T) {
|
||||
|
||||
// mock a cheatsheet
|
||||
sheet := Sheet{
|
||||
Text: "The quick brown fox\njumped over\nthe lazy dog.",
|
||||
}
|
||||
|
||||
// compile the search regex
|
||||
reg, err := regexp.Compile("(?i)foo")
|
||||
if err != nil {
|
||||
t.Errorf("failed to compile regex: %v", err)
|
||||
}
|
||||
|
||||
// search the sheet
|
||||
matches := sheet.Search(reg, false)
|
||||
|
||||
// assert that no matches were found
|
||||
if len(matches) != 0 {
|
||||
t.Errorf("failure: expected no matches: got: %s", spew.Sdump(matches))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchSingleMatchNoColor asserts that the expected output is returned
|
||||
// when a single match is returned, and no colorization is applied.
|
||||
func TestSearchSingleMatchNoColor(t *testing.T) {
|
||||
|
||||
// mock a cheatsheet
|
||||
sheet := Sheet{
|
||||
Text: "The quick brown fox\njumped over\nthe lazy dog.",
|
||||
}
|
||||
|
||||
// compile the search regex
|
||||
reg, err := regexp.Compile("(?i)fox")
|
||||
if err != nil {
|
||||
t.Errorf("failed to compile regex: %v", err)
|
||||
}
|
||||
|
||||
// search the sheet
|
||||
matches := sheet.Search(reg, false)
|
||||
|
||||
// specify the expected results
|
||||
want := []Match{
|
||||
Match{
|
||||
Line: 1,
|
||||
Text: "The quick brown fox",
|
||||
},
|
||||
}
|
||||
|
||||
// assert that the correct matches were returned
|
||||
if !reflect.DeepEqual(matches, want) {
|
||||
t.Errorf(
|
||||
"failed to return expected matches: want:\n%s, got:\n%s",
|
||||
spew.Sdump(want),
|
||||
spew.Sdump(matches),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchSingleMatchColorized asserts that the expected output is returned
|
||||
// when a single match is returned, and colorization is applied
|
||||
func TestSearchSingleMatchColorized(t *testing.T) {
|
||||
|
||||
// mock a cheatsheet
|
||||
sheet := Sheet{
|
||||
Text: "The quick brown fox\njumped over\nthe lazy dog.",
|
||||
}
|
||||
|
||||
// compile the search regex
|
||||
reg, err := regexp.Compile("(?i)fox")
|
||||
if err != nil {
|
||||
t.Errorf("failed to compile regex: %v", err)
|
||||
}
|
||||
|
||||
// search the sheet
|
||||
matches := sheet.Search(reg, true)
|
||||
|
||||
// specify the expected results
|
||||
want := []Match{
|
||||
Match{
|
||||
Line: 1,
|
||||
Text: "The quick brown \x1b[1;31mfox\x1b[0m",
|
||||
},
|
||||
}
|
||||
|
||||
// assert that the correct matches were returned
|
||||
if !reflect.DeepEqual(matches, want) {
|
||||
t.Errorf(
|
||||
"failed to return expected matches: want:\n%s, got:\n%s",
|
||||
spew.Sdump(want),
|
||||
spew.Sdump(matches),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchMultiMatchNoColor asserts that the expected output is returned
|
||||
// when a multiple matches are returned, and no colorization is applied
|
||||
func TestSearchMultiMatchNoColor(t *testing.T) {
|
||||
|
||||
// mock a cheatsheet
|
||||
sheet := Sheet{
|
||||
Text: "The quick brown fox\njumped over\nthe lazy dog.",
|
||||
}
|
||||
|
||||
// compile the search regex
|
||||
reg, err := regexp.Compile("(?i)the")
|
||||
if err != nil {
|
||||
t.Errorf("failed to compile regex: %v", err)
|
||||
}
|
||||
|
||||
// search the sheet
|
||||
matches := sheet.Search(reg, false)
|
||||
|
||||
// specify the expected results
|
||||
want := []Match{
|
||||
Match{
|
||||
Line: 1,
|
||||
Text: "The quick brown fox",
|
||||
},
|
||||
Match{
|
||||
Line: 3,
|
||||
Text: "the lazy dog.",
|
||||
},
|
||||
}
|
||||
|
||||
// assert that the correct matches were returned
|
||||
if !reflect.DeepEqual(matches, want) {
|
||||
t.Errorf(
|
||||
"failed to return expected matches: want:\n%s, got:\n%s",
|
||||
spew.Sdump(want),
|
||||
spew.Sdump(matches),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchMultiMatchColorized asserts that the expected output is returned
|
||||
// when a multiple matches are returned, and colorization is applied
|
||||
func TestSearchMultiMatchColorized(t *testing.T) {
|
||||
|
||||
// mock a cheatsheet
|
||||
sheet := Sheet{
|
||||
Text: "The quick brown fox\njumped over\nthe lazy dog.",
|
||||
}
|
||||
|
||||
// compile the search regex
|
||||
reg, err := regexp.Compile("(?i)the")
|
||||
if err != nil {
|
||||
t.Errorf("failed to compile regex: %v", err)
|
||||
}
|
||||
|
||||
// search the sheet
|
||||
matches := sheet.Search(reg, true)
|
||||
|
||||
// specify the expected results
|
||||
want := []Match{
|
||||
Match{
|
||||
Line: 1,
|
||||
Text: "\x1b[1;31mThe\x1b[0m quick brown fox",
|
||||
},
|
||||
Match{
|
||||
Line: 3,
|
||||
Text: "\x1b[1;31mthe\x1b[0m lazy dog.",
|
||||
},
|
||||
}
|
||||
|
||||
// assert that the correct matches were returned
|
||||
if !reflect.DeepEqual(matches, want) {
|
||||
t.Errorf(
|
||||
"failed to return expected matches: want:\n%s, got:\n%s",
|
||||
spew.Sdump(want),
|
||||
spew.Sdump(matches),
|
||||
)
|
||||
}
|
||||
}
|
64
internal/sheet/sheet.go
Normal file
64
internal/sheet/sheet.go
Normal file
@ -0,0 +1,64 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tj/front"
|
||||
)
|
||||
|
||||
// frontmatter is an un-exported helper struct used in parsing cheatsheets
|
||||
type frontmatter struct {
|
||||
Tags []string
|
||||
Syntax string
|
||||
}
|
||||
|
||||
// Sheet encapsulates sheet information
|
||||
type Sheet struct {
|
||||
Title string
|
||||
Path string
|
||||
Text string
|
||||
Tags []string
|
||||
Syntax string
|
||||
ReadOnly bool
|
||||
}
|
||||
|
||||
// New initializes a new Sheet
|
||||
func New(
|
||||
title string,
|
||||
path string,
|
||||
tags []string,
|
||||
readOnly bool,
|
||||
) (Sheet, error) {
|
||||
|
||||
// read the cheatsheet file
|
||||
markdown, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
return Sheet{}, fmt.Errorf("failed to read file: %s, %v", path, err)
|
||||
}
|
||||
|
||||
// parse the front-matter
|
||||
var fm frontmatter
|
||||
text, err := front.Unmarshal(markdown, &fm)
|
||||
if err != nil {
|
||||
return Sheet{}, fmt.Errorf("failed to parse front-matter: %v", err)
|
||||
}
|
||||
|
||||
// merge the sheet-specific tags into the cheatpath tags
|
||||
tags = append(tags, fm.Tags...)
|
||||
|
||||
// sort strings so they pretty-print nicely
|
||||
sort.Strings(tags)
|
||||
|
||||
// initialize and return a sheet
|
||||
return Sheet{
|
||||
Title: title,
|
||||
Path: path,
|
||||
Text: strings.TrimSpace(string(text)) + "\n",
|
||||
Tags: tags,
|
||||
Syntax: fm.Syntax,
|
||||
ReadOnly: readOnly,
|
||||
}, nil
|
||||
}
|
71
internal/sheet/sheet_test.go
Normal file
71
internal/sheet/sheet_test.go
Normal file
@ -0,0 +1,71 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/mock"
|
||||
)
|
||||
|
||||
// TestSheetSuccess asserts that sheets initialize properly
|
||||
func TestSheetSuccess(t *testing.T) {
|
||||
|
||||
// initialize a sheet
|
||||
sheet, err := New(
|
||||
"foo",
|
||||
mock.Path("sheet/foo"),
|
||||
[]string{"alpha", "bravo"},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Errorf("failed to load sheet: %v", err)
|
||||
}
|
||||
|
||||
// assert that the sheet loaded correctly
|
||||
if sheet.Title != "foo" {
|
||||
t.Errorf("failed to init title: want: foo, got: %s", sheet.Title)
|
||||
}
|
||||
|
||||
if sheet.Path != mock.Path("sheet/foo") {
|
||||
t.Errorf(
|
||||
"failed to init path: want: %s, got: %s",
|
||||
mock.Path("sheet/foo"),
|
||||
sheet.Path,
|
||||
)
|
||||
}
|
||||
|
||||
wantText := "# To foo the bar:\n foo bar\n"
|
||||
if sheet.Text != wantText {
|
||||
t.Errorf("failed to init text: want: %s, got: %s", wantText, sheet.Text)
|
||||
}
|
||||
|
||||
// NB: tags should sort alphabetically
|
||||
wantTags := []string{"alpha", "bar", "baz", "bravo", "foo"}
|
||||
if !reflect.DeepEqual(sheet.Tags, wantTags) {
|
||||
t.Errorf("failed to init tags: want: %v, got: %v", wantTags, sheet.Tags)
|
||||
}
|
||||
|
||||
if sheet.Syntax != "sh" {
|
||||
t.Errorf("failed to init syntax: want: sh, got: %s", sheet.Syntax)
|
||||
}
|
||||
|
||||
if sheet.ReadOnly != false {
|
||||
t.Errorf("failed to init readonly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSheetFailure asserts that an error is returned if the sheet cannot be
|
||||
// read
|
||||
func TestSheetFailure(t *testing.T) {
|
||||
|
||||
// initialize a sheet
|
||||
_, err := New(
|
||||
"foo",
|
||||
mock.Path("/does-not-exist"),
|
||||
[]string{"alpha", "bravo"},
|
||||
false,
|
||||
)
|
||||
if err == nil {
|
||||
t.Errorf("failed to return an error on unreadable sheet")
|
||||
}
|
||||
}
|
15
internal/sheet/tagged.go
Normal file
15
internal/sheet/tagged.go
Normal file
@ -0,0 +1,15 @@
|
||||
package sheet
|
||||
|
||||
// Tagged returns true if a sheet was tagged with `needle`
|
||||
func (s *Sheet) Tagged(needle string) bool {
|
||||
|
||||
// if any of the tags match `needle`, return `true`
|
||||
for _, tag := range s.Tags {
|
||||
if tag == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// otherwise, return `false`
|
||||
return false
|
||||
}
|
26
internal/sheet/tagged_test.go
Normal file
26
internal/sheet/tagged_test.go
Normal file
@ -0,0 +1,26 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTagged ensures that tags are properly recognized as being absent or
|
||||
// present
|
||||
func TestTagged(t *testing.T) {
|
||||
|
||||
// initialize a cheatsheet
|
||||
tags := []string{"foo", "bar", "baz"}
|
||||
sheet := Sheet{Tags: tags}
|
||||
|
||||
// assert that set tags are recognized as set
|
||||
for _, tag := range tags {
|
||||
if sheet.Tagged(tag) == false {
|
||||
t.Errorf("failed to recognize tag: %s", tag)
|
||||
}
|
||||
}
|
||||
|
||||
// assert that unset tags are recognized as unset
|
||||
if sheet.Tagged("qux") {
|
||||
t.Errorf("failed to recognize absent tag")
|
||||
}
|
||||
}
|
23
internal/sheets/consolidate.go
Normal file
23
internal/sheets/consolidate.go
Normal file
@ -0,0 +1,23 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// Consolidate applies cheatsheet "overrides", resolving title conflicts that
|
||||
// exist among cheatpaths by preferring more local cheatsheets over less local
|
||||
// cheatsheets.
|
||||
func Consolidate(
|
||||
cheatpaths []map[string]sheet.Sheet,
|
||||
) map[string]sheet.Sheet {
|
||||
|
||||
consolidated := make(map[string]sheet.Sheet)
|
||||
|
||||
for _, cheatpath := range cheatpaths {
|
||||
for title, sheet := range cheatpath {
|
||||
consolidated[title] = sheet
|
||||
}
|
||||
}
|
||||
|
||||
return consolidated
|
||||
}
|
50
internal/sheets/consolidate_test.go
Normal file
50
internal/sheets/consolidate_test.go
Normal file
@ -0,0 +1,50 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// TestConsolidate asserts that cheatsheets are properly consolidated
|
||||
func TestConsolidate(t *testing.T) {
|
||||
|
||||
// mock cheatsheets available on multiple cheatpaths
|
||||
cheatpaths := []map[string]sheet.Sheet{
|
||||
|
||||
// mock community cheatsheets
|
||||
map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo", Path: "community/foo"},
|
||||
"bar": sheet.Sheet{Title: "bar", Path: "community/bar"},
|
||||
},
|
||||
|
||||
// mock local cheatsheets
|
||||
map[string]sheet.Sheet{
|
||||
"bar": sheet.Sheet{Title: "bar", Path: "local/bar"},
|
||||
"baz": sheet.Sheet{Title: "baz", Path: "local/baz"},
|
||||
},
|
||||
}
|
||||
|
||||
// consolidate the cheatsheets
|
||||
consolidated := Consolidate(cheatpaths)
|
||||
|
||||
// specify the expected output
|
||||
want := map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo", Path: "community/foo"},
|
||||
"bar": sheet.Sheet{Title: "bar", Path: "local/bar"},
|
||||
"baz": sheet.Sheet{Title: "baz", Path: "local/baz"},
|
||||
}
|
||||
|
||||
// assert that the cheatsheets properly consolidated
|
||||
if !reflect.DeepEqual(consolidated, want) {
|
||||
t.Errorf(
|
||||
"failed to consolidate cheatpaths: want:\n%s, got:\n%s",
|
||||
spew.Sdump(want),
|
||||
spew.Sdump(consolidated),
|
||||
)
|
||||
}
|
||||
|
||||
}
|
53
internal/sheets/filter.go
Normal file
53
internal/sheets/filter.go
Normal file
@ -0,0 +1,53 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// Filter filters cheatsheets that do not match `tag(s)`
|
||||
func Filter(
|
||||
cheatpaths []map[string]sheet.Sheet,
|
||||
tags []string,
|
||||
) []map[string]sheet.Sheet {
|
||||
|
||||
// buffer a map of filtered cheatsheets
|
||||
filtered := make([]map[string]sheet.Sheet, 0, len(cheatpaths))
|
||||
|
||||
// iterate over each cheatpath
|
||||
for _, cheatsheets := range cheatpaths {
|
||||
|
||||
// create a map of cheatsheets for each cheatpath. The filtering will be
|
||||
// applied to each cheatpath individually.
|
||||
pathFiltered := make(map[string]sheet.Sheet)
|
||||
|
||||
// iterate over each cheatsheet that exists on each cheatpath
|
||||
for title, sheet := range cheatsheets {
|
||||
|
||||
// assume that the sheet should be kept (ie, should not be filtered)
|
||||
keep := true
|
||||
|
||||
// iterate over each tag. If the sheet does not match *all* tags, filter
|
||||
// it out.
|
||||
for _, tag := range tags {
|
||||
if !sheet.Tagged(strings.TrimSpace(tag)) {
|
||||
keep = false
|
||||
}
|
||||
}
|
||||
|
||||
// if the sheet does match all tags, it passes the filter
|
||||
if keep {
|
||||
pathFiltered[title] = sheet
|
||||
}
|
||||
}
|
||||
|
||||
// the sheets on this individual cheatpath have now been filtered. Now,
|
||||
// store those alongside the sheets on the other cheatpaths that also made
|
||||
// it passed the filter.
|
||||
filtered = append(filtered, pathFiltered)
|
||||
}
|
||||
|
||||
// return the filtered cheatsheets on all paths
|
||||
return filtered
|
||||
}
|
94
internal/sheets/filter_test.go
Normal file
94
internal/sheets/filter_test.go
Normal file
@ -0,0 +1,94 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// TestFilterSingleTag asserts that Filter properly filters results when passed
|
||||
// a single tag
|
||||
func TestFilterSingleTag(t *testing.T) {
|
||||
|
||||
// mock cheatsheets available on multiple cheatpaths
|
||||
cheatpaths := []map[string]sheet.Sheet{
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
|
||||
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
|
||||
},
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
|
||||
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
|
||||
},
|
||||
}
|
||||
|
||||
// filter the cheatsheets
|
||||
filtered := Filter(cheatpaths, []string{"bravo"})
|
||||
|
||||
// assert that the expect results were returned
|
||||
want := []map[string]sheet.Sheet{
|
||||
map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
|
||||
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
|
||||
},
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
|
||||
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(filtered, want) {
|
||||
t.Errorf(
|
||||
"failed to return expected results: want:\n%s, got:\n%s",
|
||||
spew.Sdump(want),
|
||||
spew.Sdump(filtered),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFilterSingleTag asserts that Filter properly filters results when passed
|
||||
// multiple tags
|
||||
func TestFilterMultiTag(t *testing.T) {
|
||||
|
||||
// mock cheatsheets available on multiple cheatpaths
|
||||
cheatpaths := []map[string]sheet.Sheet{
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
|
||||
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
|
||||
},
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
|
||||
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
|
||||
},
|
||||
}
|
||||
|
||||
// filter the cheatsheets
|
||||
filtered := Filter(cheatpaths, []string{"alpha", "bravo"})
|
||||
|
||||
// assert that the expect results were returned
|
||||
want := []map[string]sheet.Sheet{
|
||||
map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
|
||||
},
|
||||
|
||||
map[string]sheet.Sheet{
|
||||
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
|
||||
},
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(filtered, want) {
|
||||
t.Errorf(
|
||||
"failed to return expected results: want:\n%s, got:\n%s",
|
||||
spew.Sdump(want),
|
||||
spew.Sdump(filtered),
|
||||
)
|
||||
}
|
||||
}
|
77
internal/sheets/load.go
Normal file
77
internal/sheets/load.go
Normal file
@ -0,0 +1,77 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
cp "github.com/cheat/cheat/internal/cheatpath"
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// Load produces a map of cheatsheet titles to filesystem paths
|
||||
func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
|
||||
|
||||
// create a slice of maps of sheets. This structure will store all sheets
|
||||
// that are associated with each cheatpath.
|
||||
sheets := make([]map[string]sheet.Sheet, len(cheatpaths))
|
||||
|
||||
// iterate over each cheatpath
|
||||
for _, cheatpath := range cheatpaths {
|
||||
|
||||
// vivify the map of cheatsheets on this specific cheatpath
|
||||
pathsheets := make(map[string]sheet.Sheet)
|
||||
|
||||
// recursively iterate over the cheatpath, and load each cheatsheet
|
||||
// encountered along the way
|
||||
err := filepath.Walk(
|
||||
cheatpath.Path, func(
|
||||
path string,
|
||||
info os.FileInfo,
|
||||
err error) error {
|
||||
|
||||
// fail if an error occurred while walking the directory
|
||||
if err != nil {
|
||||
return fmt.Errorf("error walking path: %v", err)
|
||||
}
|
||||
|
||||
// don't register directories as cheatsheets
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// calculate the cheatsheet's "title" (the phrase with which it may be
|
||||
// accessed. Eg: `cheat tar` - `tar` is the title)
|
||||
title := strings.TrimPrefix(
|
||||
strings.TrimPrefix(path, cheatpath.Path),
|
||||
"/",
|
||||
)
|
||||
|
||||
// ignore dotfiles. Otherwise, we'll likely load .git/*
|
||||
if strings.HasPrefix(title, ".") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parse the cheatsheet file into a `sheet` struct
|
||||
s, err := sheet.New(title, path, cheatpath.Tags, cheatpath.ReadOnly)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create sheet: %v", err)
|
||||
}
|
||||
|
||||
// register the cheatsheet on its cheatpath, keyed by its title
|
||||
pathsheets[title] = s
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return sheets, fmt.Errorf("failed to load cheatsheets: %v", err)
|
||||
}
|
||||
|
||||
// store the sheets on this cheatpath alongside the other cheatsheets on
|
||||
// other cheatpaths
|
||||
sheets = append(sheets, pathsheets)
|
||||
}
|
||||
|
||||
// return the cheatsheets, grouped by cheatpath
|
||||
return sheets, nil
|
||||
}
|
3
internal/sheets/load_test.go
Normal file
3
internal/sheets/load_test.go
Normal file
@ -0,0 +1,3 @@
|
||||
package sheets
|
||||
|
||||
// TODO
|
32
internal/sheets/sort.go
Normal file
32
internal/sheets/sort.go
Normal file
@ -0,0 +1,32 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// Sort organizes the cheatsheets into an alphabetically-sorted slice
|
||||
func Sort(cheatsheets map[string]sheet.Sheet) []sheet.Sheet {
|
||||
|
||||
// create a slice that contains the cheatsheet titles
|
||||
var titles []string
|
||||
for title := range cheatsheets {
|
||||
titles = append(titles, title)
|
||||
}
|
||||
|
||||
// sort the slice of titles
|
||||
sort.Strings(titles)
|
||||
|
||||
// create a slice of sorted cheatsheets
|
||||
sorted := []sheet.Sheet{}
|
||||
|
||||
// iterate over the sorted slice of titles, and append cheatsheets to
|
||||
// `sorted` in an identical (alabetically sequential) order
|
||||
for _, title := range titles {
|
||||
sorted = append(sorted, cheatsheets[title])
|
||||
}
|
||||
|
||||
// return the sorted slice of cheatsheets
|
||||
return sorted
|
||||
}
|
34
internal/sheets/sort_test.go
Normal file
34
internal/sheets/sort_test.go
Normal file
@ -0,0 +1,34 @@
|
||||
package sheets
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cheat/cheat/internal/sheet"
|
||||
)
|
||||
|
||||
// TestSort asserts that Sort properly sorts sheets
|
||||
func TestSort(t *testing.T) {
|
||||
|
||||
// mock a map of cheatsheets
|
||||
sheets := map[string]sheet.Sheet{
|
||||
"foo": sheet.Sheet{Title: "foo"},
|
||||
"bar": sheet.Sheet{Title: "bar"},
|
||||
"baz": sheet.Sheet{Title: "baz"},
|
||||
}
|
||||
|
||||
// sort the sheets
|
||||
sorted := Sort(sheets)
|
||||
|
||||
// assert that the sheets sorted properly
|
||||
want := []string{"bar", "baz", "foo"}
|
||||
|
||||
for i, got := range sorted {
|
||||
if got.Title != want[i] {
|
||||
t.Errorf(
|
||||
"sort returned incorrect value: want: %s, got: %s",
|
||||
want[i],
|
||||
got.Title,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user