Compare commits

...

8 Commits
4.6.0 ... 5.0.0

Author SHA1 Message Date
Christopher Allen Lane
417b5b4e42 Merge branch 'feat/cobra' 2026-02-15 18:45:11 -05:00
Christopher Allen Lane
9b92261604 feat: migrate from docopt to cobra for CLI argument parsing
Replace docopt-go with spf13/cobra, giving cheat a built-in
`--completion` flag that dynamically generates shell completions for
bash, zsh, fish, and powershell. This replaces the manually-maintained
static completion scripts and resolves the root cause of multiple
completion issues (#768, #705, #633, #632, #476).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 18:19:20 -05:00
Christopher Allen Lane
ca1ec0e38d test: improve mutation coverage across 4 modules
Mutation testing (56 mutations, 10 modules) identified 7 surviving
mutations. Added 5 targeted tests to kill all actionable survivors.
Remaining 2 are logically equivalent condition reorderings in
filter.go. Overall mutation score: 96.4%.

New tests:
- TestHasMalformedYAML: YAML unmarshal error path
- TestInvalidateInvalidCheatpath: cheatpath.Validate() delegation
- TestIndentTrimsWhitespace: TrimSpace behavior
- TestColorizeDefaultSyntax: default "bash" lexer
- TestColorizeExplicitSyntax: lexer differentiation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:35:12 -05:00
Christopher Allen Lane
52403dbe4a fix: drop plan9 build target (#774)
The plan9/amd64 build fails because the vendored
cyphar/filepath-securejoin uses syscall.ELOOP, which doesn't
exist on plan9. Upstream fix (cyphar/filepath-securejoin#51) is
stalled. Given ~0 user demand (46 downloads total, one release),
remove the target rather than carry a local patch.

Closes #774

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:19:06 -05:00
Christopher Allen Lane
5ad1a3c39f chore: housekeeping and refactoring (bump to 4.7.1)
- Remove unused parameters, dead files, and inaccurate doc.go files
- Extract shared helpers, eliminate duplication
- Rename cheatpath.Cheatpath to cheatpath.Path
- Optimize filesystem walks (WalkDir, skip .git)
- Move sheet name validation to sheet.Validate
- Move integration tests to test/integration/
- Consolidate internal/mock into mocks/
- Move fuzz.sh to test/
- Inline loadSheets helper into command callers
- Extract config.New into its own file
- Fix stale references in HACKING.md and CLAUDE.md
- Restore plan9 build target
- Remove redundant and low-value tests
- Clean up project documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:11:19 -05:00
Christopher Allen Lane
d4a8a79628 chore: bump version to 4.7.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:24:54 -05:00
Christopher Allen Lane
007c9f9efe Merge branch 'brief-list-flag' 2026-02-15 12:24:42 -05:00
Christopher Allen Lane
f61203ac1b feat: add -b/--brief flag for compact list output (#505)
Add a --brief flag that lists cheatsheets with only title and tags,
omitting the file path column. Can be used standalone (-b) or combined
with --list (-lb), and composes with existing filters (-t, -p).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 12:21:35 -05:00
179 changed files with 15145 additions and 4692 deletions

105
.test-mutations.json Normal file
View File

@@ -0,0 +1,105 @@
{
"version": "1.0",
"test_command": "go test ./...",
"last_updated": "2026-02-15T00:00:00Z",
"modules": {
"internal/sheet/parse.go": {
"status": "completed",
"covering_tests": ["internal/sheet/parse_test.go", "internal/sheet/parse_extended_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 8,
"mutations_killed": 8,
"mutation_score": 100.0,
"notes": "Originally 7/8 (87.5%). Added TestHasMalformedYAML to kill YAML unmarshal error survivor."
},
"internal/config/validate.go": {
"status": "completed",
"covering_tests": ["internal/config/validate_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 8,
"mutations_killed": 8,
"mutation_score": 100.0,
"notes": "Originally 7/8 (87.5%). Added TestInvalidateInvalidCheatpath to kill cheatpath.Validate() delegation survivor."
},
"internal/sheets/filter.go": {
"status": "completed",
"covering_tests": ["internal/sheets/filter_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 7,
"mutations_killed": 5,
"mutation_score": 71.4,
"notes": "Survivors relate to UTF-8 condition ordering and OR→AND on dead code path. Not actionable — logically equivalent mutations."
},
"internal/config/paths.go": {
"status": "completed",
"covering_tests": ["internal/config/paths_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 8,
"mutations_killed": 8,
"mutation_score": 100.0,
"notes": "Perfect score. Excellent existing coverage."
},
"internal/sheet/colorize.go": {
"status": "completed",
"covering_tests": ["internal/sheet/colorize_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 5,
"mutations_killed": 5,
"mutation_score": 100.0,
"notes": "Originally 2/5 (40%). Added TestColorizeDefaultSyntax and TestColorizeExplicitSyntax. All 5 mutations now killed."
},
"internal/sheets/consolidate.go": {
"status": "completed",
"covering_tests": ["internal/sheets/consolidate_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 2,
"mutations_killed": 2,
"mutation_score": 100.0,
"notes": "Override semantics well-tested."
},
"internal/display/indent.go": {
"status": "completed",
"covering_tests": ["internal/display/indent_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 3,
"mutations_killed": 3,
"mutation_score": 100.0,
"notes": "Originally 2/3 (66.7%). Added TestIndentTrimsWhitespace to kill TrimSpace survivor."
},
"internal/display/faint.go": {
"status": "completed",
"covering_tests": ["internal/display/faint_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 3,
"mutations_killed": 3,
"mutation_score": 100.0,
"notes": "Perfect score."
},
"internal/sheets/tags.go": {
"status": "completed",
"covering_tests": ["internal/sheets/tags_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 2,
"mutations_killed": 2,
"mutation_score": 100.0,
"notes": "UTF-8 validation and sort order both tested."
},
"internal/sheet/validate.go": {
"status": "completed",
"covering_tests": ["internal/sheet/validate_test.go"],
"last_tested": "2026-02-15T00:00:00Z",
"mutations_applied": 10,
"mutations_killed": 10,
"mutation_score": 100.0,
"notes": "Perfect score. All security checks well-tested."
}
},
"global_statistics": {
"total_modules": 10,
"completed_modules": 10,
"total_mutations": 56,
"total_killed": 54,
"total_survived": 2,
"overall_score": 96.4
}
}

View File

@@ -55,9 +55,10 @@ make vendor-update
The `cheat` command-line tool is organized into several key packages:
### Command Layer (`cmd/cheat/`)
- `main.go`: Entry point, argument parsing, command routing
- `main.go`: Entry point, cobra command definition, flag registration, command routing
- `cmd_*.go`: Individual command implementations (view, edit, list, search, etc.)
- Commands are selected based on docopt parsed arguments
- `completions.go`: Dynamic shell completion functions for cheatsheet names, tags, and paths
- Commands are routed via a `switch` block in the cobra `RunE` handler
### Core Internal Packages
@@ -85,7 +86,11 @@ The `cheat` command-line tool is organized into several key packages:
- Writes to stdout or pager
- Handles text formatting and indentation
6. **`internal/repo`**: Git repository management
6. **`internal/installer`**: First-run installer
- Prompts user for initial configuration choices
- Generates default `conf.yml` and downloads community cheatsheets
7. **`internal/repo`**: Git repository management
- Clones community cheatsheet repositories
- Updates existing repositories
@@ -95,6 +100,7 @@ The `cheat` command-line tool is organized into several key packages:
- **Override mechanism**: Local sheets override community sheets with same name
- **Tag system**: Sheets can be categorized with tags in frontmatter
- **Multiple cheatpaths**: Supports personal, community, and directory-scoped sheets
- **Directory-scoped discovery**: Walks up from cwd to find the nearest `.cheat` directory (like `.git` discovery)
### Sheet Format
@@ -114,4 +120,4 @@ ssh -L 8080:localhost:80 user@remote
- Use `go-git` for repository operations, not exec'ing git commands
- Platform-specific paths are handled in `internal/config/paths.go`
- Color output uses ANSI codes via the Chroma library
- Test files use the `internal/mock` package for test data
- Test files use the `mocks` package for test data

View File

@@ -1,5 +1,4 @@
Contributing
============
# Contributing
Thank you for your interest in `cheat`.
@@ -11,4 +10,8 @@ Bug reports are still welcome. If you've found a bug, please open an issue in
the [issue tracker][issues]. Before doing so, please search through the
existing open issues to make sure it hasn't already been reported.
Feature requests may be filed, but are unlikely to be implemented. The project
is now mature and the maintainer considers its feature set to be essentially
complete.
[issues]: https://github.com/cheat/cheat/issues

View File

@@ -63,7 +63,7 @@ make coverage-text # Terminal output
The `cheat` application follows a clean architecture with well-separated concerns:
- **`cmd/cheat/`**: Command layer with argument parsing and command routing
- **`cmd/cheat/`**: Command layer (cobra-based CLI, flag registration, command routing, shell completions)
- **`internal/config`**: Configuration management (YAML loading, validation, paths)
- **`internal/cheatpath`**: Cheatsheet path management (collections, filtering)
- **`internal/sheet`**: Individual cheatsheet handling (parsing, search, highlighting)
@@ -88,7 +88,7 @@ The main configuration structure:
type Config struct {
Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"`
Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"`
Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"`
@@ -97,7 +97,7 @@ type Config struct {
```
Key functions:
- `New(opts, confPath, resolve)` - Load config from file
- `New(confPath, resolve)` - Load config from file
- `Validate()` - Validate configuration values
- `Editor()` - Get editor from environment or defaults (package-level function)
- `Pager()` - Get pager from environment or defaults (package-level function)
@@ -107,7 +107,7 @@ Key functions:
Represents a directory containing cheatsheets:
```go
type Cheatpath struct {
type Path struct {
Name string // Friendly name (e.g., "personal")
Path string // Filesystem path
Tags []string // Tags applied to all sheets in this path
@@ -202,7 +202,7 @@ go test ./... # Go test directly
Test files follow Go conventions:
- `*_test.go` files in same package
- Table-driven tests for multiple scenarios
- Mock data in `internal/mock` package
- Mock data in `mocks` package
## Error Handling

View File

@@ -1,30 +1,29 @@
Installing
==========
# Installing
`cheat` has no runtime dependencies. As such, installing it is generally
straightforward. There are a few methods available:
### Install manually
#### Unix-like
## Install manually
### Unix-like
On Unix-like systems, you may simply paste the following snippet into your terminal:
```sh
cd /tmp \
&& wget https://github.com/cheat/cheat/releases/download/4.5.1/cheat-linux-amd64.gz \
&& wget https://github.com/cheat/cheat/releases/download/5.0.0/cheat-linux-amd64.gz \
&& gunzip cheat-linux-amd64.gz \
&& chmod +x cheat-linux-amd64 \
&& sudo mv cheat-linux-amd64 /usr/local/bin/cheat
```
You may need to need to change the version number (`4.5.1`) and the archive
You may need to need to change the version number (`5.0.0`) and the archive
(`cheat-linux-amd64.gz`) depending on your platform.
See the [releases page][releases] for a list of supported platforms.
#### Windows
### Windows
On Windows, download the appropriate binary from the [releases page][releases],
unzip the archive, and place the `cheat.exe` executable on your `PATH`.
### Install via `go install`
## Install via `go install`
If you have `go` version `>=1.17` available on your `PATH`, you can install
`cheat` via `go install`:
@@ -32,7 +31,7 @@ If you have `go` version `>=1.17` available on your `PATH`, you can install
go install github.com/cheat/cheat/cmd/cheat@latest
```
### Install via package manager
## Install via package manager
Several community-maintained packages are also available:
Package manager | Package(s)
@@ -43,8 +42,6 @@ docker | [docker-cheat][pkg-docker]
nix | [nixos.cheat][pkg-nix]
snap | [cheat][pkg-snap]
<!--[pacman][] |-->
## Configuring
Three things must be done before you can use `cheat`:
1. A config file must be generated
@@ -56,7 +53,7 @@ automatically. After the installer is complete, it is strongly advised that you
view the configuration file that was generated, as you may want to change some
of its default values (to enable colorization, change the paginator, etc).
### conf.yml ###
### conf.yml
`cheat` is configured by a YAML file that will be auto-generated on first run.
By default, the config file is assumed to exist on an XDG-compliant

View File

@@ -109,11 +109,6 @@ $(dist_dir)/cheat-openbsd-amd64:
GOARCH=amd64 GOOS=openbsd \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-plan9-amd64
$(dist_dir)/cheat-plan9-amd64:
GOARCH=amd64 GOOS=plan9 \
$(GO) build $(BUILD_FLAGS) -o $@ $(cmd_dir) && $(GZIP) $@ && chmod -x $@.gz
# cheat-solaris-amd64
$(dist_dir)/cheat-solaris-amd64:
GOARCH=amd64 GOOS=solaris \
@@ -213,12 +208,12 @@ test-all: test test-integration
## test-fuzz: run quick fuzz tests for security-critical functions
.PHONY: test-fuzz
test-fuzz:
@./build/fuzz.sh 15s
@./test/fuzz.sh 15s
## test-fuzz-long: run extended fuzz tests (10 minutes each)
.PHONY: test-fuzz-long
test-fuzz-long:
@./build/fuzz.sh 10m
@./test/fuzz.sh 10m
## coverage: generate a test coverage report
.PHONY: coverage
@@ -240,22 +235,22 @@ coverage-text: .tmp
## benchmark: run performance benchmarks
.PHONY: benchmark
benchmark: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./cmd/cheat | tee .tmp/benchmark-latest.txt && \
$(RM) -f cheat.test
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem ./test/integration | tee .tmp/benchmark-latest.txt && \
$(RM) -f integration.test
## benchmark-cpu: run benchmarks with CPU profiling
.PHONY: benchmark-cpu
benchmark-cpu: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./cmd/cheat && \
$(RM) -f cheat.test && \
$(GO) test -tags=integration -bench=. -benchtime=10s -cpuprofile=.tmp/cpu.prof ./test/integration && \
$(RM) -f integration.test && \
echo "CPU profile saved to .tmp/cpu.prof" && \
echo "View with: go tool pprof -http=:8080 .tmp/cpu.prof"
## benchmark-mem: run benchmarks with memory profiling
.PHONY: benchmark-mem
benchmark-mem: .tmp
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./cmd/cheat && \
$(RM) -f cheat.test && \
$(GO) test -tags=integration -bench=. -benchtime=10s -benchmem -memprofile=.tmp/mem.prof ./test/integration && \
$(RM) -f integration.test && \
echo "Memory profile saved to .tmp/mem.prof" && \
echo "View with: go tool pprof -http=:8080 .tmp/mem.prof"

View File

@@ -1,8 +1,6 @@
![Workflow status](https://github.com/cheat/cheat/actions/workflows/build.yml/badge.svg)
cheat
=====
# cheat
`cheat` allows you to create and view interactive cheatsheets on the
command-line. It was designed to help remind \*nix system administrators of
@@ -13,9 +11,7 @@ remember.
Use `cheat` with [cheatsheets][].
Example
-------
## Example
The next time you're forced to disarm a nuclear weapon without consulting
Google, you may run:
@@ -42,8 +38,10 @@ tar -xjvf '/path/to/foo.tgz'
tar -cjvf '/path/to/foo.tgz' '/path/to/foo/'
```
Usage
-----
## Installing
For installation and configuration instructions, see [INSTALLING.md][].
## Usage
To view a cheatsheet:
```sh
@@ -70,6 +68,12 @@ To list all available cheatsheets:
cheat -l
```
To briefly list all cheatsheets (names and tags only):
```sh
cheat -b
```
To list all cheatsheets that are tagged with "networking":
```sh
@@ -101,14 +105,7 @@ Flags may be combined in intuitive ways. Example: to search sheets on the
cheat -p personal -t networking --regex -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
```
Installing
----------
For installation and configuration instructions, see [INSTALLING.md][].
Cheatsheets
-----------
## Cheatsheets
Cheatsheets are plain-text files with no file extension, and are named
according to the command used to view them:
@@ -137,8 +134,7 @@ The `cheat` executable includes no cheatsheets, but [community-sourced
cheatsheets are available][cheatsheets]. You will be asked if you would like to
install the community-sourced cheatsheets the first time you run `cheat`.
Cheatpaths
----------
## Cheatpaths
Cheatsheets are stored on "cheatpaths", which are directories that contain
cheatsheets. Cheatpaths are specified in the `conf.yml` file.
@@ -170,7 +166,7 @@ If a user attempts to edit a cheatsheet on a read-only cheatpath, `cheat` will
transparently copy that sheet to a writeable directory before opening it for
editing.
### Directory-scoped Cheatpaths ###
### Directory-scoped Cheatpaths
At times, it can be useful to closely associate cheatsheets with a directory on
your filesystem. `cheat` facilitates this by searching for a `.cheat` directory
in the current working directory and its ancestors (similar to how `git` locates
@@ -178,24 +174,39 @@ in the current working directory and its ancestors (similar to how `git` locates
added to the cheatpaths. This means you can place a `.cheat` directory at your
project root and it will be available from any subdirectory within that project.
Autocompletion
--------------
Shell autocompletion is currently available for `bash`, `fish`, and `zsh`. Copy
the relevant [completion script][completions] into the appropriate directory on
your filesystem to enable autocompletion. (This directory will vary depending
on operating system and shell specifics.)
## Autocompletion
`cheat` can generate shell completion scripts for `bash`, `zsh`, `fish`, and
`powershell` via the `--completion` flag:
Additionally, `cheat` supports enhanced autocompletion via integration with
[fzf][]. To enable `fzf` integration:
```sh
cheat --completion bash
cheat --completion zsh
cheat --completion fish
cheat --completion powershell
```
1. Ensure that `fzf` is available on your `$PATH`
2. Set an envvar: `export CHEAT_USE_FZF=true`
Pipe the output to the appropriate location for your shell. For example:
```sh
# bash (user-local)
mkdir -p ~/.local/share/bash-completion/completions
cheat --completion bash > ~/.local/share/bash-completion/completions/cheat
# bash (system-wide)
cheat --completion bash > /etc/bash_completion.d/cheat
# zsh (ensure the directory is on your fpath)
cheat --completion zsh > "${fpath[1]}/_cheat"
# fish
cheat --completion fish > ~/.config/fish/completions/cheat.fish
```
Completions are dynamically generated and include cheatsheet names, tags, and
cheatpath names.
[INSTALLING.md]: INSTALLING.md
[Releases]: https://github.com/cheat/cheat/releases
[cheatsheets]: https://github.com/cheat/cheatsheets
[completions]: https://github.com/cheat/cheat/tree/master/scripts
[Chroma]: https://github.com/alecthomas/chroma
[supported languages]: https://github.com/alecthomas/chroma#supported-languages
[fzf]: https://github.com/junegunn/fzf
[go]: https://golang.org

View File

@@ -38,10 +38,10 @@ The validation is performed at the application layer before any file operations
### Validation Function
The validation is implemented in `internal/cheatpath/validate.go`:
The validation is implemented in `internal/sheet/validate.go`:
```go
func ValidateSheetName(name string) error {
func Validate(name string) error {
// Reject empty names
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
@@ -133,7 +133,7 @@ The following patterns are explicitly allowed:
Comprehensive tests ensure the validation works correctly:
1. **Unit tests** (`internal/cheatpath/validate_test.go`) verify the validation logic
1. **Unit tests** (`internal/sheet/validate_test.go`) verify the validation logic
2. **Integration tests** verify the actual binary blocks malicious inputs
3. **No system files are accessed** during testing - all tests use isolated directories

View File

@@ -8,7 +8,7 @@ Accepted
## Context
In `cmd/cheat/main.go` lines 47-52, the code parses environment variables assuming they all contain an equals sign:
In the `envVars()` function in `cmd/cheat/main.go`, the code parses environment variables assuming they all contain an equals sign:
```go
for _, e := range os.Environ() {

View File

@@ -100,5 +100,5 @@ The parallelization attempt was valuable as a learning exercise and definitively
## References
- Benchmark implementation: cmd/cheat/search_bench_test.go
- Benchmark implementation: test/integration/search_bench_test.go
- Reverted parallel implementation: see git history (commit 82eb918)

View File

@@ -3,9 +3,11 @@ package main
import (
"fmt"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
)
func cmdConf(_ map[string]interface{}, conf config.Config) {
func cmdConf(_ *cobra.Command, _ []string, conf config.Config) {
fmt.Println(conf.Path)
}

View File

@@ -5,12 +5,14 @@ import (
"fmt"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
)
// cmdDirectories lists the configured cheatpaths.
func cmdDirectories(_ map[string]interface{}, conf config.Config) {
func cmdDirectories(_ *cobra.Command, _ []string, conf config.Config) {
// initialize a tabwriter to produce cleanly columnized output
var out bytes.Buffer

View File

@@ -7,18 +7,21 @@ import (
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets"
)
// cmdEdit opens a cheatsheet for editing (or creates it if it doesn't exist).
func cmdEdit(opts map[string]interface{}, conf config.Config) {
func cmdEdit(cmd *cobra.Command, _ []string, conf config.Config) {
cheatsheet := opts["--edit"].(string)
cheatsheet, _ := cmd.Flags().GetString("edit")
// validate the cheatsheet name
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil {
if err := sheet.Validate(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1)
}
@@ -29,12 +32,11 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(opts["--tag"].(string), ","),
strings.Split(tagVal, ","),
)
}
@@ -52,50 +54,30 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
// if the sheet exists and is not read-only, edit it in place
if ok && !sheet.ReadOnly {
editpath = sheet.Path
// if the sheet exists but is read-only, copy it before editing
} else if ok && sheet.ReadOnly {
// compute the new edit path
// begin by getting a writeable cheatpath
} else {
// for read-only or non-existent sheets, resolve a writeable path
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
os.Exit(1)
}
// compute the new edit path
editpath = filepath.Join(writepath.Path, sheet.Title)
// create any necessary subdirectories
dirs := filepath.Dir(editpath)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
fmt.Fprintf(os.Stderr, "failed to create directory: %s, %v\n", dirs, err)
os.Exit(1)
}
// use the existing title for read-only copies, the requested name otherwise
title := cheatsheet
if ok {
title = sheet.Title
}
editpath = filepath.Join(writepath.Path, title)
// copy the sheet to the new edit path
err = sheet.Copy(editpath)
if err != nil {
if ok {
// copy the read-only sheet to the writeable path
// (Copy handles MkdirAll internally)
if err := sheet.Copy(editpath); err != nil {
fmt.Fprintf(os.Stderr, "failed to copy cheatsheet: %v\n", err)
os.Exit(1)
}
// if the sheet does not exist, create it
} else {
// compute the new edit path
// begin by getting a writeable cheatpath
writepath, err := cheatpath.Writeable(conf.Cheatpaths)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get writeable path: %v\n", err)
os.Exit(1)
}
// compute the new edit path
editpath = filepath.Join(writepath.Path, cheatsheet)
// create any necessary subdirectories
// create any necessary subdirectories for the new sheet
dirs := filepath.Dir(editpath)
if dirs != "." {
if err := os.MkdirAll(dirs, 0755); err != nil {
@@ -104,20 +86,21 @@ func cmdEdit(opts map[string]interface{}, conf config.Config) {
}
}
}
}
// split `conf.Editor` into parts to separate the editor's executable from
// any arguments it may have been passed. If this is not done, the nearby
// call to `exec.Command` will fail.
parts := strings.Fields(conf.Editor)
editor := parts[0]
args := append(parts[1:], editpath)
editorArgs := append(parts[1:], editpath)
// edit the cheatsheet
cmd := exec.Command(editor, args...)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
editorCmd := exec.Command(editor, editorArgs...)
editorCmd.Stdout = os.Stdout
editorCmd.Stdin = os.Stdin
editorCmd.Stderr = os.Stderr
if err := editorCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "failed to edit cheatsheet: %v\n", err)
os.Exit(1)
}

View File

@@ -3,78 +3,27 @@ package main
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/installer"
)
// cmdInit displays an example config file.
func cmdInit() {
func cmdInit(home string, envvars map[string]string) {
// get the user's home directory
home, err := homedir.Dir()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get user home directory: %v\n", err)
os.Exit(1)
}
// read the envvars into a map of strings
envvars := map[string]string{}
for _, e := range os.Environ() {
pair := strings.SplitN(e, "=", 2)
envvars[pair[0]] = pair[1]
}
// load the config template
configs := configs()
// identify the os-specifc paths at which configs may be located
// identify the os-specific paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to read config paths: %v\n", err)
os.Exit(1)
}
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confpath := confpaths[0]
confdir := filepath.Dir(confpath)
// create paths for community, personal, and work cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal")
work := filepath.Join(confdir, "cheatsheets", "work")
// template the above paths into the default configs
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
configs = strings.Replace(configs, "WORK_PATH", work, -1)
// locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
}
// comment out the community cheatpath by default, since the directory
// won't exist until the user clones it
configs = strings.Replace(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
-1,
)
// expand template placeholders and comment out community cheatpath
configs := installer.ExpandTemplate(configs(), confpath)
configs = installer.CommentCommunity(configs, confpath)
// output the templated configs
fmt.Println(configs)

View File

@@ -9,6 +9,8 @@ import (
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheet"
@@ -16,7 +18,7 @@ import (
)
// cmdList lists all available cheatsheets.
func cmdList(opts map[string]interface{}, conf config.Config) {
func cmdList(cmd *cobra.Command, args []string, conf config.Config) {
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
@@ -24,12 +26,11 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
// filter cheatsheets by tag if --tag was provided
if opts["--tag"] != nil {
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(opts["--tag"].(string), ","),
strings.Split(tagVal, ","),
)
}
@@ -49,16 +50,13 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
})
// filter if <cheatsheet> was specified
// NB: our docopt specification is misleading here. When used in conjunction
// with `-l`, `<cheatsheet>` is really a pattern against which to filter
// sheet titles.
if opts["<cheatsheet>"] != nil {
if len(args) > 0 {
// initialize a slice of filtered sheets
filtered := []sheet.Sheet{}
// initialize our filter pattern
pattern := "(?i)" + opts["<cheatsheet>"].(string)
pattern := "(?i)" + args[0]
// compile the regex
reg, err := regexp.Compile(pattern)
@@ -87,13 +85,19 @@ func cmdList(opts map[string]interface{}, conf config.Config) {
var out bytes.Buffer
w := tabwriter.NewWriter(&out, 0, 0, 1, ' ', 0)
// write a header row
fmt.Fprintln(w, "title:\tfile:\ttags:")
// generate sorted, columnized output
briefFlag, _ := cmd.Flags().GetBool("brief")
if briefFlag {
fmt.Fprintln(w, "title:\ttags:")
for _, sheet := range flattened {
fmt.Fprintf(w, "%s\t%s\n", sheet.Title, strings.Join(sheet.Tags, ","))
}
} else {
fmt.Fprintln(w, "title:\tfile:\ttags:")
for _, sheet := range flattened {
fmt.Fprintf(w, "%s\t%s\t%s\n", sheet.Title, sheet.Path, strings.Join(sheet.Tags, ","))
}
}
// write columnized output to stdout
w.Flush()

View File

@@ -5,18 +5,20 @@ import (
"os"
"strings"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/sheet"
"github.com/cheat/cheat/internal/sheets"
)
// cmdRemove removes (deletes) a cheatsheet.
func cmdRemove(opts map[string]interface{}, conf config.Config) {
func cmdRemove(cmd *cobra.Command, _ []string, conf config.Config) {
cheatsheet := opts["--rm"].(string)
cheatsheet, _ := cmd.Flags().GetString("rm")
// validate the cheatsheet name
if err := cheatpath.ValidateSheetName(cheatsheet); err != nil {
if err := sheet.Validate(cheatsheet); err != nil {
fmt.Fprintf(os.Stderr, "invalid cheatsheet name: %v\n", err)
os.Exit(1)
}
@@ -27,12 +29,11 @@ func cmdRemove(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(opts["--tag"].(string), ","),
strings.Split(tagVal, ","),
)
}

View File

@@ -6,15 +6,19 @@ import (
"regexp"
"strings"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
// cmdSearch searches for strings in cheatsheets.
func cmdSearch(opts map[string]interface{}, conf config.Config) {
func cmdSearch(cmd *cobra.Command, args []string, conf config.Config) {
phrase := opts["--search"].(string)
phrase, _ := cmd.Flags().GetString("search")
colorize, _ := cmd.Flags().GetBool("colorize")
useRegex, _ := cmd.Flags().GetBool("regex")
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
@@ -22,12 +26,11 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(opts["--tag"].(string), ","),
strings.Split(tagVal, ","),
)
}
@@ -35,7 +38,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
pattern := "(?i)" + phrase
// unless --regex is provided, in which case we pass the regex unaltered
if opts["--regex"] == true {
if useRegex {
pattern = phrase
}
@@ -55,7 +58,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
// if <cheatsheet> was provided, constrain the search only to
// matching cheatsheets
if opts["<cheatsheet>"] != nil && sheet.Title != opts["<cheatsheet>"] {
if len(args) > 0 && sheet.Title != args[0] {
continue
}
@@ -70,7 +73,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
}
// if colorization was requested, apply it here
if conf.Color(opts) {
if conf.Color(colorize) {
sheet.Colorize(conf)
}
@@ -80,7 +83,7 @@ func cmdSearch(opts map[string]interface{}, conf config.Config) {
// append the cheatsheet title
sheet.Title,
// append the cheatsheet path
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(colorize)),
// indent each line of content
display.Indent(sheet.Text),
)

View File

@@ -4,13 +4,15 @@ import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
// cmdTags lists all tags in use.
func cmdTags(_ map[string]interface{}, conf config.Config) {
func cmdTags(_ *cobra.Command, _ []string, conf config.Config) {
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)

View File

@@ -5,15 +5,19 @@ import (
"os"
"strings"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/display"
"github.com/cheat/cheat/internal/sheets"
)
// cmdView displays a cheatsheet for viewing.
func cmdView(opts map[string]interface{}, conf config.Config) {
func cmdView(cmd *cobra.Command, args []string, conf config.Config) {
cheatsheet := opts["<cheatsheet>"].(string)
cheatsheet := args[0]
colorize, _ := cmd.Flags().GetBool("colorize")
// load the cheatsheets
cheatsheets, err := sheets.Load(conf.Cheatpaths)
@@ -21,17 +25,17 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
fmt.Fprintf(os.Stderr, "failed to list cheatsheets: %v\n", err)
os.Exit(1)
}
// filter cheatcheats by tag if --tag was provided
if opts["--tag"] != nil {
if cmd.Flags().Changed("tag") {
tagVal, _ := cmd.Flags().GetString("tag")
cheatsheets = sheets.Filter(
cheatsheets,
strings.Split(opts["--tag"].(string), ","),
strings.Split(tagVal, ","),
)
}
// if --all was passed, display cheatsheets from all cheatpaths
if opts["--all"].(bool) {
allFlag, _ := cmd.Flags().GetBool("all")
if allFlag {
// iterate over the cheatpaths
out := ""
for _, cheatpath := range cheatsheets {
@@ -42,11 +46,11 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
// identify the matching cheatsheet
out += fmt.Sprintf("%s %s\n",
sheet.Title,
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf),
display.Faint(fmt.Sprintf("(%s)", sheet.CheatPath), conf.Color(colorize)),
)
// apply colorization if requested
if conf.Color(opts) {
if conf.Color(colorize) {
sheet.Colorize(conf)
}
@@ -73,7 +77,7 @@ func cmdView(opts map[string]interface{}, conf config.Config) {
}
// apply colorization if requested
if conf.Color(opts) {
if conf.Color(colorize) {
sheet.Colorize(conf)
}

View File

@@ -5,34 +5,140 @@ import (
"fmt"
"os"
"runtime"
"strings"
"github.com/docopt/docopt-go"
"github.com/mitchellh/go-homedir"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/completions"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/installer"
)
const version = "4.6.0"
const version = "5.0.0"
var rootCmd = &cobra.Command{
Use: "cheat [cheatsheet]",
Short: "Create and view interactive cheatsheets on the command-line",
Long: `cheat allows you to create and view interactive cheatsheets on the
command-line. It was designed to help remind *nix system administrators of
options for commands that they use frequently, but not frequently enough to
remember.`,
Example: ` To initialize a config file:
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
To view the tar cheatsheet:
cheat tar
To edit (or create) the foo cheatsheet:
cheat -e foo
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
cheat -p work -e foo/bar
To view all cheatsheet directories:
cheat -d
To list all available cheatsheets:
cheat -l
To briefly list all cheatsheets whose titles match "apt":
cheat -b apt
To list all tags in use:
cheat -T
To list available cheatsheets that are tagged as "personal":
cheat -l -t personal
To search for "ssh" among all cheatsheets, and colorize matches:
cheat -c -s ssh
To search (by regex) for cheatsheets that contain an IP address:
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
To remove (delete) the foo/bar cheatsheet:
cheat --rm foo/bar
To view the configuration file path:
cheat --conf
To generate shell completions (bash, zsh, fish, powershell):
cheat --completion bash`,
RunE: run,
Args: cobra.MaximumNArgs(1),
SilenceErrors: true,
SilenceUsage: true,
ValidArgsFunction: completions.Cheatsheets,
CompletionOptions: cobra.CompletionOptions{
DisableDefaultCmd: true,
},
}
func init() {
f := rootCmd.Flags()
// bool flags
f.BoolP("all", "a", false, "Search among all cheatpaths")
f.BoolP("brief", "b", false, "List cheatsheets without file paths")
f.BoolP("colorize", "c", false, "Colorize output")
f.BoolP("directories", "d", false, "List cheatsheet directories")
f.Bool("init", false, "Write a default config file to stdout")
f.BoolP("list", "l", false, "List cheatsheets")
f.BoolP("regex", "r", false, "Treat search <phrase> as a regex")
f.BoolP("tags", "T", false, "List all tags in use")
f.BoolP("version", "v", false, "Print the version number")
f.Bool("conf", false, "Display the config file path")
// string flags
f.StringP("edit", "e", "", "Edit `cheatsheet`")
f.StringP("path", "p", "", "Return only sheets found on cheatpath `name`")
f.StringP("search", "s", "", "Search cheatsheets for `phrase`")
f.StringP("tag", "t", "", "Return only sheets matching `tag`")
f.String("rm", "", "Remove (delete) `cheatsheet`")
f.String("completion", "", "Generate shell completion script (`shell`: bash, zsh, fish, powershell)")
// register flag completion functions
rootCmd.RegisterFlagCompletionFunc("tag", completions.Tags)
rootCmd.RegisterFlagCompletionFunc("path", completions.Paths)
rootCmd.RegisterFlagCompletionFunc("edit", completions.Cheatsheets)
rootCmd.RegisterFlagCompletionFunc("rm", completions.Cheatsheets)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
// initialize options
opts, err := docopt.ParseArgs(usage(), nil, version)
func run(cmd *cobra.Command, args []string) error {
f := cmd.Flags()
// handle --init early (no config needed)
if initFlag, _ := f.GetBool("init"); initFlag {
home, err := homedir.Dir()
if err != nil {
// panic here, because this should never happen
panic(fmt.Errorf("docopt failed to parse: %v", err))
fmt.Fprintf(os.Stderr, "failed to get user home directory: %v\n", err)
os.Exit(1)
}
envvars := config.EnvVars()
cmdInit(home, envvars)
os.Exit(0)
}
// if --init was passed, we don't want to attempt to load a config file.
// Instead, just execute cmd_init and exit
if opts["--init"] != nil && opts["--init"] == true {
cmdInit()
// handle --version early
if versionFlag, _ := f.GetBool("version"); versionFlag {
fmt.Println(version)
os.Exit(0)
}
// handle --completion early (no config needed)
if f.Changed("completion") {
shell, _ := f.GetString("completion")
return completions.Generate(cmd, shell, os.Stdout)
}
// get the user's home directory
home, err := homedir.Dir()
if err != nil {
@@ -41,17 +147,9 @@ func main() {
}
// read the envvars into a map of strings
envvars := map[string]string{}
for _, e := range os.Environ() {
// os.Environ() guarantees "key=value" format (see ADR-002)
pair := strings.SplitN(e, "=", 2)
if runtime.GOOS == "windows" {
pair[0] = strings.ToUpper(pair[0])
}
envvars[pair[0]] = pair[1]
}
envvars := config.EnvVars()
// identify the os-specifc paths at which configs may be located
// identify the os-specific paths at which configs may be located
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
@@ -92,7 +190,7 @@ func main() {
}
// initialize the configs
conf, err := config.New(opts, confpath, true)
conf, err := config.New(confpath, true)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
os.Exit(1)
@@ -105,10 +203,11 @@ func main() {
}
// filter the cheatpaths if --path was passed
if opts["--path"] != nil {
if f.Changed("path") {
pathVal, _ := f.GetString("path")
conf.Cheatpaths, err = cheatpath.Filter(
conf.Cheatpaths,
opts["--path"].(string),
pathVal,
)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid option --path: %v\n", err)
@@ -117,41 +216,44 @@ func main() {
}
// determine which command to execute
var cmd func(map[string]interface{}, config.Config)
confFlag, _ := f.GetBool("conf")
dirFlag, _ := f.GetBool("directories")
listFlag, _ := f.GetBool("list")
briefFlag, _ := f.GetBool("brief")
tagsFlag, _ := f.GetBool("tags")
tagVal, _ := f.GetString("tag")
switch {
case opts["--conf"].(bool):
cmd = cmdConf
case confFlag:
cmdConf(cmd, args, conf)
case opts["--directories"].(bool):
cmd = cmdDirectories
case dirFlag:
cmdDirectories(cmd, args, conf)
case opts["--edit"] != nil:
cmd = cmdEdit
case f.Changed("edit"):
cmdEdit(cmd, args, conf)
case opts["--list"].(bool):
cmd = cmdList
case listFlag, briefFlag:
cmdList(cmd, args, conf)
case opts["--tags"].(bool):
cmd = cmdTags
case tagsFlag:
cmdTags(cmd, args, conf)
case opts["--search"] != nil:
cmd = cmdSearch
case f.Changed("search"):
cmdSearch(cmd, args, conf)
case opts["--rm"] != nil:
cmd = cmdRemove
case f.Changed("rm"):
cmdRemove(cmd, args, conf)
case opts["<cheatsheet>"] != nil:
cmd = cmdView
case len(args) > 0:
cmdView(cmd, args, conf)
case opts["--tag"] != nil && opts["--tag"].(string) != "":
cmd = cmdList
case tagVal != "":
cmdList(cmd, args, conf)
default:
fmt.Println(usage())
os.Exit(0)
return cmd.Help()
}
// execute the command
cmd(opts, conf)
return nil
}

View File

@@ -1,64 +0,0 @@
package main
// usage returns the usage text for the cheat command
func usage() string {
return `Usage:
cheat [options] [<cheatsheet>]
Options:
--init Write a default config file to stdout
-a --all Search among all cheatpaths
-c --colorize Colorize output
-d --directories List cheatsheet directories
-e --edit=<cheatsheet> Edit <cheatsheet>
-l --list List cheatsheets
-p --path=<name> Return only sheets found on cheatpath <name>
-r --regex Treat search <phrase> as a regex
-s --search=<phrase> Search cheatsheets for <phrase>
-t --tag=<tag> Return only sheets matching <tag>
-T --tags List all tags in use
-v --version Print the version number
--rm=<cheatsheet> Remove (delete) <cheatsheet>
--conf Display the config file path
Examples:
To initialize a config file:
mkdir -p ~/.config/cheat && cheat --init > ~/.config/cheat/conf.yml
To view the tar cheatsheet:
cheat tar
To edit (or create) the foo cheatsheet:
cheat -e foo
To edit (or create) the foo/bar cheatsheet on the "work" cheatpath:
cheat -p work -e foo/bar
To view all cheatsheet directories:
cheat -d
To list all available cheatsheets:
cheat -l
To list all cheatsheets whose titles match "apt":
cheat -l apt
To list all tags in use:
cheat -T
To list available cheatsheets that are tagged as "personal":
cheat -l -t personal
To search for "ssh" among all cheatsheets, and colorize matches:
cheat -c -s ssh
To search (by regex) for cheatsheets that contain an IP address:
cheat -c -r -s '(?:[0-9]{1,3}\.){3}[0-9]{1,3}'
To remove (delete) the foo/bar cheatsheet:
cheat --rm foo/bar
To view the configuration file path:
cheat --conf`
}

View File

@@ -23,6 +23,9 @@ Display the config file path.
\-a, \[en]all
Search among all cheatpaths.
.TP
\-b, \[en]brief
List cheatsheets without file paths.
.TP
\-c, \[en]colorize
Colorize output.
.TP
@@ -55,6 +58,11 @@ Print the version number.
.TP
\[en]rm=\f[I]CHEATSHEET\f[R]
Remove (deletes) \f[I]CHEATSHEET\f[R].
.TP
\[en]completion=\f[I]SHELL\f[R]
Generate a shell completion script.
\f[I]SHELL\f[R] must be one of: \f[B]bash\f[R], \f[B]zsh\f[R],
\f[B]fish\f[R], \f[B]powershell\f[R].
.SH EXAMPLES
.TP
To view the foo cheatsheet:
@@ -72,8 +80,8 @@ cheat \-d
To list all available cheatsheets:
cheat \-l
.TP
To list all cheatsheets whose titles match `apt':
cheat \-l \f[I]apt\f[R]
To briefly list all cheatsheets whose titles match `apt':
cheat \-b \f[I]apt\f[R]
.TP
To list all tags in use:
cheat \-T
@@ -132,37 +140,52 @@ Cheatpaths may be configured in \f[I]conf.yaml\f[R], and viewed via
For detailed instructions on how to configure cheatpaths, please refer
to the comments in conf.yml.
.SS Autocompletion
Autocompletion scripts for \f[B]bash\f[R], \f[B]zsh\f[R], and
\f[B]fish\f[R] are available for download:
.IP \[bu] 2
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.bash
.UE \c
.IP \[bu] 2
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.fish
.UE \c
.IP \[bu] 2
\c
.UR https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh
.UE \c
\f[B]cheat\f[R] can generate shell completion scripts for
\f[B]bash\f[R], \f[B]zsh\f[R], \f[B]fish\f[R], and \f[B]powershell\f[R]
via the \f[B]\[en]completion\f[R] flag:
.IP
.EX
cheat \-\-completion bash
cheat \-\-completion zsh
cheat \-\-completion fish
cheat \-\-completion powershell
.EE
.PP
The \f[B]bash\f[R] and \f[B]zsh\f[R] scripts provide optional
integration with \f[B]fzf\f[R], if the latter is available on your
\f[B]PATH\f[R].
Completions are dynamically generated and include cheatsheet names,
tags, and cheatpath names.
.PP
The installation process will vary per system and shell configuration,
and thus will not be discussed here.
To install completions, pipe the output to the appropriate location for
your shell.
For example, on \f[B]bash\f[R]:
.IP
.EX
cheat \-\-completion bash > /etc/bash_completion.d/cheat
.EE
.PP
Or for the current user only:
.IP
.EX
cheat \-\-completion bash > \[ti]/.local/share/bash\-completion/completions/cheat
.EE
.PP
For \f[B]zsh\f[R], you may need to add the completions directory to your
\f[B]fpath\f[R]:
.IP
.EX
cheat \-\-completion zsh > \[dq]${fpath[1]}/_cheat\[dq]
.EE
.PP
For \f[B]fish\f[R]:
.IP
.EX
cheat \-\-completion fish > \[ti]/.config/fish/completions/cheat.fish
.EE
.SH ENVIRONMENT
.TP
\f[B]CHEAT_CONFIG_PATH\f[R]
The path at which the config file is available.
If \f[B]CHEAT_CONFIG_PATH\f[R] is set, all other config paths will be
ignored.
.TP
\f[B]CHEAT_USE_FZF\f[R]
If set, autocompletion scripts will attempt to integrate with
\f[B]fzf\f[R].
.SH RETURN VALUES
.IP "0." 3
Successful termination

View File

@@ -29,6 +29,9 @@ OPTIONS
-a, --all
: Search among all cheatpaths.
-b, --brief
: List cheatsheets without file paths.
-c, --colorize
: Colorize output.
@@ -62,6 +65,10 @@ OPTIONS
--rm=_CHEATSHEET_
: Remove (deletes) _CHEATSHEET_.
--completion=_SHELL_
: Generate a shell completion script. _SHELL_ must be one of: **bash**,
**zsh**, **fish**, **powershell**.
EXAMPLES
========
@@ -81,8 +88,8 @@ To view all cheatsheet directories:
To list all available cheatsheets:
: cheat -l
To list all cheatsheets whose titles match 'apt':
: cheat -l _apt_
To briefly list all cheatsheets whose titles match 'apt':
: cheat -b _apt_
To list all tags in use:
: cheat -T
@@ -146,18 +153,33 @@ comments in conf.yml.
Autocompletion
--------------
Autocompletion scripts for **bash**, **zsh**, and **fish** are available for
download:
**cheat** can generate shell completion scripts for **bash**, **zsh**,
**fish**, and **powershell** via the **--completion** flag:
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.bash>
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.fish>
- <https://github.com/cheat/cheat/blob/master/scripts/cheat.zsh>
cheat --completion bash
cheat --completion zsh
cheat --completion fish
cheat --completion powershell
The **bash** and **zsh** scripts provide optional integration with **fzf**, if
the latter is available on your **PATH**.
Completions are dynamically generated and include cheatsheet names, tags, and
cheatpath names.
The installation process will vary per system and shell configuration, and thus
will not be discussed here.
To install completions, pipe the output to the appropriate location for your
shell. For example, on **bash**:
cheat --completion bash > /etc/bash_completion.d/cheat
Or for the current user only:
cheat --completion bash > ~/.local/share/bash-completion/completions/cheat
For **zsh**, you may need to add the completions directory to your **fpath**:
cheat --completion zsh > "${fpath[1]}/_cheat"
For **fish**:
cheat --completion fish > ~/.config/fish/completions/cheat.fish
ENVIRONMENT
@@ -168,10 +190,6 @@ ENVIRONMENT
: The path at which the config file is available. If **CHEAT_CONFIG_PATH** is
set, all other config paths will be ignored.
**CHEAT_USE_FZF**
: If set, autocompletion scripts will attempt to integrate with **fzf**.
RETURN VALUES
=============

4
go.mod
View File

@@ -5,10 +5,10 @@ go 1.26
require (
github.com/alecthomas/chroma/v2 v2.23.1
github.com/davecgh/go-spew v1.1.1
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
github.com/go-git/go-git/v5 v5.16.5
github.com/mattn/go-isatty v0.0.20
github.com/mitchellh/go-homedir v1.1.0
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
)
@@ -23,12 +23,14 @@ require (
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.7.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.5.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect

11
go.sum
View File

@@ -17,6 +17,7 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -24,8 +25,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@@ -46,6 +45,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/kevinburke/ssh_config v1.5.0 h1:3cPZmE54xb5j3G5xQCjSvokqNwU2uW+3ry1+PRLSPpA=
@@ -73,11 +74,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -85,6 +91,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=

View File

@@ -2,27 +2,10 @@
// management.
package cheatpath
import "fmt"
// Cheatpath encapsulates cheatsheet path information
type Cheatpath struct {
// Path encapsulates cheatsheet path information
type Path struct {
Name string `yaml:"name"`
Path string `yaml:"path"`
ReadOnly bool `yaml:"readonly"`
Tags []string `yaml:"tags"`
}
// Validate ensures that the Cheatpath is valid
func (c Cheatpath) Validate() error {
// Check that name is not empty
if c.Name == "" {
return fmt.Errorf("cheatpath name cannot be empty")
}
// Check that path is not empty
if c.Path == "" {
return fmt.Errorf("cheatpath path cannot be empty")
}
return nil
}

View File

@@ -8,13 +8,13 @@ import (
func TestCheatpathValidate(t *testing.T) {
tests := []struct {
name string
cheatpath Cheatpath
cheatpath Path
wantErr bool
errMsg string
}{
{
name: "valid cheatpath",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "personal",
Path: "/home/user/.config/cheat/personal",
ReadOnly: false,
@@ -24,7 +24,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "empty name",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "",
Path: "/home/user/.config/cheat/personal",
ReadOnly: false,
@@ -35,7 +35,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "empty path",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "personal",
Path: "",
ReadOnly: false,
@@ -46,7 +46,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "both empty",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "",
Path: "",
ReadOnly: true,
@@ -57,7 +57,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "minimal valid",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "x",
Path: "/",
},
@@ -65,7 +65,7 @@ func TestCheatpathValidate(t *testing.T) {
},
{
name: "with readonly and tags",
cheatpath: Cheatpath{
cheatpath: Path{
Name: "community",
Path: "/usr/share/cheat",
ReadOnly: true,
@@ -88,26 +88,3 @@ func TestCheatpathValidate(t *testing.T) {
})
}
}
func TestCheatpathStruct(t *testing.T) {
// Test that the struct fields work as expected
cp := Cheatpath{
Name: "test",
Path: "/test/path",
ReadOnly: true,
Tags: []string{"tag1", "tag2"},
}
if cp.Name != "test" {
t.Errorf("expected Name to be 'test', got %q", cp.Name)
}
if cp.Path != "/test/path" {
t.Errorf("expected Path to be '/test/path', got %q", cp.Path)
}
if !cp.ReadOnly {
t.Error("expected ReadOnly to be true")
}
if len(cp.Tags) != 2 || cp.Tags[0] != "tag1" || cp.Tags[1] != "tag2" {
t.Errorf("expected Tags to be [tag1 tag2], got %v", cp.Tags)
}
}

View File

@@ -1,64 +0,0 @@
// Package cheatpath manages collections of cheat sheets organized in filesystem directories.
//
// A Cheatpath represents a directory containing cheat sheets, with associated
// metadata such as tags and read-only status. Multiple cheatpaths can be
// configured to organize sheets from different sources (personal, community, work, etc.).
//
// # Cheatpath Structure
//
// Each cheatpath has:
// - Name: A friendly identifier (e.g., "personal", "community")
// - Path: The filesystem path to the directory
// - Tags: Tags automatically applied to all sheets in this path
// - ReadOnly: Whether sheets in this path can be modified
//
// Example configuration:
//
// cheatpaths:
// - name: personal
// path: ~/cheat
// tags: []
// readonly: false
// - name: community
// path: ~/cheat/community
// tags: [community]
// readonly: true
//
// # Directory-Scoped Cheatpaths
//
// The package supports directory-scoped cheatpaths via `.cheat` directories.
// When running cheat, the tool walks upward from the current working directory
// to the filesystem root, stopping at the first `.cheat` directory found. That
// directory is temporarily added to the available cheatpaths.
//
// # Precedence and Overrides
//
// When multiple cheatpaths contain a sheet with the same name, the sheet
// from the most "local" cheatpath takes precedence. This allows users to
// override community sheets with personal versions.
//
// Key Functions
//
// - Filter: Filters cheatpaths by name
// - Validate: Ensures cheatpath configuration is valid
// - Writeable: Returns the first writeable cheatpath
//
// Example Usage
//
// // Filter cheatpaths to only "personal"
// filtered, err := cheatpath.Filter(paths, "personal")
// if err != nil {
// log.Fatal(err)
// }
//
// // Find a writeable cheatpath
// writeable, err := cheatpath.Writeable(paths)
// if err != nil {
// log.Fatal(err)
// }
//
// // Validate cheatpath configuration
// if err := cheatpath.Validate(paths); err != nil {
// log.Fatal(err)
// }
package cheatpath

View File

@@ -5,15 +5,15 @@ import (
)
// Filter filters all cheatpaths that are not named `name`
func Filter(paths []Cheatpath, name string) ([]Cheatpath, error) {
func Filter(paths []Path, name string) ([]Path, error) {
// if a path of the given name exists, return it
for _, path := range paths {
if path.Name == name {
return []Cheatpath{path}, nil
return []Path{path}, nil
}
}
// otherwise, return an error
return []Cheatpath{}, fmt.Errorf("cheatpath does not exist: %s", name)
return []Path{}, fmt.Errorf("cheatpath does not exist: %s", name)
}

View File

@@ -9,10 +9,10 @@ import (
func TestFilterSuccess(t *testing.T) {
// init cheatpaths
paths := []Cheatpath{
Cheatpath{Name: "foo"},
Cheatpath{Name: "bar"},
Cheatpath{Name: "baz"},
paths := []Path{
Path{Name: "foo"},
Path{Name: "bar"},
Path{Name: "baz"},
}
// filter the paths
@@ -39,10 +39,10 @@ func TestFilterSuccess(t *testing.T) {
func TestFilterFailure(t *testing.T) {
// init cheatpaths
paths := []Cheatpath{
Cheatpath{Name: "foo"},
Cheatpath{Name: "bar"},
Cheatpath{Name: "baz"},
paths := []Path{
Path{Name: "foo"},
Path{Name: "bar"},
Path{Name: "baz"},
}
// filter the paths

View File

@@ -2,39 +2,15 @@ package cheatpath
import (
"fmt"
"path/filepath"
"strings"
)
// ValidateSheetName ensures that a cheatsheet name does not contain
// directory traversal sequences or other potentially dangerous patterns.
func ValidateSheetName(name string) error {
// Reject empty names
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
// Validate ensures that the Path is valid
func (c Path) Validate() error {
if c.Name == "" {
return fmt.Errorf("cheatpath name cannot be empty")
}
// Reject names containing directory traversal
if strings.Contains(name, "..") {
return fmt.Errorf("cheatsheet name cannot contain '..'")
if c.Path == "" {
return fmt.Errorf("cheatpath path cannot be empty")
}
// Reject absolute paths
if filepath.IsAbs(name) {
return fmt.Errorf("cheatsheet name cannot be an absolute path")
}
// Reject names that start with ~ (home directory expansion)
if strings.HasPrefix(name, "~") {
return fmt.Errorf("cheatsheet name cannot start with '~'")
}
// Reject hidden files (files that start with a dot)
// We don't display hidden files, so we shouldn't create them
filename := filepath.Base(name)
if strings.HasPrefix(filename, ".") {
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
}
return nil
}

View File

@@ -4,8 +4,8 @@ import (
"fmt"
)
// Writeable returns a writeable Cheatpath
func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
// Writeable returns a writeable Path
func Writeable(cheatpaths []Path) (Path, error) {
// iterate backwards over the cheatpaths
// NB: we're going backwards because we assume that the most "local"
@@ -18,5 +18,5 @@ func Writeable(cheatpaths []Cheatpath) (Cheatpath, error) {
}
// otherwise, return an error
return Cheatpath{}, fmt.Errorf("no writeable cheatpaths found")
return Path{}, fmt.Errorf("no writeable cheatpaths found")
}

View File

@@ -9,10 +9,10 @@ import (
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},
cheatpaths := []Path{
Path{Path: "/foo", ReadOnly: true},
Path{Path: "/bar", ReadOnly: false},
Path{Path: "/baz", ReadOnly: true},
}
// get the writeable cheatpath
@@ -34,10 +34,10 @@ func TestWriteableOK(t *testing.T) {
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},
cheatpaths := []Path{
Path{Path: "/foo", ReadOnly: true},
Path{Path: "/bar", ReadOnly: true},
Path{Path: "/baz", ReadOnly: true},
}
// get the writeable cheatpath

View File

@@ -0,0 +1,43 @@
// Package completions provides dynamic shell completion functions and
// completion script generation for the cheat CLI.
package completions
import (
"sort"
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/sheets"
)
// Cheatsheets provides completion for cheatsheet names.
func Cheatsheets(
_ *cobra.Command,
args []string,
_ string,
) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
conf, err := loadConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
cheatsheets, err := sheets.Load(conf.Cheatpaths)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
consolidated := sheets.Consolidate(cheatsheets)
names := make([]string, 0, len(consolidated))
for name := range consolidated {
names = append(names, name)
}
sort.Strings(names)
return names, cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -0,0 +1,38 @@
package completions
import (
"runtime"
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/config"
)
// loadConfig loads the cheat configuration for use in completion functions.
// It returns an error rather than exiting, since completions should degrade
// gracefully.
func loadConfig() (config.Config, error) {
home, err := homedir.Dir()
if err != nil {
return config.Config{}, err
}
envvars := config.EnvVars()
confpaths, err := config.Paths(runtime.GOOS, home, envvars)
if err != nil {
return config.Config{}, err
}
confpath, err := config.Path(confpaths)
if err != nil {
return config.Config{}, err
}
conf, err := config.New(confpath, true)
if err != nil {
return config.Config{}, err
}
return conf, nil
}

View File

@@ -0,0 +1,24 @@
package completions
import (
"fmt"
"io"
"github.com/spf13/cobra"
)
// Generate writes a shell completion script to the given writer.
func Generate(cmd *cobra.Command, shell string, w io.Writer) error {
switch shell {
case "bash":
return cmd.Root().GenBashCompletionV2(w, true)
case "zsh":
return cmd.Root().GenZshCompletion(w)
case "fish":
return cmd.Root().GenFishCompletion(w, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(w)
default:
return fmt.Errorf("unsupported shell: %s (valid: bash, zsh, fish, powershell)", shell)
}
}

View File

@@ -0,0 +1,25 @@
package completions
import (
"github.com/spf13/cobra"
)
// Paths provides completion for the --path flag.
func Paths(
_ *cobra.Command,
_ []string,
_ string,
) ([]string, cobra.ShellCompDirective) {
conf, err := loadConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
names := make([]string, 0, len(conf.Cheatpaths))
for _, cp := range conf.Cheatpaths {
names = append(names, cp.Name)
}
return names, cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -0,0 +1,27 @@
package completions
import (
"github.com/spf13/cobra"
"github.com/cheat/cheat/internal/sheets"
)
// Tags provides completion for the --tag flag.
func Tags(
_ *cobra.Command,
_ []string,
_ string,
) ([]string, cobra.ShellCompDirective) {
conf, err := loadConfig()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
cheatsheets, err := sheets.Load(conf.Cheatpaths)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return sheets.Tags(cheatsheets), cobra.ShellCompDirectiveNoFileComp
}

View File

@@ -7,7 +7,7 @@ import (
)
// Color indicates whether colorization should be applied to the output
func (c *Config) Color(opts map[string]interface{}) bool {
func (c *Config) Color(forceColorize bool) bool {
// default to the colorization specified in the configs...
colorize := c.Colorize
@@ -18,7 +18,7 @@ func (c *Config) Color(opts map[string]interface{}) bool {
}
// ... *unless* the --colorize flag was passed
if opts["--colorize"] == true {
if forceColorize {
colorize = true
}

View File

@@ -10,13 +10,11 @@ func TestColor(t *testing.T) {
// mock a config
conf := Config{}
opts := map[string]interface{}{"--colorize": false}
if conf.Color(opts) {
t.Errorf("failed to respect --colorize (false)")
if conf.Color(false) {
t.Errorf("failed to respect forceColorize (false)")
}
opts = map[string]interface{}{"--colorize": true}
if !conf.Color(opts) {
t.Errorf("failed to respect --colorize (true)")
if !conf.Color(true) {
t.Errorf("failed to respect forceColorize (true)")
}
}

View File

@@ -2,158 +2,16 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
)
// Config encapsulates configuration parameters
type Config struct {
Colorize bool `yaml:"colorize"`
Editor string `yaml:"editor"`
Cheatpaths []cp.Cheatpath `yaml:"cheatpaths"`
Cheatpaths []cp.Path `yaml:"cheatpaths"`
Style string `yaml:"style"`
Formatter string `yaml:"formatter"`
Pager string `yaml:"pager"`
Path string
}
// New returns a new Config struct
func New(_ map[string]interface{}, confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.Unmarshal(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// if a .cheat directory exists in the current directory or any ancestor,
// append it to the cheatpaths
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
}
if local := findLocalCheatpath(cwd); local != "" {
path := cp.Cheatpath{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
var validPaths []cp.Cheatpath
for _, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path)
if err != nil {
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
}
// follow symlinks
//
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
// It's necessary because `EvalSymlinks` will error if the symlink points
// to a non-existent location on the filesystem. When unit-testing,
// however, we don't want to have dependencies on the filesystem. As such,
// `resolve` is a switch that allows us to turn off symlink resolution when
// running the config tests.
if resolve {
evaled, err := filepath.EvalSymlinks(expanded)
if err != nil {
// if the path simply doesn't exist, warn and skip it
if os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"WARNING: cheatpath '%s' does not exist, skipping\n",
expanded,
)
continue
}
return Config{}, fmt.Errorf(
"failed to resolve symlink: %s: %v",
expanded,
err,
)
}
expanded = evaled
}
cheatpath.Path = expanded
validPaths = append(validPaths, cheatpath)
}
conf.Cheatpaths = validPaths
// determine the editor: env vars override the config file value,
// following standard Unix convention (see #589)
if v := os.Getenv("VISUAL"); v != "" {
conf.Editor = v
} else if v := os.Getenv("EDITOR"); v != "" {
conf.Editor = v
} else {
conf.Editor = strings.TrimSpace(conf.Editor)
}
// if an editor was still not determined, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
// 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 = "terminal"
}
// load the pager
conf.Pager = strings.TrimSpace(conf.Pager)
return conf, nil
}
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
// It returns the path to the first .cheat directory found, or an empty string
// if none exists. This mirrors the discovery pattern used by git for .git
// directories.
func findLocalCheatpath(dir string) string {
for {
candidate := filepath.Join(dir, ".cheat")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

View File

@@ -3,10 +3,9 @@ package config
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/cheat/cheat/internal/mock"
"github.com/cheat/cheat/mocks"
)
// TestConfigYAMLErrors tests YAML parsing errors
@@ -19,258 +18,22 @@ func TestConfigYAMLErrors(t *testing.T) {
defer os.RemoveAll(tempDir)
invalidYAML := filepath.Join(tempDir, "invalid.yml")
err = os.WriteFile(invalidYAML, []byte("invalid: yaml: content:\n - no closing"), 0644)
err = os.WriteFile(invalidYAML, []byte("cheatpaths: [{unclosed\n"), 0644)
if err != nil {
t.Fatalf("failed to write invalid yaml: %v", err)
}
// Attempt to load invalid YAML
_, err = New(map[string]interface{}{}, invalidYAML, false)
_, err = New(invalidYAML, false)
if err == nil {
t.Error("expected error for invalid YAML, got nil")
}
}
// TestConfigLocalCheatpath tests local .cheat directory detection
func TestConfigLocalCheatpath(t *testing.T) {
// Create a temporary directory to act as working directory
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
tempDir, err = filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
}
// Save current working directory
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Change to temp directory
err = os.Chdir(tempDir)
if err != nil {
t.Fatalf("failed to change dir: %v", err)
}
// Create .cheat directory
localCheat := filepath.Join(tempDir, ".cheat")
err = os.Mkdir(localCheat, 0755)
if err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Load config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
// Check that local cheatpath was added
found := false
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" && cp.Path == localCheat {
found = true
break
}
}
if !found {
t.Error("local .cheat directory was not added to cheatpaths")
}
}
// TestConfigLocalCheatpathInParent tests that .cheat in a parent directory is found
func TestConfigLocalCheatpathInParent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
tempDir, err = filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
}
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat in the root of the temp dir
localCheat := filepath.Join(tempDir, ".cheat")
if err := os.Mkdir(localCheat, 0755); err != nil {
t.Fatalf("failed to create .cheat dir: %v", err)
}
// Create a subdirectory and cd into it
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
if err := os.Chdir(subDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
found := false
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" && cp.Path == localCheat {
found = true
break
}
}
if !found {
t.Error("parent .cheat directory was not added to cheatpaths")
}
}
// TestConfigLocalCheatpathNearestWins tests that the nearest .cheat wins
func TestConfigLocalCheatpathNearestWins(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Resolve symlinks in temp dir path (macOS /var -> /private/var)
tempDir, err = filepath.EvalSymlinks(tempDir)
if err != nil {
t.Fatalf("failed to resolve temp dir symlinks: %v", err)
}
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat at root
if err := os.Mkdir(filepath.Join(tempDir, ".cheat"), 0755); err != nil {
t.Fatalf("failed to create root .cheat dir: %v", err)
}
// Create sub/.cheat (the nearer one)
subDir := filepath.Join(tempDir, "sub")
if err := os.Mkdir(subDir, 0755); err != nil {
t.Fatalf("failed to create sub dir: %v", err)
}
nearCheat := filepath.Join(subDir, ".cheat")
if err := os.Mkdir(nearCheat, 0755); err != nil {
t.Fatalf("failed to create near .cheat dir: %v", err)
}
// cd into sub/deep/
deepDir := filepath.Join(subDir, "deep")
if err := os.Mkdir(deepDir, 0755); err != nil {
t.Fatalf("failed to create deep dir: %v", err)
}
if err := os.Chdir(deepDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
found := false
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
if cp.Path != nearCheat {
t.Errorf("expected nearest .cheat %s, got %s", nearCheat, cp.Path)
}
found = true
break
}
}
if !found {
t.Error("no cwd cheatpath found")
}
}
// TestConfigNoLocalCheatpath tests that no cwd cheatpath is added when no .cheat exists
func TestConfigNoLocalCheatpath(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
t.Error("cwd cheatpath should not be added when no .cheat exists")
}
}
}
// TestConfigLocalCheatpathFileSkipped tests that a .cheat file (not dir) is skipped
func TestConfigLocalCheatpathFileSkipped(t *testing.T) {
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
// Create .cheat as a file, not a directory
if err := os.WriteFile(filepath.Join(tempDir, ".cheat"), []byte("not a dir"), 0644); err != nil {
t.Fatalf("failed to create .cheat file: %v", err)
}
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("failed to chdir: %v", err)
}
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
for _, cp := range conf.Cheatpaths {
if cp.Name == "cwd" {
t.Error("cwd cheatpath should not be added for a .cheat file")
}
}
}
// TestConfigDefaults tests default values
func TestConfigDefaults(t *testing.T) {
// Load empty config
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
conf, err := New(mocks.Path("conf/empty.yml"), false)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
@@ -329,13 +92,16 @@ cheatpaths:
}
// Load config with symlink resolution
conf, err := New(map[string]interface{}{}, configFile, true)
conf, err := New(configFile, true)
if err != nil {
t.Errorf("failed to load config: %v", err)
}
// Verify symlink was resolved
if len(conf.Cheatpaths) > 0 && conf.Cheatpaths[0].Path != targetDir {
if len(conf.Cheatpaths) == 0 {
t.Fatal("expected at least one cheatpath, got none")
}
if conf.Cheatpaths[0].Path != targetDir {
t.Errorf("expected symlink to be resolved to %s, got %s", targetDir, conf.Cheatpaths[0].Path)
}
}
@@ -372,7 +138,7 @@ cheatpaths:
// Load config with symlink resolution should skip the broken cheatpath
// (warn to stderr) rather than hard-error
conf, err := New(map[string]interface{}{}, configFile, true)
conf, err := New(configFile, true)
if err != nil {
t.Errorf("expected no error for broken symlink (should skip), got: %v", err)
}
@@ -380,70 +146,3 @@ cheatpaths:
t.Errorf("expected broken cheatpath to be filtered out, got %d cheatpaths", len(conf.Cheatpaths))
}
}
// TestConfigTildeExpansionError tests tilde expansion error handling
func TestConfigTildeExpansionError(t *testing.T) {
// This is tricky to test without mocking homedir.Expand
// We'll create a config with an invalid home reference
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
defer os.RemoveAll(tempDir)
// Create config with user that likely doesn't exist
configContent := `---
editor: vim
cheatpaths:
- name: test
path: ~nonexistentuser12345/cheat
readonly: true
`
configFile := filepath.Join(tempDir, "config.yml")
err = os.WriteFile(configFile, []byte(configContent), 0644)
if err != nil {
t.Fatalf("failed to write config: %v", err)
}
// Load config - this may or may not fail depending on the system
// but we're testing that it doesn't panic
_, _ = New(map[string]interface{}{}, configFile, false)
}
// TestConfigGetCwdError tests error handling when os.Getwd fails
func TestConfigGetCwdError(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows does not allow removing the current directory")
}
// This is difficult to test without being able to break os.Getwd
// We'll create a scenario where the current directory is removed
// Create and enter a temp directory
tempDir, err := os.MkdirTemp("", "cheat-config-test-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
oldCwd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get cwd: %v", err)
}
defer os.Chdir(oldCwd)
err = os.Chdir(tempDir)
if err != nil {
t.Fatalf("failed to change dir: %v", err)
}
// Remove the directory we're in
err = os.RemoveAll(tempDir)
if err != nil {
t.Fatalf("failed to remove temp dir: %v", err)
}
// Now os.Getwd should fail
_, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
// This might not fail on all systems, so we just ensure no panic
_ = err
}

View File

@@ -65,58 +65,3 @@ func FuzzFindLocalCheatpath(f *testing.F) {
}
})
}
// FuzzFindLocalCheatpathNearestWins verifies that when two .cheat directories
// exist at different levels of the ancestor chain, the nearest one is returned.
func FuzzFindLocalCheatpathNearestWins(f *testing.F) {
f.Add(uint8(5), uint8(1), uint8(3))
f.Add(uint8(8), uint8(0), uint8(7))
f.Add(uint8(3), uint8(0), uint8(2))
f.Add(uint8(10), uint8(2), uint8(8))
f.Fuzz(func(t *testing.T, totalDepth, shallowRaw, deepRaw uint8) {
depth := int(totalDepth%12) + 2 // 2..13 (need room for two placements)
s := int(shallowRaw) % depth
d := int(deepRaw) % depth
// Need two distinct levels
if s == d {
d = (d + 1) % depth
}
// Ensure s < d (shallow is higher in tree, deep is closer to search dir)
if s > d {
s, d = d, s
}
tempDir := t.TempDir()
// Build chain
dirs := make([]string, 0, depth+1)
dirs = append(dirs, tempDir)
current := tempDir
for i := 0; i < depth; i++ {
current = filepath.Join(current, fmt.Sprintf("d%d", i))
if err := os.Mkdir(current, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
dirs = append(dirs, current)
}
// Place .cheat at both levels
shallowCheat := filepath.Join(dirs[s], ".cheat")
deepCheat := filepath.Join(dirs[d], ".cheat")
if err := os.Mkdir(shallowCheat, 0755); err != nil {
t.Fatalf("mkdir shallow .cheat: %v", err)
}
if err := os.Mkdir(deepCheat, 0755); err != nil {
t.Fatalf("mkdir deep .cheat: %v", err)
}
// Search from the deepest directory — should find the deeper (nearer) .cheat
result := findLocalCheatpath(current)
if result != deepCheat {
t.Errorf("depth=%d shallow=%d deep=%d: expected nearest %s, got %s",
depth, s, d, deepCheat, result)
}
})
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/mitchellh/go-homedir"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/mock"
"github.com/cheat/cheat/mocks"
)
// TestFindLocalCheatpathInCurrentDir tests that .cheat in the given dir is found
@@ -286,7 +286,7 @@ func TestConfigSuccessful(t *testing.T) {
}()
// initialize a config
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
conf, err := New(mocks.Path("conf/conf.yml"), false)
if err != nil {
t.Errorf("failed to parse config file: %v", err)
}
@@ -306,18 +306,18 @@ func TestConfigSuccessful(t *testing.T) {
}
// assert that the cheatpaths are correct
want := []cheatpath.Cheatpath{
cheatpath.Cheatpath{
want := []cheatpath.Path{
cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "community"),
ReadOnly: true,
Tags: []string{"community"},
},
cheatpath.Cheatpath{
cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "work"),
ReadOnly: false,
Tags: []string{"work"},
},
cheatpath.Cheatpath{
cheatpath.Path{
Path: filepath.Join(home, ".dotfiles", "cheat", "personal"),
ReadOnly: false,
Tags: []string{"personal"},
@@ -338,7 +338,7 @@ func TestConfigSuccessful(t *testing.T) {
func TestConfigFailure(t *testing.T) {
// attempt to read a non-existent config file
_, err := New(map[string]interface{}{}, "/does-not-exit", false)
_, err := New("/does-not-exit", false)
if err == nil {
t.Errorf("failed to error on unreadable config")
}
@@ -358,7 +358,7 @@ func TestEditorEnvOverride(t *testing.T) {
// with no env vars, the config file value should be used
os.Unsetenv("VISUAL")
os.Unsetenv("EDITOR")
conf, err := New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
conf, err := New(mocks.Path("conf/conf.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
@@ -368,7 +368,7 @@ func TestEditorEnvOverride(t *testing.T) {
// $EDITOR should override the config file value
os.Setenv("EDITOR", "nano")
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
conf, err = New(mocks.Path("conf/conf.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
@@ -378,7 +378,7 @@ func TestEditorEnvOverride(t *testing.T) {
// $VISUAL should override both $EDITOR and the config file value
os.Setenv("VISUAL", "emacs")
conf, err = New(map[string]interface{}{}, mock.Path("conf/conf.yml"), false)
conf, err = New(mocks.Path("conf/conf.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
@@ -401,7 +401,7 @@ func TestEditorEnvFallback(t *testing.T) {
// set $EDITOR and assert it's used when config has no editor
os.Unsetenv("VISUAL")
os.Setenv("EDITOR", "foo")
conf, err := New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
conf, err := New(mocks.Path("conf/empty.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}
@@ -411,7 +411,7 @@ func TestEditorEnvFallback(t *testing.T) {
// set $VISUAL and assert it takes precedence over $EDITOR
os.Setenv("VISUAL", "bar")
conf, err = New(map[string]interface{}{}, mock.Path("conf/empty.yml"), false)
conf, err = New(mocks.Path("conf/empty.yml"), false)
if err != nil {
t.Fatalf("failed to init configs: %v", err)
}

View File

@@ -1,52 +0,0 @@
// Package config manages application configuration and settings.
//
// The config package provides functionality to:
// - Load configuration from YAML files
// - Validate configuration values
// - Manage platform-specific configuration paths
// - Handle editor and pager settings
// - Configure colorization and formatting options
//
// # Configuration Structure
//
// The main configuration file (conf.yml) contains:
// - Editor preferences
// - Pager settings
// - Colorization options
// - Cheatpath definitions
// - Formatting preferences
//
// Example configuration:
//
// ---
// editor: vim
// colorize: true
// style: monokai
// formatter: terminal256
// pager: less -FRX
// cheatpaths:
// - name: personal
// path: ~/cheat
// tags: []
// readonly: false
// - name: community
// path: ~/cheat/.cheat
// tags: [community]
// readonly: true
//
// # Platform-Specific Paths
//
// The package automatically detects configuration paths based on the operating system:
// - Linux/Unix: $XDG_CONFIG_HOME/cheat/conf.yml or ~/.config/cheat/conf.yml
// - macOS: ~/Library/Application Support/cheat/conf.yml
// - Windows: %APPDATA%\cheat\conf.yml
//
// # Environment Variables
//
// The following environment variables are respected:
// - CHEAT_CONFIG_PATH: Override the configuration file location
// - CHEAT_USE_FZF: Enable fzf integration when set to "true"
// - EDITOR: Default editor if not specified in config
// - VISUAL: Fallback editor if EDITOR is not set
// - PAGER: Default pager if not specified in config
package config

20
internal/config/env.go Normal file
View File

@@ -0,0 +1,20 @@
package config
import (
"os"
"runtime"
"strings"
)
// EnvVars reads environment variables into a map of strings.
func EnvVars() map[string]string {
envvars := map[string]string{}
for _, e := range os.Environ() {
pair := strings.SplitN(e, "=", 2)
if runtime.GOOS == "windows" {
pair[0] = strings.ToUpper(pair[0])
}
envvars[pair[0]] = pair[1]
}
return envvars
}

View File

@@ -4,7 +4,6 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -90,9 +89,6 @@ func TestInitWriteError(t *testing.T) {
if err == nil {
t.Error("expected error when writing to invalid path, got nil")
}
if err != nil && !strings.Contains(err.Error(), "failed to create") {
t.Errorf("expected 'failed to create' error, got: %v", err)
}
}
// TestInitExistingFile tests that Init overwrites existing files

147
internal/config/new.go Normal file
View File

@@ -0,0 +1,147 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/mitchellh/go-homedir"
"gopkg.in/yaml.v3"
)
// New returns a new Config struct
func New(confPath string, resolve bool) (Config, error) {
// read the config file
buf, err := os.ReadFile(confPath)
if err != nil {
return Config{}, fmt.Errorf("could not read config file: %v", err)
}
// initialize a config object
conf := Config{}
// store the config path
conf.Path = confPath
// unmarshal the yaml
err = yaml.Unmarshal(buf, &conf)
if err != nil {
return Config{}, fmt.Errorf("could not unmarshal yaml: %v", err)
}
// if a .cheat directory exists in the current directory or any ancestor,
// append it to the cheatpaths
cwd, err := os.Getwd()
if err != nil {
return Config{}, fmt.Errorf("failed to get cwd: %v", err)
}
if local := findLocalCheatpath(cwd); local != "" {
path := cp.Path{
Name: "cwd",
Path: local,
ReadOnly: false,
Tags: []string{},
}
conf.Cheatpaths = append(conf.Cheatpaths, path)
}
// process cheatpaths
var validPaths []cp.Path
for _, cheatpath := range conf.Cheatpaths {
// expand ~ in config paths
expanded, err := homedir.Expand(cheatpath.Path)
if err != nil {
return Config{}, fmt.Errorf("failed to expand ~: %v", err)
}
// follow symlinks
//
// NB: `resolve` is an ugly kludge that exists for the sake of unit-tests.
// It's necessary because `EvalSymlinks` will error if the symlink points
// to a non-existent location on the filesystem. When unit-testing,
// however, we don't want to have dependencies on the filesystem. As such,
// `resolve` is a switch that allows us to turn off symlink resolution when
// running the config tests.
if resolve {
evaled, err := filepath.EvalSymlinks(expanded)
if err != nil {
// if the path simply doesn't exist, warn and skip it
if os.IsNotExist(err) {
fmt.Fprintf(os.Stderr,
"WARNING: cheatpath '%s' does not exist, skipping\n",
expanded,
)
continue
}
return Config{}, fmt.Errorf(
"failed to resolve symlink: %s: %v",
expanded,
err,
)
}
expanded = evaled
}
cheatpath.Path = expanded
validPaths = append(validPaths, cheatpath)
}
conf.Cheatpaths = validPaths
// determine the editor: env vars override the config file value,
// following standard Unix convention (see #589)
if v := os.Getenv("VISUAL"); v != "" {
conf.Editor = v
} else if v := os.Getenv("EDITOR"); v != "" {
conf.Editor = v
} else {
conf.Editor = strings.TrimSpace(conf.Editor)
}
// if an editor was still not determined, attempt to choose one
// that's appropriate for the environment
if conf.Editor == "" {
if conf.Editor, err = Editor(); err != nil {
return Config{}, err
}
}
// 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 = "terminal"
}
// load the pager
conf.Pager = strings.TrimSpace(conf.Pager)
return conf, nil
}
// findLocalCheatpath walks upward from dir looking for a .cheat directory.
// It returns the path to the first .cheat directory found, or an empty string
// if none exists. This mirrors the discovery pattern used by git for .git
// directories.
func findLocalCheatpath(dir string) string {
for {
candidate := filepath.Join(dir, ".cheat")
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}

View File

@@ -38,7 +38,7 @@ cheatpaths:
}
// Load the config
conf, err := New(map[string]interface{}{}, configPath, false)
conf, err := New(configPath, false)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}
@@ -88,7 +88,7 @@ cheatpaths:
}
// Load the config
conf, err := New(map[string]interface{}{}, configPath, false)
conf, err := New(configPath, false)
if err != nil {
// It's OK if this fails due to no editor being found
// The important thing is it doesn't panic
@@ -123,7 +123,7 @@ cheatpaths:
}
// Load the config
conf, err := New(map[string]interface{}{}, configPath, false)
conf, err := New(configPath, false)
if err != nil {
t.Fatalf("failed to load config: %v", err)
}

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
@@ -44,29 +45,20 @@ func TestPager(t *testing.T) {
os.Setenv("PAGER", "")
pager := Pager()
// Should find one of the fallback pagers or return empty string
if pager == "" {
return // no pager found is acceptable
}
// Should find one of the known fallback pagers
validPagers := map[string]bool{
"": true, // no pager found
"pager": true,
"less": true,
"more": true,
}
// Check if it's a path to one of these
found := false
for p := range validPagers {
if p == "" && pager == "" {
found = true
break
}
if p != "" && (pager == p || len(pager) >= len(p) && pager[len(pager)-len(p):] == p) {
found = true
break
}
}
if !found {
t.Errorf("unexpected pager value: %s", pager)
base := filepath.Base(pager)
if !validPagers[base] {
t.Errorf("unexpected pager value: %s (base: %s)", pager, base)
}
})

View File

@@ -14,8 +14,8 @@ func TestValidateCorrect(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
@@ -38,8 +38,8 @@ func TestInvalidateMissingEditor(t *testing.T) {
conf := Config{
Colorize: true,
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
@@ -71,19 +71,28 @@ func TestInvalidateMissingCheatpaths(t *testing.T) {
}
}
// TestMissingInvalidFormatters asserts that configs which contain invalid
// TestInvalidateInvalidFormatter asserts that configs which contain invalid
// formatters are invalidated
func TestMissingInvalidFormatters(t *testing.T) {
func TestInvalidateInvalidFormatter(t *testing.T) {
// mock a config
// mock a config with a valid editor and cheatpaths but invalid formatter
conf := Config{
Colorize: true,
Editor: "vim",
Formatter: "html",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
},
}
// assert that no errors are returned
// assert that the config is invalidated due to the formatter
if err := conf.Validate(); err == nil {
t.Errorf("failed to invalidate config without formatter")
t.Errorf("failed to invalidate config with invalid formatter")
}
}
@@ -96,14 +105,14 @@ func TestInvalidateDuplicateCheatpathNames(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
cheatpath.Cheatpath{
cheatpath.Path{
Name: "foo",
Path: "/bar",
ReadOnly: false,
@@ -127,14 +136,14 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Cheatpath{
cheatpath.Cheatpath{
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "foo",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
cheatpath.Cheatpath{
cheatpath.Path{
Name: "bar",
Path: "/foo",
ReadOnly: false,
@@ -148,3 +157,28 @@ func TestInvalidateDuplicateCheatpathPaths(t *testing.T) {
t.Errorf("failed to invalidate config with cheatpaths with duplicate paths")
}
}
// TestInvalidateInvalidCheatpath asserts that configs containing a cheatpath
// with an empty name are invalidated
func TestInvalidateInvalidCheatpath(t *testing.T) {
// mock a config with a cheatpath that has an empty name
conf := Config{
Colorize: true,
Editor: "vim",
Formatter: "terminal16m",
Cheatpaths: []cheatpath.Path{
cheatpath.Path{
Name: "",
Path: "/foo",
ReadOnly: false,
Tags: []string{},
},
},
}
// assert that an error is returned
if err := conf.Validate(); err == nil {
t.Errorf("failed to invalidate config with invalid cheatpath (empty name)")
}
}

View File

@@ -1,45 +0,0 @@
// Package display handles output formatting and presentation for the cheat application.
//
// The display package provides utilities for:
// - Writing output to stdout or a pager
// - Formatting text with indentation
// - Creating faint (dimmed) text for de-emphasis
// - Managing colored output
//
// # Pager Integration
//
// The package integrates with system pagers (less, more, etc.) to handle
// long output. If a pager is configured and the output is to a terminal,
// content is automatically piped through the pager.
//
// # Text Formatting
//
// Various formatting utilities are provided:
// - Faint: Creates dimmed text using ANSI escape codes
// - Indent: Adds consistent indentation to text blocks
// - Write: Intelligent output that uses stdout or pager as appropriate
//
// Example Usage
//
// // Write output, using pager if configured
// if err := display.Write(output, config); err != nil {
// log.Fatal(err)
// }
//
// // Create faint text for de-emphasis
// fainted := display.Faint("(read-only)", config)
//
// // Indent a block of text
// indented := display.Indent(text, " ")
//
// # Color Support
//
// The package respects the colorization settings from the config.
// When colorization is disabled, formatting functions like Faint
// return unmodified text.
//
// # Terminal Detection
//
// The package uses isatty to detect if output is to a terminal,
// which affects decisions about using a pager and applying colors.
package display

View File

@@ -2,17 +2,13 @@
// cheatsheet content to stdout, or alternatively the system pager.
package display
import (
"fmt"
"github.com/cheat/cheat/internal/config"
)
import "fmt"
// Faint returns a faintly-colored string that's used to de-prioritize text
// written to stdout
func Faint(str string, conf config.Config) string {
func Faint(str string, colorize bool) string {
// make `str` faint only if colorization has been requested
if conf.Colorize {
if colorize {
return fmt.Sprintf("\033[2m%s\033[0m", str)
}

View File

@@ -1,26 +1,20 @@
package display
import (
"testing"
"github.com/cheat/cheat/internal/config"
)
import "testing"
// TestFaint asserts that Faint applies faint formatting
func TestFaint(t *testing.T) {
// case: apply colorization
conf := config.Config{Colorize: true}
want := "\033[2mfoo\033[0m"
got := Faint("foo", conf)
got := Faint("foo", true)
if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got)
}
// case: do not apply colorization
conf.Colorize = false
want = "foo"
got = Faint("foo", conf)
got = Faint("foo", false)
if want != got {
t.Errorf("failed to faint: want: %s, got: %s", want, got)
}

View File

@@ -10,3 +10,13 @@ func TestIndent(t *testing.T) {
t.Errorf("failed to indent: want: %s, got: %s", want, got)
}
}
// TestIndentTrimsWhitespace asserts that Indent trims leading and trailing
// whitespace before indenting
func TestIndentTrimsWhitespace(t *testing.T) {
got := Indent(" foo\nbar\nbaz \n")
want := "\tfoo\n\tbar\n\tbaz\n"
if got != want {
t.Errorf("failed to trim and indent: want: %q, got: %q", want, got)
}
}

View File

@@ -24,7 +24,7 @@ func Write(out string, conf config.Config) {
// writeToPager writes output through a pager command
func writeToPager(out string, conf config.Config) {
parts := strings.Split(conf.Pager, " ")
parts := strings.Fields(conf.Pager)
pager := parts[0]
args := parts[1:]

View File

@@ -2,7 +2,6 @@ package installer
import (
"bytes"
"fmt"
"io"
"os"
"strings"
@@ -158,23 +157,3 @@ func TestPromptError(t *testing.T) {
t.Errorf("expected 'failed to prompt' error, got: %v", err)
}
}
// TestPromptIntegration provides a simple integration test
func TestPromptIntegration(t *testing.T) {
// This demonstrates how the prompt would be used in practice
// It's skipped by default since it requires actual user input
if os.Getenv("TEST_INTERACTIVE") != "1" {
t.Skip("Skipping interactive test - set TEST_INTERACTIVE=1 to run")
}
fmt.Println("\n=== Interactive Prompt Test ===")
fmt.Println("You will be prompted to answer a question.")
fmt.Println("Try different inputs: y, n, Y, N, empty (just press Enter)")
result, err := Prompt("Would you like to continue? [Y/n]", true)
if err != nil {
t.Fatalf("Prompt failed: %v", err)
}
fmt.Printf("You answered: %v\n", result)
}

View File

@@ -3,8 +3,6 @@ package installer
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config"
"github.com/cheat/cheat/internal/repo"
@@ -13,27 +11,11 @@ import (
// Run runs the installer
func Run(configs string, confpath string) error {
// determine the appropriate paths for config data and (optional) community
// cheatsheets based on the user's platform
confdir := filepath.Dir(confpath)
// expand template placeholders with platform-appropriate paths
configs = ExpandTemplate(configs, confpath)
// create paths for community, personal, and work cheatsheets
community := filepath.Join(confdir, "cheatsheets", "community")
personal := filepath.Join(confdir, "cheatsheets", "personal")
work := filepath.Join(confdir, "cheatsheets", "work")
// set default cheatpaths
configs = strings.Replace(configs, "COMMUNITY_PATH", community, -1)
configs = strings.Replace(configs, "PERSONAL_PATH", personal, -1)
configs = strings.Replace(configs, "WORK_PATH", work, -1)
// locate and set a default pager
configs = strings.Replace(configs, "PAGER_PATH", config.Pager(), -1)
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.Replace(configs, "EDITOR_PATH", editor, -1)
}
// determine cheatsheet directory paths
community, personal, work := cheatsheetDirs(confpath)
// prompt the user to download the community cheatsheets
yes, err := Prompt(
@@ -51,19 +33,7 @@ func Run(configs string, confpath string) error {
return fmt.Errorf("failed to clone cheatsheets: %v", err)
}
} else {
// comment out the community cheatpath in the config since
// the directory won't exist
configs = strings.Replace(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
-1,
)
configs = CommentCommunity(configs, confpath)
}
// always create personal and work directories

View File

@@ -1,7 +1,6 @@
package installer
import (
"fmt"
"io"
"os"
"path/filepath"
@@ -245,10 +244,10 @@ cheatpaths:
if strings.Contains(contentStr, "PERSONAL_PATH") {
t.Error("PERSONAL_PATH was not replaced")
}
if strings.Contains(contentStr, "EDITOR_PATH") && !strings.Contains(contentStr, fmt.Sprintf("editor: %s", "")) {
if strings.Contains(contentStr, "EDITOR_PATH") {
t.Error("EDITOR_PATH was not replaced")
}
if strings.Contains(contentStr, "PAGER_PATH") && !strings.Contains(contentStr, fmt.Sprintf("pager: %s", "")) {
if strings.Contains(contentStr, "PAGER_PATH") {
t.Error("PAGER_PATH was not replaced")
}
if strings.Contains(contentStr, "WORK_PATH") {

View File

@@ -0,0 +1,58 @@
package installer
import (
"path/filepath"
"strings"
"github.com/cheat/cheat/internal/config"
)
// cheatsheetDirs returns the community, personal, and work cheatsheet
// directory paths derived from a config file path.
func cheatsheetDirs(confpath string) (community, personal, work string) {
confdir := filepath.Dir(confpath)
community = filepath.Join(confdir, "cheatsheets", "community")
personal = filepath.Join(confdir, "cheatsheets", "personal")
work = filepath.Join(confdir, "cheatsheets", "work")
return
}
// ExpandTemplate replaces placeholder tokens in the config template with
// platform-appropriate paths derived from confpath.
func ExpandTemplate(configs string, confpath string) string {
community, personal, work := cheatsheetDirs(confpath)
// substitute paths
configs = strings.ReplaceAll(configs, "COMMUNITY_PATH", community)
configs = strings.ReplaceAll(configs, "PERSONAL_PATH", personal)
configs = strings.ReplaceAll(configs, "WORK_PATH", work)
// locate and set a default pager
configs = strings.ReplaceAll(configs, "PAGER_PATH", config.Pager())
// locate and set a default editor
if editor, err := config.Editor(); err == nil {
configs = strings.ReplaceAll(configs, "EDITOR_PATH", editor)
}
return configs
}
// CommentCommunity comments out the community cheatpath block in the config
// template. This is used when the community cheatsheets directory won't exist
// (either because the user declined to download them, or because the config
// is being output as an example).
func CommentCommunity(configs string, confpath string) string {
community, _, _ := cheatsheetDirs(confpath)
return strings.ReplaceAll(configs,
" - name: community\n"+
" path: "+community+"\n"+
" tags: [ community ]\n"+
" readonly: true",
" #- name: community\n"+
" # path: "+community+"\n"+
" # tags: [ community ]\n"+
" # readonly: true",
)
}

View File

@@ -1,29 +0,0 @@
// Package mock implements mock functions used in unit-tests.
package mock
import (
"fmt"
"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 mock path
file, err := filepath.Abs(
filepath.Join(
filepath.Dir(thisfile),
"../../mocks",
filename,
),
)
if err != nil {
panic(fmt.Errorf("failed to resolve mock path: %v", err))
}
return file
}

View File

@@ -1 +0,0 @@
package repo

View File

@@ -1,6 +1,7 @@
package sheet
import (
"strings"
"testing"
"github.com/cheat/cheat/internal/config"
@@ -16,45 +17,78 @@ func TestColorize(t *testing.T) {
}
// mock a sheet
original := "echo 'foo'"
s := Sheet{
Text: "echo 'foo'",
Text: original,
}
// colorize the sheet text
s.Colorize(conf)
// initialize expectations
want := "echo"
want += " 'foo'"
// assert that the text was modified (colorization applied)
if s.Text == original {
t.Error("Colorize did not modify sheet text")
}
// assert
if s.Text != want {
t.Errorf("failed to colorize sheet: want: %s, got: %s", want, s.Text)
// assert that ANSI escape codes are present
if !strings.Contains(s.Text, "\x1b[") && !strings.Contains(s.Text, "[0m") {
t.Errorf("colorized text does not contain ANSI escape codes: %q", s.Text)
}
// assert that the original content is still present within the colorized output
if !strings.Contains(s.Text, "echo") || !strings.Contains(s.Text, "foo") {
t.Errorf("colorized text lost original content: %q", s.Text)
}
}
// TestColorizeError tests the error handling in Colorize
func TestColorizeError(_ *testing.T) {
// Create a sheet with content
sheet := Sheet{
Text: "some text",
Syntax: "invalidlexer12345", // Use an invalid lexer that might cause issues
}
// TestColorizeDefaultSyntax asserts that when no syntax is specified, the
// default ("bash") is used and produces the same output as an explicit "bash"
func TestColorizeDefaultSyntax(t *testing.T) {
// Create a config with invalid formatter/style
conf := config.Config{
Formatter: "invalidformatter",
Style: "invalidstyle",
Formatter: "terminal16m",
Style: "monokai",
}
// Store original text
originalText := sheet.Text
// use bash-specific content that tokenizes differently across lexers
code := "if [[ -f /etc/passwd ]]; then\n echo \"found\" | grep -o found\nfi"
// Colorize should not panic even with invalid settings
sheet.Colorize(conf)
// colorize with empty syntax (should default to "bash")
noSyntax := Sheet{Text: code}
noSyntax.Colorize(conf)
// The text might be unchanged if there was an error, or it might be colorized
// We're mainly testing that it doesn't panic
_ = sheet.Text
_ = originalText
// colorize with explicit "bash" syntax
bashSyntax := Sheet{Text: code, Syntax: "bash"}
bashSyntax.Colorize(conf)
// both should produce the same output
if noSyntax.Text != bashSyntax.Text {
t.Errorf(
"default syntax does not match explicit bash:\ndefault: %q\nexplicit: %q",
noSyntax.Text,
bashSyntax.Text,
)
}
}
// TestColorizeExplicitSyntax asserts that a specified syntax is used
func TestColorizeExplicitSyntax(t *testing.T) {
conf := config.Config{
Formatter: "terminal16m",
Style: "monokai",
}
// colorize as bash
bashSheet := Sheet{Text: "def hello():\n pass", Syntax: "bash"}
bashSheet.Colorize(conf)
// colorize as python
pySheet := Sheet{Text: "def hello():\n pass", Syntax: "python"}
pySheet.Colorize(conf)
// different lexers should produce different output for Python code
if bashSheet.Text == pySheet.Text {
t.Error("bash and python syntax produced identical output")
}
}

View File

@@ -12,13 +12,10 @@ func TestCopyErrors(t *testing.T) {
tests := []struct {
name string
setup func() (*Sheet, string, func())
wantErr bool
errMsg string
}{
{
name: "source file does not exist",
setup: func() (*Sheet, string, func()) {
// Create a sheet with non-existent path
sheet := &Sheet{
Title: "test",
Path: "/non/existent/file.txt",
@@ -30,13 +27,10 @@ func TestCopyErrors(t *testing.T) {
}
return sheet, dest, cleanup
},
wantErr: true,
errMsg: "failed to open cheatsheet",
},
{
name: "destination directory creation fails",
setup: func() (*Sheet, string, func()) {
// Create a source file
src, err := os.CreateTemp("", "copy-test-src-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
@@ -50,13 +44,11 @@ func TestCopyErrors(t *testing.T) {
CheatPath: "test",
}
// Create a file where we want a directory
blockerFile := filepath.Join(os.TempDir(), "copy-blocker-file")
if err := os.WriteFile(blockerFile, []byte("blocker"), 0644); err != nil {
t.Fatalf("failed to create blocker file: %v", err)
}
// Try to create dest under the blocker file (will fail)
dest := filepath.Join(blockerFile, "subdir", "dest.txt")
cleanup := func() {
@@ -65,13 +57,10 @@ func TestCopyErrors(t *testing.T) {
}
return sheet, dest, cleanup
},
wantErr: true,
errMsg: "failed to create directory",
},
{
name: "destination file creation fails",
setup: func() (*Sheet, string, func()) {
// Create a source file
src, err := os.CreateTemp("", "copy-test-src-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
@@ -85,7 +74,6 @@ func TestCopyErrors(t *testing.T) {
CheatPath: "test",
}
// Create a directory where we want the file
destDir := filepath.Join(os.TempDir(), "copy-test-dir")
if err := os.Mkdir(destDir, 0755); err != nil && !os.IsExist(err) {
t.Fatalf("failed to create dest dir: %v", err)
@@ -97,8 +85,6 @@ func TestCopyErrors(t *testing.T) {
}
return sheet, destDir, cleanup
},
wantErr: true,
errMsg: "failed to create outfile",
},
}
@@ -108,43 +94,27 @@ func TestCopyErrors(t *testing.T) {
defer cleanup()
err := sheet.Copy(dest)
if (err != nil) != tt.wantErr {
t.Errorf("Copy() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errMsg != "" {
if !contains(err.Error(), tt.errMsg) {
t.Errorf("Copy() error = %v, want error containing %q", err, tt.errMsg)
}
if err == nil {
t.Error("Copy() expected error, got nil")
}
})
}
}
// TestCopyIOError tests the io.Copy error case
func TestCopyIOError(t *testing.T) {
// This is difficult to test without mocking io.Copy
// The error case would occur if the source file is modified
// or removed after opening but before copying
t.Skip("Skipping io.Copy error test - requires file system race condition")
}
// TestCopyCleanupOnError verifies that partially written files are cleaned up on error
func TestCopyCleanupOnError(t *testing.T) {
// TestCopyUnreadableSource verifies that Copy returns an error when the source
// file cannot be opened (e.g., permission denied).
func TestCopyUnreadableSource(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod does not restrict reads on Windows")
}
// Create a source file that we'll make unreadable after opening
src, err := os.CreateTemp("", "copy-test-cleanup-*")
src, err := os.CreateTemp("", "copy-test-unreadable-*")
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer os.Remove(src.Name())
// Write some content
content := "test content for cleanup"
if _, err := src.WriteString(content); err != nil {
if _, err := src.WriteString("test content"); err != nil {
t.Fatalf("failed to write content: %v", err)
}
src.Close()
@@ -155,38 +125,21 @@ func TestCopyCleanupOnError(t *testing.T) {
CheatPath: "test",
}
// Destination path
dest := filepath.Join(os.TempDir(), "copy-cleanup-test.txt")
defer os.Remove(dest) // Clean up if test fails
dest := filepath.Join(os.TempDir(), "copy-unreadable-test.txt")
defer os.Remove(dest)
// Make the source file unreadable (simulating a read error during copy)
// This is platform-specific, but should work on Unix-like systems
if err := os.Chmod(src.Name(), 0000); err != nil {
t.Skip("Cannot change file permissions on this platform")
}
defer os.Chmod(src.Name(), 0644) // Restore permissions for cleanup
defer os.Chmod(src.Name(), 0644)
// Attempt to copy - this should fail during io.Copy
err = sheet.Copy(dest)
if err == nil {
t.Error("Expected Copy to fail with permission error")
t.Error("expected Copy to fail with permission error")
}
// Verify the destination file was cleaned up
// Destination should not exist since the error occurs before it is created
if _, err := os.Stat(dest); !os.IsNotExist(err) {
t.Error("Destination file should have been removed after copy failure")
t.Error("destination file should not exist after open failure")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
}
func containsHelper(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View File

@@ -1,65 +0,0 @@
// Package sheet provides functionality for parsing and managing individual cheat sheets.
//
// A sheet represents a single cheatsheet file containing helpful commands, notes,
// or documentation. Sheets can include optional YAML frontmatter for metadata
// such as tags and syntax highlighting preferences.
//
// # Sheet Format
//
// Sheets are plain text files that may begin with YAML frontmatter:
//
// ---
// syntax: bash
// tags: [networking, linux, ssh]
// ---
// # Connect to remote server
// ssh user@hostname
//
// # Copy files over SSH
// scp local_file user@hostname:/remote/path
//
// The frontmatter is optional. If omitted, the sheet will use default values.
//
// # Core Types
//
// The Sheet type contains:
// - Title: The sheet's name (derived from filename)
// - Path: Full filesystem path to the sheet
// - Text: The content of the sheet (without frontmatter)
// - Tags: Categories assigned to the sheet
// - Syntax: Language hint for syntax highlighting
// - ReadOnly: Whether the sheet can be modified
//
// Key Functions
//
// - New: Creates a new Sheet from a file path
// - Parse: Extracts frontmatter and content from sheet text
// - Search: Searches sheet content using regular expressions
// - Colorize: Applies syntax highlighting to sheet content
//
// # Syntax Highlighting
//
// The package integrates with the Chroma library to provide syntax highlighting.
// Supported languages include bash, python, go, javascript, and many others.
// The syntax can be specified in the frontmatter or auto-detected.
//
// Example Usage
//
// // Load a sheet from disk
// s, err := sheet.New("/path/to/sheet", []string{"personal"}, false)
// if err != nil {
// log.Fatal(err)
// }
//
// // Search for content
// matches, err := s.Search("ssh", false)
// if err != nil {
// log.Fatal(err)
// }
//
// // Apply syntax highlighting
// colorized, err := s.Colorize(config)
// if err != nil {
// log.Fatal(err)
// }
package sheet

View File

@@ -27,22 +27,3 @@ func TestParseWindowsLineEndings(t *testing.T) {
t.Errorf("failed to parse syntax: want: %s, got: %s", want, fm.Syntax)
}
}
// TestParseInvalidYAML tests parsing with invalid YAML in frontmatter
func TestParseInvalidYAML(t *testing.T) {
// stub our cheatsheet content with invalid YAML
markdown := `---
syntax: go
tags: [ test
unclosed bracket
---
To foo the bar: baz`
// parse the frontmatter
_, _, err := parse(markdown)
// assert that an error was returned for invalid YAML
if err == nil {
t.Error("expected error for invalid YAML, got nil")
}
}

View File

@@ -38,7 +38,7 @@ To foo the bar: baz`
t.Errorf("failed to parse tags: want: %s, got: %s", want, fm.Tags[0])
}
if len(fm.Tags) != 1 {
t.Errorf("failed to parse tags: want: len 0, got: len %d", len(fm.Tags))
t.Errorf("failed to parse tags: want: len 1, got: len %d", len(fm.Tags))
}
}
@@ -93,3 +93,20 @@ To foo the bar: baz`
t.Errorf("failed to parse text: want: %s, got: %s", markdown, text)
}
}
// TestHasMalformedYAML asserts that an error is returned when the frontmatter
// contains invalid YAML that cannot be unmarshalled
func TestHasMalformedYAML(t *testing.T) {
// stub cheatsheet content with syntactically invalid YAML between the
// delimiters (a bare tab character followed by unquoted colon)
markdown := "---\n\t:\t:\n---\nBody text here"
// parse the frontmatter
_, _, err := parse(markdown)
// assert that an error was returned due to YAML unmarshal failure
if err == nil {
t.Error("failed to error on malformed YAML frontmatter")
}
}

View File

@@ -122,69 +122,3 @@ func FuzzSearchRegex(f *testing.F) {
}
})
}
// FuzzSearchCatastrophicBacktracking specifically tests for regex patterns
// that could cause performance issues
func FuzzSearchCatastrophicBacktracking(f *testing.F) {
// Seed with patterns known to potentially cause issues
f.Add("a", 10, 5)
f.Add("x", 20, 3)
f.Fuzz(func(t *testing.T, char string, repeats int, groups int) {
// Limit the size to avoid memory issues in the test
if repeats > 30 || repeats < 0 || groups > 10 || groups < 0 || len(char) > 5 {
t.Skip("Skipping invalid or overly large test case")
}
// Construct patterns that might cause backtracking
patterns := []string{
strings.Repeat(char, repeats),
"(" + char + "+)+",
"(" + char + "*)*",
"(" + char + "|" + char + ")*",
}
// Add nested groups
if groups > 0 && groups < 10 {
nested := char
for i := 0; i < groups; i++ {
nested = "(" + nested + ")+"
}
patterns = append(patterns, nested)
}
// Test text that might trigger backtracking
testText := strings.Repeat(char, repeats) + "x"
for _, pattern := range patterns {
// Try to compile the pattern
reg, err := regexp.Compile(pattern)
if err != nil {
// Invalid pattern, skip
continue
}
// Test with timeout
done := make(chan bool, 1)
go func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Search panicked with backtracking pattern %q: %v", pattern, r)
}
done <- true
}()
sheet := Sheet{Text: testText}
_ = sheet.Search(reg)
}()
select {
case <-done:
// Completed successfully
case <-time.After(50 * time.Millisecond):
t.Logf("Warning: potential backtracking issue with pattern %q (completed slowly)", pattern)
}
}
})
}

View File

@@ -4,7 +4,7 @@ import (
"reflect"
"testing"
"github.com/cheat/cheat/internal/mock"
"github.com/cheat/cheat/mocks"
)
// TestSheetSuccess asserts that sheets initialize properly
@@ -14,7 +14,7 @@ func TestSheetSuccess(t *testing.T) {
sheet, err := New(
"foo",
"community",
mock.Path("sheet/foo"),
mocks.Path("sheet/foo"),
[]string{"alpha", "bravo"},
false,
)
@@ -27,10 +27,10 @@ func TestSheetSuccess(t *testing.T) {
t.Errorf("failed to init title: want: foo, got: %s", sheet.Title)
}
if sheet.Path != mock.Path("sheet/foo") {
if sheet.Path != mocks.Path("sheet/foo") {
t.Errorf(
"failed to init path: want: %s, got: %s",
mock.Path("sheet/foo"),
mocks.Path("sheet/foo"),
sheet.Path,
)
}
@@ -63,7 +63,7 @@ func TestSheetFailure(t *testing.T) {
_, err := New(
"foo",
"community",
mock.Path("/does-not-exist"),
mocks.Path("/does-not-exist"),
[]string{"alpha", "bravo"},
false,
)
@@ -80,7 +80,7 @@ func TestSheetFrontMatterFailure(t *testing.T) {
_, err := New(
"foo",
"community",
mock.Path("sheet/bad-fm"),
mocks.Path("sheet/bad-fm"),
[]string{"alpha", "bravo"},
false,
)

View File

@@ -1,15 +1,8 @@
package sheet
import "slices"
// 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
return slices.Contains(s.Tags, needle)
}

View File

@@ -0,0 +1,40 @@
package sheet
import (
"fmt"
"path/filepath"
"strings"
)
// Validate ensures that a cheatsheet name does not contain
// directory traversal sequences or other potentially dangerous patterns.
func Validate(name string) error {
// Reject empty names
if name == "" {
return fmt.Errorf("cheatsheet name cannot be empty")
}
// Reject names containing directory traversal
if strings.Contains(name, "..") {
return fmt.Errorf("cheatsheet name cannot contain '..'")
}
// Reject absolute paths
if filepath.IsAbs(name) {
return fmt.Errorf("cheatsheet name cannot be an absolute path")
}
// Reject names that start with ~ (home directory expansion)
if strings.HasPrefix(name, "~") {
return fmt.Errorf("cheatsheet name cannot start with '~'")
}
// Reject hidden files (files that start with a dot)
// We don't display hidden files, so we shouldn't create them
filename := filepath.Base(name)
if strings.HasPrefix(filename, ".") {
return fmt.Errorf("cheatsheet name cannot start with '.' (hidden files are not supported)")
}
return nil
}

View File

@@ -1,4 +1,4 @@
package cheatpath
package sheet
import (
"strings"
@@ -6,9 +6,9 @@ import (
"unicode/utf8"
)
// FuzzValidateSheetName tests the ValidateSheetName function with fuzzing
// FuzzValidate tests the Validate function with fuzzing
// to ensure it properly prevents path traversal and other security issues
func FuzzValidateSheetName(f *testing.F) {
func FuzzValidate(f *testing.F) {
// Add seed corpus with various valid and malicious inputs
// Valid names
f.Add("docker")
@@ -84,11 +84,11 @@ func FuzzValidateSheetName(f *testing.F) {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("ValidateSheetName panicked with input %q: %v", input, r)
t.Errorf("Validate panicked with input %q: %v", input, r)
}
}()
err := ValidateSheetName(input)
err := Validate(input)
// Security invariants that must always hold
if err == nil {
@@ -129,8 +129,8 @@ func FuzzValidateSheetName(f *testing.F) {
})
}
// FuzzValidateSheetNamePathTraversal specifically targets path traversal bypasses
func FuzzValidateSheetNamePathTraversal(f *testing.F) {
// FuzzValidatePathTraversal specifically targets path traversal bypasses
func FuzzValidatePathTraversal(f *testing.F) {
// Seed corpus focusing on path traversal variations
f.Add("..", "/", "")
f.Add("", "..", "/")
@@ -153,11 +153,11 @@ func FuzzValidateSheetNamePathTraversal(f *testing.F) {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("ValidateSheetName panicked with constructed input %q: %v", input, r)
t.Errorf("Validate panicked with constructed input %q: %v", input, r)
}
}()
err := ValidateSheetName(input)
err := Validate(input)
// If the input contains literal "..", it must be rejected
if strings.Contains(input, "..") && err == nil {

View File

@@ -1,4 +1,4 @@
package cheatpath
package sheet
import (
"runtime"
@@ -6,7 +6,7 @@ import (
"testing"
)
func TestValidateSheetName(t *testing.T) {
func TestValidate(t *testing.T) {
tests := []struct {
name string
input string
@@ -98,14 +98,14 @@ func TestValidateSheetName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSheetName(tt.input)
err := Validate(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
t.Errorf("Validate(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if err != nil && tt.errMsg != "" {
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("ValidateName(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg)
t.Errorf("Validate(%q) error = %v, want error containing %q", tt.input, err, tt.errMsg)
}
}
})

View File

@@ -1,65 +0,0 @@
// Package sheets manages collections of cheat sheets across multiple cheatpaths.
//
// The sheets package provides functionality to:
// - Load sheets from multiple cheatpaths
// - Consolidate duplicate sheets (with precedence rules)
// - Filter sheets by tags
// - Sort sheets alphabetically
// - Extract unique tags across all sheets
//
// # Loading Sheets
//
// Sheets are loaded recursively from cheatpath directories, excluding:
// - Hidden files (starting with .)
// - Files in .git directories
// - Files with extensions (sheets have no extension)
//
// # Consolidation
//
// When multiple cheatpaths contain sheets with the same name, consolidation
// rules apply based on the order of cheatpaths. Sheets from earlier paths
// override those from later paths, allowing personal sheets to override
// community sheets.
//
// Example:
//
// cheatpaths:
// 1. personal: ~/cheat
// 2. community: ~/cheat/community
//
// If both contain "git", the version from "personal" is used.
//
// # Filtering
//
// Sheets can be filtered by:
// - Tags: Include only sheets with specific tags
// - Cheatpath: Include only sheets from specific paths
//
// Key Functions
//
// - Load: Loads all sheets from the given cheatpaths
// - Filter: Filters sheets by tag
// - Consolidate: Merges sheets from multiple paths with precedence
// - Sort: Sorts sheets alphabetically by title
// - Tags: Extracts all unique tags from sheets
//
// Example Usage
//
// // Load sheets from all cheatpaths
// allSheets, err := sheets.Load(cheatpaths)
// if err != nil {
// log.Fatal(err)
// }
//
// // Consolidate to handle duplicates
// consolidated := sheets.Consolidate(allSheets)
//
// // Filter by tag
// filtered := sheets.Filter(consolidated, "networking")
//
// // Sort alphabetically
// sheets.Sort(filtered)
//
// // Get all unique tags
// tags := sheets.Tags(consolidated)
package sheets

View File

@@ -18,28 +18,26 @@ func TestFilterSingleTag(t *testing.T) {
map[string]sheet.Sheet{
"foo": sheet.Sheet{Title: "foo", Tags: []string{"alpha", "bravo"}},
"bar": sheet.Sheet{Title: "bar", Tags: []string{"bravo", "charlie"}},
"bar": sheet.Sheet{Title: "bar", Tags: []string{"charlie"}},
},
map[string]sheet.Sheet{
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha", "bravo"}},
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha"}},
"bat": sheet.Sheet{Title: "bat", Tags: []string{"bravo", "charlie"}},
},
}
// filter the cheatsheets
filtered := Filter(cheatpaths, []string{"bravo"})
filtered := Filter(cheatpaths, []string{"alpha"})
// 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"}},
"baz": sheet.Sheet{Title: "baz", Tags: []string{"alpha"}},
},
}

View File

@@ -8,12 +8,11 @@ import (
"strings"
cp "github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/repo"
"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) {
func Load(cheatpaths []cp.Path) ([]map[string]sheet.Sheet, error) {
// create a slice of maps of sheets. This structure will store all sheets
// that are associated with each cheatpath.
@@ -27,10 +26,10 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
// recursively iterate over the cheatpath, and load each cheatsheet
// encountered along the way
err := filepath.Walk(
err := filepath.WalkDir(
cheatpath.Path, func(
path string,
info os.FileInfo,
d fs.DirEntry,
err error) error {
// fail if an error occurred while walking the directory
@@ -38,8 +37,12 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
return fmt.Errorf("failed to walk path: %v", err)
}
// don't register directories as cheatsheets
if info.IsDir() {
if d.IsDir() {
// skip .git directories to avoid hundreds/thousands of
// needless syscalls (see repo.GitDir for full history)
if filepath.Base(path) == ".git" {
return fs.SkipDir
}
return nil
}
@@ -63,17 +66,6 @@ func Load(cheatpaths []cp.Cheatpath) ([]map[string]sheet.Sheet, error) {
string(os.PathSeparator),
)
// Don't walk the `.git` directory. Doing so creates
// hundreds/thousands of needless syscalls and could
// potentially harm performance on machines with slow disks.
skip, err := repo.GitDir(path)
if err != nil {
return fmt.Errorf("failed to identify .git directory: %v", err)
}
if skip {
return fs.SkipDir
}
// parse the cheatsheet file into a `sheet` struct
s, err := sheet.New(
title,

View File

@@ -5,22 +5,22 @@ import (
"testing"
"github.com/cheat/cheat/internal/cheatpath"
"github.com/cheat/cheat/internal/mock"
"github.com/cheat/cheat/mocks"
)
// TestLoad asserts that sheets on valid cheatpaths can be loaded successfully
func TestLoad(t *testing.T) {
// mock cheatpaths
cheatpaths := []cheatpath.Cheatpath{
cheatpaths := []cheatpath.Path{
{
Name: "community",
Path: path.Join(mock.Path("cheatsheets"), "community"),
Path: path.Join(mocks.Path("cheatsheets"), "community"),
ReadOnly: true,
},
{
Name: "personal",
Path: path.Join(mock.Path("cheatsheets"), "personal"),
Path: path.Join(mocks.Path("cheatsheets"), "personal"),
ReadOnly: false,
},
}
@@ -54,7 +54,7 @@ func TestLoad(t *testing.T) {
func TestLoadBadPath(t *testing.T) {
// mock a bad cheatpath
cheatpaths := []cheatpath.Cheatpath{
cheatpaths := []cheatpath.Path{
{
Name: "badpath",
Path: "/cheat/test/path/does/not/exist",

View File

@@ -32,9 +32,7 @@ func Tags(cheatpaths []map[string]sheet.Sheet) []string {
}
// sort the slice
sort.Slice(sorted, func(i, j int) bool {
return sorted[i] < sorted[j]
})
sort.Strings(sorted)
return sorted
}

View File

@@ -127,64 +127,3 @@ func FuzzTags(f *testing.F) {
}()
})
}
// FuzzTagsStress tests Tags function with large numbers of tags
func FuzzTagsStress(f *testing.F) {
// Seed: number of unique tags, number of sheets, tags per sheet
f.Add(10, 10, 5)
f.Add(100, 50, 10)
f.Add(1000, 100, 20)
f.Fuzz(func(t *testing.T, numUniqueTags int, numSheets int, tagsPerSheet int) {
// Limit to reasonable values
if numUniqueTags > 1000 || numUniqueTags < 0 ||
numSheets > 1000 || numSheets < 0 ||
tagsPerSheet > 100 || tagsPerSheet < 0 {
t.Skip("Skipping unreasonable test case")
}
// Generate unique tags
uniqueTags := make([]string, numUniqueTags)
for i := 0; i < numUniqueTags; i++ {
uniqueTags[i] = "tag" + string(rune(i))
}
// Create sheets with random tags
cheatpaths := []map[string]sheet.Sheet{
make(map[string]sheet.Sheet),
}
for i := 0; i < numSheets; i++ {
// Select random tags for this sheet
sheetTags := make([]string, 0, tagsPerSheet)
for j := 0; j < tagsPerSheet && j < numUniqueTags; j++ {
// Distribute tags across sheets
tagIndex := (i*tagsPerSheet + j) % numUniqueTags
sheetTags = append(sheetTags, uniqueTags[tagIndex])
}
cheatpaths[0]["sheet"+string(rune(i))] = sheet.Sheet{
Title: "sheet" + string(rune(i)),
Tags: sheetTags,
}
}
// Should handle large numbers efficiently
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("Tags panicked with %d unique tags, %d sheets, %d tags/sheet: %v",
numUniqueTags, numSheets, tagsPerSheet, r)
}
}()
result := Tags(cheatpaths)
// Should have at most numUniqueTags in result
if len(result) > numUniqueTags {
t.Errorf("More tags in result (%d) than unique tags created (%d)",
len(result), numUniqueTags)
}
}()
})
}

23
mocks/path.go Normal file
View File

@@ -0,0 +1,23 @@
// Package mocks provides test fixture data and helpers for unit tests.
package mocks
import (
"fmt"
"path/filepath"
"runtime"
)
// Path returns the absolute path to the specified mock file within
// the mocks/ directory.
func Path(filename string) string {
_, thisfile, _, _ := runtime.Caller(0)
file, err := filepath.Abs(
filepath.Join(filepath.Dir(thisfile), filename),
)
if err != nil {
panic(fmt.Errorf("failed to resolve mock path: %v", err))
}
return file
}

View File

@@ -1,74 +0,0 @@
# cheat(1) completion -*- shell-script -*-
# generate cheatsheet completions, optionally using `fzf`
_cheat_complete_cheatsheets()
{
if [[ "$CHEAT_USE_FZF" = true ]]; then
FZF_COMPLETION_TRIGGER='' _fzf_complete "--no-multi" "$@" < <(
cheat -l | tail -n +2 | cut -d' ' -f1
)
else
COMPREPLY=( $(compgen -W "$(cheat -l | tail -n +2 | cut -d' ' -f1)" -- "$cur") )
fi
}
# generate tag completions, optionally using `fzf`
_cheat_complete_tags()
{
if [ "$CHEAT_USE_FZF" = true ]; then
FZF_COMPLETION_TRIGGER='' _fzf_complete "--no-multi" "$@" < <(cheat -T)
else
COMPREPLY=( $(compgen -W "$(cheat -T)" -- "$cur") )
fi
}
# implement the `cheat` autocompletions
_cheat()
{
local cur prev words cword split
_init_completion -s || return
# complete options that are currently being typed: `--col` => `--colorize`
if [[ $cur == -* ]]; then
COMPREPLY=( $(compgen -W '$(_parse_help "$1" | sed "s/=//g")' -- "$cur") )
[[ $COMPREPLY == *= ]] && compopt -o nospace
return
fi
# implement completions
case $prev in
--colorize|-c|\
--directories|-d|\
--init|\
--regex|-r|\
--search|-s|\
--tags|-T|\
--version|-v)
# noop the above, which should implement no completions
;;
--edit|-e)
_cheat_complete_cheatsheets
;;
--list|-l)
_cheat_complete_cheatsheets
;;
--path|-p)
COMPREPLY=( $(compgen -W "$(cheat -d | cut -d':' -f1)" -- "$cur") )
;;
--rm)
_cheat_complete_cheatsheets
;;
--tag|-t)
_cheat_complete_tags
;;
*)
_cheat_complete_cheatsheets
;;
esac
$split && return
} &&
complete -F _cheat cheat
# ex: filetype=sh

View File

@@ -1,13 +0,0 @@
complete -c cheat -f -a "(cheat -l | tail -n +2 | cut -d ' ' -f 1)"
complete -c cheat -l init -d "Write a default config file to stdout"
complete -c cheat -s c -l colorize -d "Colorize output"
complete -c cheat -s d -l directories -d "List cheatsheet directories"
complete -c cheat -s e -l edit -x -a "(cheat -l | tail -n +2 | cut -d ' ' -f 1)" -d "Edit cheatsheet"
complete -c cheat -s l -l list -d "List cheatsheets"
complete -c cheat -s p -l path -x -a "(cheat -d | cut -d ':' -f 1)" -d "Return only sheets found on given path"
complete -c cheat -s r -l regex -d "Treat search phrase as a regex"
complete -c cheat -s s -l search -x -d "Search cheatsheets for given phrase"
complete -c cheat -s t -l tag -x -a "(cheat -T)" -d "Return only sheets matching the given tag"
complete -c cheat -s T -l tags -d "List all tags in use"
complete -c cheat -s v -l version -d "Print the version number"
complete -c cheat -l rm -x -a "(cheat -l | tail -n +2 | cut -d ' ' -f 1)" -d "Remove (delete) cheatsheet"

View File

@@ -1,65 +0,0 @@
#compdef cheat
local cheats taglist pathlist
_cheat_complete_personal_cheatsheets()
{
cheats=("${(f)$(cheat -l -t personal | tail -n +2 | cut -d' ' -f1)}")
_describe -t cheats 'cheats' cheats
}
_cheat_complete_full_cheatsheets()
{
cheats=("${(f)$(cheat -l | tail -n +2 | cut -d' ' -f1)}")
_describe -t cheats 'cheats' cheats
}
_cheat_complete_tags()
{
taglist=("${(f)$(cheat -T)}")
_describe -t taglist 'taglist' taglist
}
_cheat_complete_paths()
{
pathlist=("${(f)$(cheat -d | cut -d':' -f1)}")
_describe -t pathlist 'pathlist' pathlist
}
_cheat() {
_arguments -C \
'(--init)--init[Write a default config file to stdout]: :->none' \
'(-c --colorize)'{-c,--colorize}'[Colorize output]: :->none' \
'(-d --directories)'{-d,--directories}'[List cheatsheet directories]: :->none' \
'(-e --edit)'{-e,--edit}'[Edit <sheet>]: :->personal' \
'(-l --list)'{-l,--list}'[List cheatsheets]: :->full' \
'(-p --path)'{-p,--path}'[Return only sheets found on path <name>]: :->pathlist' \
'(-r --regex)'{-r,--regex}'[Treat search <phrase> as a regex]: :->none' \
'(-s --search)'{-s,--search}'[Search cheatsheets for <phrase>]: :->none' \
'(-t --tag)'{-t,--tag}'[Return only sheets matching <tag>]: :->taglist' \
'(-T --tags)'{-T,--tags}'[List all tags in use]: :->none' \
'(-v --version)'{-v,--version}'[Print the version number]: :->none' \
'(--rm)--rm[Remove (delete) <sheet>]: :->personal'
case $state in
(none)
;;
(full)
_cheat_complete_full_cheatsheets
;;
(personal)
_cheat_complete_personal_cheatsheets
;;
(taglist)
_cheat_complete_tags
;;
(pathlist)
_cheat_complete_paths
;;
(*)
;;
esac
}
compdef _cheat cheat

View File

@@ -16,14 +16,12 @@ DURATION="${1:-15s}"
# Define fuzz tests: "TestName:Package:Description"
TESTS=(
"FuzzParse:./internal/sheet:YAML frontmatter parsing"
"FuzzValidateSheetName:./internal/cheatpath:sheet name validation (path traversal protection)"
"FuzzValidate:./internal/sheet:sheet name validation (path traversal protection)"
"FuzzSearchRegex:./internal/sheet:regex search operations"
"FuzzSearchCatastrophicBacktracking:./internal/sheet:catastrophic backtracking"
"FuzzTagged:./internal/sheet:tag matching with malicious input"
"FuzzFilter:./internal/sheets:tag filtering operations"
"FuzzTags:./internal/sheets:tag aggregation and sorting"
"FuzzFindLocalCheatpath:./internal/config:recursive .cheat directory discovery"
"FuzzFindLocalCheatpathNearestWins:./internal/config:nearest .cheat wins invariant"
)
echo "Running fuzz tests ($DURATION each)..."

View File

@@ -0,0 +1,129 @@
package integration
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestBriefFlagIntegration exercises the -b/--brief flag end-to-end.
func TestBriefFlagIntegration(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("integration test uses Unix-specific env vars")
}
// Build the cheat binary once for all sub-tests.
binPath := filepath.Join(t.TempDir(), "cheat_test")
build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}
// Set up a temp environment with some cheatsheets.
root := t.TempDir()
sheetsDir := filepath.Join(root, "sheets")
os.MkdirAll(sheetsDir, 0755)
os.WriteFile(
filepath.Join(sheetsDir, "tar"),
[]byte("---\nsyntax: bash\ntags: [ compression ]\n---\ntar xf archive.tar\n"),
0644,
)
os.WriteFile(
filepath.Join(sheetsDir, "curl"),
[]byte("---\nsyntax: bash\ntags: [ networking, http ]\n---\ncurl https://example.com\n"),
0644,
)
confPath := filepath.Join(root, "conf.yml")
conf := fmt.Sprintf("---\neditor: vi\ncolorize: false\ncheatpaths:\n - name: test\n path: %s\n readonly: true\n", sheetsDir)
os.WriteFile(confPath, []byte(conf), 0644)
env := []string{
"CHEAT_CONFIG_PATH=" + confPath,
"HOME=" + root,
"PATH=" + os.Getenv("PATH"),
"EDITOR=vi",
}
run := func(t *testing.T, args ...string) string {
t.Helper()
cmd := exec.Command(binPath, args...)
cmd.Dir = root
cmd.Env = env
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("cheat %v failed: %v\nOutput: %s", args, err, output)
}
return string(output)
}
t.Run("brief output omits file path column", func(t *testing.T) {
output := run(t, "-b")
lines := strings.Split(strings.TrimSpace(output), "\n")
// Header should have title and tags but not file
if !strings.Contains(lines[0], "title:") {
t.Errorf("expected title: in header, got: %s", lines[0])
}
if !strings.Contains(lines[0], "tags:") {
t.Errorf("expected tags: in header, got: %s", lines[0])
}
if strings.Contains(lines[0], "file:") {
t.Errorf("brief output should not contain file: column, got: %s", lines[0])
}
// Data lines should not contain the sheets directory path
for _, line := range lines[1:] {
if strings.Contains(line, sheetsDir) {
t.Errorf("brief output should not contain file paths, got: %s", line)
}
}
})
t.Run("list output still includes file path column", func(t *testing.T) {
output := run(t, "-l")
lines := strings.Split(strings.TrimSpace(output), "\n")
if !strings.Contains(lines[0], "file:") {
t.Errorf("list output should contain file: column, got: %s", lines[0])
}
})
t.Run("brief with filter works", func(t *testing.T) {
output := run(t, "-b", "tar")
if !strings.Contains(output, "tar") {
t.Errorf("expected tar in output, got: %s", output)
}
if strings.Contains(output, "curl") {
t.Errorf("filter should exclude curl, got: %s", output)
}
})
t.Run("combined -lb works identically to -b", func(t *testing.T) {
briefOnly := run(t, "-b", "tar")
combined := run(t, "-lb", "tar")
if briefOnly != combined {
t.Errorf("-b and -lb should produce identical output\n-b:\n%s\n-lb:\n%s", briefOnly, combined)
}
})
t.Run("brief with tag filter works", func(t *testing.T) {
output := run(t, "-b", "-t", "networking")
if !strings.Contains(output, "curl") {
t.Errorf("expected curl in tag-filtered output, got: %s", output)
}
if strings.Contains(output, "tar") {
// tar is tagged "compression", not "networking"
t.Errorf("tag filter should exclude tar, got: %s", output)
}
if strings.Contains(output, "file:") {
t.Errorf("brief output should not contain file: column, got: %s", output)
}
})
}

View File

@@ -1,4 +1,4 @@
package main
package integration
import (
"fmt"
@@ -32,7 +32,8 @@ func TestLocalCheatpathIntegration(t *testing.T) {
// Build the cheat binary once for all sub-tests.
binPath := filepath.Join(t.TempDir(), "cheat_test")
build := exec.Command("go", "build", "-o", binPath, ".")
build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}

View File

@@ -1,4 +1,4 @@
package main
package integration
import (
"os"
@@ -19,7 +19,8 @@ func TestFirstRunIntegration(t *testing.T) {
binName += ".exe"
}
binPath := filepath.Join(t.TempDir(), binName)
build := exec.Command("go", "build", "-o", binPath, ".")
build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("failed to build cheat: %v\nOutput: %s", err, output)
}

View File

@@ -0,0 +1,30 @@
package integration
import (
"path/filepath"
"runtime"
"testing"
)
// repoRoot returns the absolute path to the repository root.
// It derives this from the known location of this source file
// (test/integration/) relative to the repo root.
func repoRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("failed to determine repo root via runtime.Caller")
}
// file is <repo>/test/integration/helpers_test.go → go up two dirs
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
}
// repoRootBench is the same as repoRoot but for use in benchmarks.
func repoRootBench(b *testing.B) string {
b.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
b.Fatal("failed to determine repo root via runtime.Caller")
}
return filepath.Dir(filepath.Dir(filepath.Dir(file)))
}

View File

@@ -1,4 +1,4 @@
package main
package integration
import (
"fmt"
@@ -19,7 +19,9 @@ func TestPathTraversalIntegration(t *testing.T) {
// Build the cheat binary
binPath := filepath.Join(t.TempDir(), "cheat_test")
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil {
build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
}
@@ -159,7 +161,9 @@ func TestPathTraversalRealWorld(t *testing.T) {
// Build cheat
binPath := filepath.Join(t.TempDir(), "cheat_test")
if output, err := exec.Command("go", "build", "-o", binPath, ".").CombinedOutput(); err != nil {
build := exec.Command("go", "build", "-o", binPath, "./cmd/cheat")
build.Dir = repoRoot(t)
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("Failed to build: %v\n%s", err, output)
}

View File

@@ -1,6 +1,6 @@
//go:build integration
package main
package integration
import (
"bytes"
@@ -9,7 +9,6 @@ import (
"os/exec"
"path/filepath"
"testing"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
@@ -17,12 +16,10 @@ import (
// BenchmarkSearchCommand benchmarks the actual cheat search command
func BenchmarkSearchCommand(b *testing.B) {
root := repoRootBench(b)
// Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
b.Fatalf("Failed to get root dir: %v", err)
}
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
tmpDir := filepath.Join(root, ".tmp", "bench-test")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err)
}
@@ -35,7 +32,7 @@ func BenchmarkSearchCommand(b *testing.B) {
})
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir
cmd.Dir = root
if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
}
@@ -108,23 +105,15 @@ cheatpaths:
cmd := exec.Command(cheatBin, tc.args...)
cmd.Env = env
// Capture output to prevent spamming
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
start := time.Now()
err := cmd.Run()
elapsed := time.Since(start)
if err != nil {
b.Fatalf("Command failed: %v\nStderr: %s", err, stderr.String())
}
// Report custom metric
b.ReportMetric(float64(elapsed.Nanoseconds())/1e6, "ms/op")
// Ensure we got some results
if stdout.Len() == 0 {
b.Fatal("No output from search")
}
@@ -135,12 +124,10 @@ cheatpaths:
// BenchmarkListCommand benchmarks the list command for comparison
func BenchmarkListCommand(b *testing.B) {
root := repoRootBench(b)
// Build the cheat binary in .tmp (using absolute path)
rootDir, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
b.Fatalf("Failed to get root dir: %v", err)
}
tmpDir := filepath.Join(rootDir, ".tmp", "bench-test")
tmpDir := filepath.Join(root, ".tmp", "bench-test")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
b.Fatalf("Failed to create temp dir: %v", err)
}
@@ -153,7 +140,7 @@ func BenchmarkListCommand(b *testing.B) {
})
cmd := exec.Command("go", "build", "-o", cheatBin, "./cmd/cheat")
cmd.Dir = rootDir
cmd.Dir = root
if output, err := cmd.CombinedOutput(); err != nil {
b.Fatalf("Failed to build cheat: %v\nOutput: %s", err, output)
}

View File

@@ -1,32 +0,0 @@
# Travis CI (http://travis-ci.org/) is a continuous integration
# service for open source projects. This file configures it
# to run unit tests for docopt-go.
language: go
go:
- 1.4
- 1.5
- 1.6
- 1.7
- 1.8
- 1.9
- tip
matrix:
fast_finish: true
before_install:
- go get golang.org/x/tools/cmd/cover
- go get github.com/mattn/goveralls
install:
- go get -d -v ./... && go build -v ./...
script:
- go vet -x ./...
- go test -v ./...
- go test -covermode=count -coverprofile=profile.cov .
after_script:
- $HOME/gopath/bin/goveralls -coverprofile=profile.cov -service=travis-ci

View File

@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2013 Keith Batten
Copyright (c) 2016 David Irvine
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,116 +0,0 @@
docopt-go
=========
[![Build Status](https://travis-ci.org/docopt/docopt.go.svg?branch=master)](https://travis-ci.org/docopt/docopt.go)
[![Coverage Status](https://coveralls.io/repos/github/docopt/docopt.go/badge.svg)](https://coveralls.io/github/docopt/docopt.go)
[![GoDoc](https://godoc.org/github.com/docopt/docopt.go?status.svg)](https://godoc.org/github.com/docopt/docopt.go)
An implementation of [docopt](http://docopt.org/) in the [Go](http://golang.org/) programming language.
**docopt** helps you create *beautiful* command-line interfaces easily:
```go
package main
import (
"fmt"
"github.com/docopt/docopt-go"
)
func main() {
usage := `Naval Fate.
Usage:
naval_fate ship new <name>...
naval_fate ship <name> move <x> <y> [--speed=<kn>]
naval_fate ship shoot <x> <y>
naval_fate mine (set|remove) <x> <y> [--moored|--drifting]
naval_fate -h | --help
naval_fate --version
Options:
-h --help Show this screen.
--version Show version.
--speed=<kn> Speed in knots [default: 10].
--moored Moored (anchored) mine.
--drifting Drifting mine.`
arguments, _ := docopt.ParseDoc(usage)
fmt.Println(arguments)
}
```
**docopt** parses command-line arguments based on a help message. Don't write parser code: a good help message already has all the necessary information in it.
## Installation
⚠ Use the alias "docopt-go". To use docopt in your Go code:
```go
import "github.com/docopt/docopt-go"
```
To install docopt in your `$GOPATH`:
```console
$ go get github.com/docopt/docopt-go
```
## API
Given a conventional command-line help message, docopt processes the arguments. See https://github.com/docopt/docopt#help-message-format for a description of the help message format.
This package exposes three different APIs, depending on the level of control required. The first, simplest way to parse your docopt usage is to just call:
```go
docopt.ParseDoc(usage)
```
This will use `os.Args[1:]` as the argv slice, and use the default parser options. If you want to provide your own version string and args, then use:
```go
docopt.ParseArgs(usage, argv, "1.2.3")
```
If the last parameter (version) is a non-empty string, it will be printed when `--version` is given in the argv slice. Finally, we can instantiate our own `docopt.Parser` which gives us control over how things like help messages are printed and whether to exit after displaying usage messages, etc.
```go
parser := &docopt.Parser{
HelpHandler: docopt.PrintHelpOnly,
OptionsFirst: true,
}
opts, err := parser.ParseArgs(usage, argv, "")
```
In particular, setting your own custom `HelpHandler` function makes unit testing your own docs with example command line invocations much more enjoyable.
All three of these return a map of option names to the values parsed from argv, and an error or nil. You can get the values using the helpers, or just treat it as a regular map:
```go
flag, _ := opts.Bool("--flag")
secs, _ := opts.Int("<seconds>")
```
Additionally, you can `Bind` these to a struct, assigning option values to the
exported fields of that struct, all at once.
```go
var config struct {
Command string `docopt:"<cmd>"`
Tries int `docopt:"-n"`
Force bool // Gets the value of --force
}
opts.Bind(&config)
```
More documentation is available at [godoc.org](https://godoc.org/github.com/docopt/docopt-go).
## Unit Testing
Unit testing your own usage docs is recommended, so you can be sure that for a given command line invocation, the expected options are set. An example of how to do this is [in the examples folder](examples/unit_test/unit_test.go).
## Tests
All tests from the Python version are implemented and passing at [Travis CI](https://travis-ci.org/docopt/docopt-go). New language-agnostic tests have been added to [test_golang.docopt](test_golang.docopt).
To run tests for docopt-go, use `go test`.

View File

@@ -1,49 +0,0 @@
/*
Package docopt parses command-line arguments based on a help message.
Given a conventional command-line help message, docopt processes the arguments.
See https://github.com/docopt/docopt#help-message-format for a description of
the help message format.
This package exposes three different APIs, depending on the level of control
required. The first, simplest way to parse your docopt usage is to just call:
docopt.ParseDoc(usage)
This will use os.Args[1:] as the argv slice, and use the default parser
options. If you want to provide your own version string and args, then use:
docopt.ParseArgs(usage, argv, "1.2.3")
If the last parameter (version) is a non-empty string, it will be printed when
--version is given in the argv slice. Finally, we can instantiate our own
docopt.Parser which gives us control over how things like help messages are
printed and whether to exit after displaying usage messages, etc.
parser := &docopt.Parser{
HelpHandler: docopt.PrintHelpOnly,
OptionsFirst: true,
}
opts, err := parser.ParseArgs(usage, argv, "")
In particular, setting your own custom HelpHandler function makes unit testing
your own docs with example command line invocations much more enjoyable.
All three of these return a map of option names to the values parsed from argv,
and an error or nil. You can get the values using the helpers, or just treat it
as a regular map:
flag, _ := opts.Bool("--flag")
secs, _ := opts.Int("<seconds>")
Additionally, you can `Bind` these to a struct, assigning option values to the
exported fields of that struct, all at once.
var config struct {
Command string `docopt:"<cmd>"`
Tries int `docopt:"-n"`
Force bool // Gets the value of --force
}
opts.Bind(&config)
*/
package docopt

View File

@@ -1,575 +0,0 @@
// Licensed under terms of MIT license (see LICENSE-MIT)
// Copyright (c) 2013 Keith Batten, kbatten@gmail.com
// Copyright (c) 2016 David Irvine
package docopt
import (
"fmt"
"os"
"regexp"
"strings"
)
type Parser struct {
// HelpHandler is called when we encounter bad user input, or when the user
// asks for help.
// By default, this calls os.Exit(0) if it handled a built-in option such
// as -h, --help or --version. If the user errored with a wrong command or
// options, we exit with a return code of 1.
HelpHandler func(err error, usage string)
// OptionsFirst requires that option flags always come before positional
// arguments; otherwise they can overlap.
OptionsFirst bool
// SkipHelpFlags tells the parser not to look for -h and --help flags and
// call the HelpHandler.
SkipHelpFlags bool
}
var PrintHelpAndExit = func(err error, usage string) {
if err != nil {
fmt.Fprintln(os.Stderr, usage)
os.Exit(1)
} else {
fmt.Println(usage)
os.Exit(0)
}
}
var PrintHelpOnly = func(err error, usage string) {
if err != nil {
fmt.Fprintln(os.Stderr, usage)
} else {
fmt.Println(usage)
}
}
var NoHelpHandler = func(err error, usage string) {}
var DefaultParser = &Parser{
HelpHandler: PrintHelpAndExit,
OptionsFirst: false,
SkipHelpFlags: false,
}
// ParseDoc parses os.Args[1:] based on the interface described in doc, using the default parser options.
func ParseDoc(doc string) (Opts, error) {
return ParseArgs(doc, nil, "")
}
// ParseArgs parses custom arguments based on the interface described in doc. If you provide a non-empty version
// string, then this will be displayed when the --version flag is found. This method uses the default parser options.
func ParseArgs(doc string, argv []string, version string) (Opts, error) {
return DefaultParser.ParseArgs(doc, argv, version)
}
// ParseArgs parses custom arguments based on the interface described in doc. If you provide a non-empty version
// string, then this will be displayed when the --version flag is found.
func (p *Parser) ParseArgs(doc string, argv []string, version string) (Opts, error) {
return p.parse(doc, argv, version)
}
// Deprecated: Parse is provided for backward compatibility with the original docopt.go package.
// Please rather make use of ParseDoc, ParseArgs, or use your own custom Parser.
func Parse(doc string, argv []string, help bool, version string, optionsFirst bool, exit ...bool) (map[string]interface{}, error) {
exitOk := true
if len(exit) > 0 {
exitOk = exit[0]
}
p := &Parser{
OptionsFirst: optionsFirst,
SkipHelpFlags: !help,
}
if exitOk {
p.HelpHandler = PrintHelpAndExit
} else {
p.HelpHandler = PrintHelpOnly
}
return p.parse(doc, argv, version)
}
func (p *Parser) parse(doc string, argv []string, version string) (map[string]interface{}, error) {
if argv == nil {
argv = os.Args[1:]
}
if p.HelpHandler == nil {
p.HelpHandler = DefaultParser.HelpHandler
}
args, output, err := parse(doc, argv, !p.SkipHelpFlags, version, p.OptionsFirst)
if _, ok := err.(*UserError); ok {
// the user gave us bad input
p.HelpHandler(err, output)
} else if len(output) > 0 && err == nil {
// the user asked for help or --version
p.HelpHandler(err, output)
}
return args, err
}
// -----------------------------------------------------------------------------
// parse and return a map of args, output and all errors
func parse(doc string, argv []string, help bool, version string, optionsFirst bool) (args map[string]interface{}, output string, err error) {
if argv == nil && len(os.Args) > 1 {
argv = os.Args[1:]
}
usageSections := parseSection("usage:", doc)
if len(usageSections) == 0 {
err = newLanguageError("\"usage:\" (case-insensitive) not found.")
return
}
if len(usageSections) > 1 {
err = newLanguageError("More than one \"usage:\" (case-insensitive).")
return
}
usage := usageSections[0]
options := parseDefaults(doc)
formal, err := formalUsage(usage)
if err != nil {
output = handleError(err, usage)
return
}
pat, err := parsePattern(formal, &options)
if err != nil {
output = handleError(err, usage)
return
}
patternArgv, err := parseArgv(newTokenList(argv, errorUser), &options, optionsFirst)
if err != nil {
output = handleError(err, usage)
return
}
patFlat, err := pat.flat(patternOption)
if err != nil {
output = handleError(err, usage)
return
}
patternOptions := patFlat.unique()
patFlat, err = pat.flat(patternOptionSSHORTCUT)
if err != nil {
output = handleError(err, usage)
return
}
for _, optionsShortcut := range patFlat {
docOptions := parseDefaults(doc)
optionsShortcut.children = docOptions.unique().diff(patternOptions)
}
if output = extras(help, version, patternArgv, doc); len(output) > 0 {
return
}
err = pat.fix()
if err != nil {
output = handleError(err, usage)
return
}
matched, left, collected := pat.match(&patternArgv, nil)
if matched && len(*left) == 0 {
patFlat, err = pat.flat(patternDefault)
if err != nil {
output = handleError(err, usage)
return
}
args = append(patFlat, *collected...).dictionary()
return
}
err = newUserError("")
output = handleError(err, usage)
return
}
func handleError(err error, usage string) string {
if _, ok := err.(*UserError); ok {
return strings.TrimSpace(fmt.Sprintf("%s\n%s", err, usage))
}
return ""
}
func parseSection(name, source string) []string {
p := regexp.MustCompile(`(?im)^([^\n]*` + name + `[^\n]*\n?(?:[ \t].*?(?:\n|$))*)`)
s := p.FindAllString(source, -1)
if s == nil {
s = []string{}
}
for i, v := range s {
s[i] = strings.TrimSpace(v)
}
return s
}
func parseDefaults(doc string) patternList {
defaults := patternList{}
p := regexp.MustCompile(`\n[ \t]*(-\S+?)`)
for _, s := range parseSection("options:", doc) {
// FIXME corner case "bla: options: --foo"
_, _, s = stringPartition(s, ":") // get rid of "options:"
split := p.Split("\n"+s, -1)[1:]
match := p.FindAllStringSubmatch("\n"+s, -1)
for i := range split {
optionDescription := match[i][1] + split[i]
if strings.HasPrefix(optionDescription, "-") {
defaults = append(defaults, parseOption(optionDescription))
}
}
}
return defaults
}
func parsePattern(source string, options *patternList) (*pattern, error) {
tokens := tokenListFromPattern(source)
result, err := parseExpr(tokens, options)
if err != nil {
return nil, err
}
if tokens.current() != nil {
return nil, tokens.errorFunc("unexpected ending: %s" + strings.Join(tokens.tokens, " "))
}
return newRequired(result...), nil
}
func parseArgv(tokens *tokenList, options *patternList, optionsFirst bool) (patternList, error) {
/*
Parse command-line argument vector.
If options_first:
argv ::= [ long | shorts ]* [ argument ]* [ '--' [ argument ]* ] ;
else:
argv ::= [ long | shorts | argument ]* [ '--' [ argument ]* ] ;
*/
parsed := patternList{}
for tokens.current() != nil {
if tokens.current().eq("--") {
for _, v := range tokens.tokens {
parsed = append(parsed, newArgument("", v))
}
return parsed, nil
} else if tokens.current().hasPrefix("--") {
pl, err := parseLong(tokens, options)
if err != nil {
return nil, err
}
parsed = append(parsed, pl...)
} else if tokens.current().hasPrefix("-") && !tokens.current().eq("-") {
ps, err := parseShorts(tokens, options)
if err != nil {
return nil, err
}
parsed = append(parsed, ps...)
} else if optionsFirst {
for _, v := range tokens.tokens {
parsed = append(parsed, newArgument("", v))
}
return parsed, nil
} else {
parsed = append(parsed, newArgument("", tokens.move().String()))
}
}
return parsed, nil
}
func parseOption(optionDescription string) *pattern {
optionDescription = strings.TrimSpace(optionDescription)
options, _, description := stringPartition(optionDescription, " ")
options = strings.Replace(options, ",", " ", -1)
options = strings.Replace(options, "=", " ", -1)
short := ""
long := ""
argcount := 0
var value interface{}
value = false
reDefault := regexp.MustCompile(`(?i)\[default: (.*)\]`)
for _, s := range strings.Fields(options) {
if strings.HasPrefix(s, "--") {
long = s
} else if strings.HasPrefix(s, "-") {
short = s
} else {
argcount = 1
}
if argcount > 0 {
matched := reDefault.FindAllStringSubmatch(description, -1)
if len(matched) > 0 {
value = matched[0][1]
} else {
value = nil
}
}
}
return newOption(short, long, argcount, value)
}
func parseExpr(tokens *tokenList, options *patternList) (patternList, error) {
// expr ::= seq ( '|' seq )* ;
seq, err := parseSeq(tokens, options)
if err != nil {
return nil, err
}
if !tokens.current().eq("|") {
return seq, nil
}
var result patternList
if len(seq) > 1 {
result = patternList{newRequired(seq...)}
} else {
result = seq
}
for tokens.current().eq("|") {
tokens.move()
seq, err = parseSeq(tokens, options)
if err != nil {
return nil, err
}
if len(seq) > 1 {
result = append(result, newRequired(seq...))
} else {
result = append(result, seq...)
}
}
if len(result) > 1 {
return patternList{newEither(result...)}, nil
}
return result, nil
}
func parseSeq(tokens *tokenList, options *patternList) (patternList, error) {
// seq ::= ( atom [ '...' ] )* ;
result := patternList{}
for !tokens.current().match(true, "]", ")", "|") {
atom, err := parseAtom(tokens, options)
if err != nil {
return nil, err
}
if tokens.current().eq("...") {
atom = patternList{newOneOrMore(atom...)}
tokens.move()
}
result = append(result, atom...)
}
return result, nil
}
func parseAtom(tokens *tokenList, options *patternList) (patternList, error) {
// atom ::= '(' expr ')' | '[' expr ']' | 'options' | long | shorts | argument | command ;
tok := tokens.current()
result := patternList{}
if tokens.current().match(false, "(", "[") {
tokens.move()
var matching string
pl, err := parseExpr(tokens, options)
if err != nil {
return nil, err
}
if tok.eq("(") {
matching = ")"
result = patternList{newRequired(pl...)}
} else if tok.eq("[") {
matching = "]"
result = patternList{newOptional(pl...)}
}
moved := tokens.move()
if !moved.eq(matching) {
return nil, tokens.errorFunc("unmatched '%s', expected: '%s' got: '%s'", tok, matching, moved)
}
return result, nil
} else if tok.eq("options") {
tokens.move()
return patternList{newOptionsShortcut()}, nil
} else if tok.hasPrefix("--") && !tok.eq("--") {
return parseLong(tokens, options)
} else if tok.hasPrefix("-") && !tok.eq("-") && !tok.eq("--") {
return parseShorts(tokens, options)
} else if tok.hasPrefix("<") && tok.hasSuffix(">") || tok.isUpper() {
return patternList{newArgument(tokens.move().String(), nil)}, nil
}
return patternList{newCommand(tokens.move().String(), false)}, nil
}
func parseLong(tokens *tokenList, options *patternList) (patternList, error) {
// long ::= '--' chars [ ( ' ' | '=' ) chars ] ;
long, eq, v := stringPartition(tokens.move().String(), "=")
var value interface{}
var opt *pattern
if eq == "" && v == "" {
value = nil
} else {
value = v
}
if !strings.HasPrefix(long, "--") {
return nil, newError("long option '%s' doesn't start with --", long)
}
similar := patternList{}
for _, o := range *options {
if o.long == long {
similar = append(similar, o)
}
}
if tokens.err == errorUser && len(similar) == 0 { // if no exact match
similar = patternList{}
for _, o := range *options {
if strings.HasPrefix(o.long, long) {
similar = append(similar, o)
}
}
}
if len(similar) > 1 { // might be simply specified ambiguously 2+ times?
similarLong := make([]string, len(similar))
for i, s := range similar {
similarLong[i] = s.long
}
return nil, tokens.errorFunc("%s is not a unique prefix: %s?", long, strings.Join(similarLong, ", "))
} else if len(similar) < 1 {
argcount := 0
if eq == "=" {
argcount = 1
}
opt = newOption("", long, argcount, false)
*options = append(*options, opt)
if tokens.err == errorUser {
var val interface{}
if argcount > 0 {
val = value
} else {
val = true
}
opt = newOption("", long, argcount, val)
}
} else {
opt = newOption(similar[0].short, similar[0].long, similar[0].argcount, similar[0].value)
if opt.argcount == 0 {
if value != nil {
return nil, tokens.errorFunc("%s must not have an argument", opt.long)
}
} else {
if value == nil {
if tokens.current().match(true, "--") {
return nil, tokens.errorFunc("%s requires argument", opt.long)
}
moved := tokens.move()
if moved != nil {
value = moved.String() // only set as string if not nil
}
}
}
if tokens.err == errorUser {
if value != nil {
opt.value = value
} else {
opt.value = true
}
}
}
return patternList{opt}, nil
}
func parseShorts(tokens *tokenList, options *patternList) (patternList, error) {
// shorts ::= '-' ( chars )* [ [ ' ' ] chars ] ;
tok := tokens.move()
if !tok.hasPrefix("-") || tok.hasPrefix("--") {
return nil, newError("short option '%s' doesn't start with -", tok)
}
left := strings.TrimLeft(tok.String(), "-")
parsed := patternList{}
for left != "" {
var opt *pattern
short := "-" + left[0:1]
left = left[1:]
similar := patternList{}
for _, o := range *options {
if o.short == short {
similar = append(similar, o)
}
}
if len(similar) > 1 {
return nil, tokens.errorFunc("%s is specified ambiguously %d times", short, len(similar))
} else if len(similar) < 1 {
opt = newOption(short, "", 0, false)
*options = append(*options, opt)
if tokens.err == errorUser {
opt = newOption(short, "", 0, true)
}
} else { // why copying is necessary here?
opt = newOption(short, similar[0].long, similar[0].argcount, similar[0].value)
var value interface{}
if opt.argcount > 0 {
if left == "" {
if tokens.current().match(true, "--") {
return nil, tokens.errorFunc("%s requires argument", short)
}
value = tokens.move().String()
} else {
value = left
left = ""
}
}
if tokens.err == errorUser {
if value != nil {
opt.value = value
} else {
opt.value = true
}
}
}
parsed = append(parsed, opt)
}
return parsed, nil
}
func formalUsage(section string) (string, error) {
_, _, section = stringPartition(section, ":") // drop "usage:"
pu := strings.Fields(section)
if len(pu) == 0 {
return "", newLanguageError("no fields found in usage (perhaps a spacing error).")
}
result := "( "
for _, s := range pu[1:] {
if s == pu[0] {
result += ") | ( "
} else {
result += s + " "
}
}
result += ")"
return result, nil
}
func extras(help bool, version string, options patternList, doc string) string {
if help {
for _, o := range options {
if (o.name == "-h" || o.name == "--help") && o.value == true {
return strings.Trim(doc, "\n")
}
}
}
if version != "" {
for _, o := range options {
if (o.name == "--version") && o.value == true {
return version
}
}
}
return ""
}
func stringPartition(s, sep string) (string, string, string) {
sepPos := strings.Index(s, sep)
if sepPos == -1 { // no seperator found
return s, "", ""
}
split := strings.SplitN(s, sep, 2)
return split[0], sep, split[1]
}

View File

@@ -1,49 +0,0 @@
package docopt
import (
"fmt"
)
type errorType int
const (
errorUser errorType = iota
errorLanguage
)
func (e errorType) String() string {
switch e {
case errorUser:
return "errorUser"
case errorLanguage:
return "errorLanguage"
}
return ""
}
// UserError records an error with program arguments.
type UserError struct {
msg string
Usage string
}
func (e UserError) Error() string {
return e.msg
}
func newUserError(msg string, f ...interface{}) error {
return &UserError{fmt.Sprintf(msg, f...), ""}
}
// LanguageError records an error with the doc string.
type LanguageError struct {
msg string
}
func (e LanguageError) Error() string {
return e.msg
}
func newLanguageError(msg string, f ...interface{}) error {
return &LanguageError{fmt.Sprintf(msg, f...)}
}
var newError = fmt.Errorf

View File

@@ -1,264 +0,0 @@
package docopt
import (
"fmt"
"reflect"
"strconv"
"strings"
"unicode"
)
func errKey(key string) error {
return fmt.Errorf("no such key: %q", key)
}
func errType(key string) error {
return fmt.Errorf("key: %q failed type conversion", key)
}
func errStrconv(key string, convErr error) error {
return fmt.Errorf("key: %q failed type conversion: %s", key, convErr)
}
// Opts is a map of command line options to their values, with some convenience
// methods for value type conversion (bool, float64, int, string). For example,
// to get an option value as an int:
//
// opts, _ := docopt.ParseDoc("Usage: sleep <seconds>")
// secs, _ := opts.Int("<seconds>")
//
// Additionally, Opts.Bind allows you easily populate a struct's fields with the
// values of each option value. See below for examples.
//
// Lastly, you can still treat Opts as a regular map, and do any type checking
// and conversion that you want to yourself. For example:
//
// if s, ok := opts["<binary>"].(string); ok {
// if val, err := strconv.ParseUint(s, 2, 64); err != nil { ... }
// }
//
// Note that any non-boolean option / flag will have a string value in the
// underlying map.
type Opts map[string]interface{}
func (o Opts) String(key string) (s string, err error) {
v, ok := o[key]
if !ok {
err = errKey(key)
return
}
s, ok = v.(string)
if !ok {
err = errType(key)
}
return
}
func (o Opts) Bool(key string) (b bool, err error) {
v, ok := o[key]
if !ok {
err = errKey(key)
return
}
b, ok = v.(bool)
if !ok {
err = errType(key)
}
return
}
func (o Opts) Int(key string) (i int, err error) {
s, err := o.String(key)
if err != nil {
return
}
i, err = strconv.Atoi(s)
if err != nil {
err = errStrconv(key, err)
}
return
}
func (o Opts) Float64(key string) (f float64, err error) {
s, err := o.String(key)
if err != nil {
return
}
f, err = strconv.ParseFloat(s, 64)
if err != nil {
err = errStrconv(key, err)
}
return
}
// Bind populates the fields of a given struct with matching option values.
// Each key in Opts will be mapped to an exported field of the struct pointed
// to by `v`, as follows:
//
// abc int // Unexported field, ignored
// Abc string // Mapped from `--abc`, `<abc>`, or `abc`
// // (case insensitive)
// A string // Mapped from `-a`, `<a>` or `a`
// // (case insensitive)
// Abc int `docopt:"XYZ"` // Mapped from `XYZ`
// Abc bool `docopt:"-"` // Mapped from `-`
// Abc bool `docopt:"-x,--xyz"` // Mapped from `-x` or `--xyz`
// // (first non-zero value found)
//
// Tagged (annotated) fields will always be mapped first. If no field is tagged
// with an option's key, Bind will try to map the option to an appropriately
// named field (as above).
//
// Bind also handles conversion to bool, float, int or string types.
func (o Opts) Bind(v interface{}) error {
structVal := reflect.ValueOf(v)
if structVal.Kind() != reflect.Ptr {
return newError("'v' argument is not pointer to struct type")
}
for structVal.Kind() == reflect.Ptr {
structVal = structVal.Elem()
}
if structVal.Kind() != reflect.Struct {
return newError("'v' argument is not pointer to struct type")
}
structType := structVal.Type()
tagged := make(map[string]int) // Tagged field tags
untagged := make(map[string]int) // Untagged field names
for i := 0; i < structType.NumField(); i++ {
field := structType.Field(i)
if isUnexportedField(field) || field.Anonymous {
continue
}
tag := field.Tag.Get("docopt")
if tag == "" {
untagged[field.Name] = i
continue
}
for _, t := range strings.Split(tag, ",") {
tagged[t] = i
}
}
// Get the index of the struct field to use, based on the option key.
// Second argument is true/false on whether something was matched.
getFieldIndex := func(key string) (int, bool) {
if i, ok := tagged[key]; ok {
return i, true
}
if i, ok := untagged[guessUntaggedField(key)]; ok {
return i, true
}
return -1, false
}
indexMap := make(map[string]int) // Option keys to field index
// Pre-check that option keys are mapped to fields and fields are zero valued, before populating them.
for k := range o {
i, ok := getFieldIndex(k)
if !ok {
if k == "--help" || k == "--version" { // Don't require these to be mapped.
continue
}
return newError("mapping of %q is not found in given struct, or is an unexported field", k)
}
fieldVal := structVal.Field(i)
zeroVal := reflect.Zero(fieldVal.Type())
if !reflect.DeepEqual(fieldVal.Interface(), zeroVal.Interface()) {
return newError("%q field is non-zero, will be overwritten by value of %q", structType.Field(i).Name, k)
}
indexMap[k] = i
}
// Populate fields with option values.
for k, v := range o {
i, ok := indexMap[k]
if !ok {
continue // Not mapped.
}
field := structVal.Field(i)
if !reflect.DeepEqual(field.Interface(), reflect.Zero(field.Type()).Interface()) {
// The struct's field is already non-zero (by our doing), so don't change it.
// This happens with comma separated tags, e.g. `docopt:"-h,--help"` which is a
// convenient way of checking if one of multiple boolean flags are set.
continue
}
optVal := reflect.ValueOf(v)
// Option value is the zero Value, so we can't get its .Type(). No need to assign anyway, so move along.
if !optVal.IsValid() {
continue
}
if !field.CanSet() {
return newError("%q field cannot be set", structType.Field(i).Name)
}
// Try to assign now if able. bool and string values should be assignable already.
if optVal.Type().AssignableTo(field.Type()) {
field.Set(optVal)
continue
}
// Try to convert the value and assign if able.
switch field.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if x, err := o.Int(k); err == nil {
field.SetInt(int64(x))
continue
}
case reflect.Float32, reflect.Float64:
if x, err := o.Float64(k); err == nil {
field.SetFloat(x)
continue
}
}
// TODO: Something clever (recursive?) with non-string slices.
// case reflect.Slice:
// if optVal.Kind() == reflect.Slice {
// for i := 0; i < optVal.Len(); i++ {
// sliceVal := optVal.Index(i)
// fmt.Printf("%v", sliceVal)
// }
// fmt.Printf("\n")
// }
return newError("value of %q is not assignable to %q field", k, structType.Field(i).Name)
}
return nil
}
// isUnexportedField returns whether the field is unexported.
// isUnexportedField is to avoid the bug in versions older than Go1.3.
// See following links:
// https://code.google.com/p/go/issues/detail?id=7247
// http://golang.org/ref/spec#Exported_identifiers
func isUnexportedField(field reflect.StructField) bool {
return !(field.PkgPath == "" && unicode.IsUpper(rune(field.Name[0])))
}
// Convert a string like "--my-special-flag" to "MySpecialFlag".
func titleCaseDashes(key string) string {
nextToUpper := true
mapFn := func(r rune) rune {
if r == '-' {
nextToUpper = true
return -1
}
if nextToUpper {
nextToUpper = false
return unicode.ToUpper(r)
}
return r
}
return strings.Map(mapFn, key)
}
// Best guess which field.Name in a struct to assign for an option key.
func guessUntaggedField(key string) string {
switch {
case strings.HasPrefix(key, "--") && len(key[2:]) > 1:
return titleCaseDashes(key[2:])
case strings.HasPrefix(key, "-") && len(key[1:]) == 1:
return titleCaseDashes(key[1:])
case strings.HasPrefix(key, "<") && strings.HasSuffix(key, ">"):
key = key[1 : len(key)-1]
}
return strings.Title(strings.ToLower(key))
}

View File

@@ -1,550 +0,0 @@
package docopt
import (
"fmt"
"reflect"
"strings"
)
type patternType uint
const (
// leaf
patternArgument patternType = 1 << iota
patternCommand
patternOption
// branch
patternRequired
patternOptionAL
patternOptionSSHORTCUT // Marker/placeholder for [options] shortcut.
patternOneOrMore
patternEither
patternLeaf = patternArgument +
patternCommand +
patternOption
patternBranch = patternRequired +
patternOptionAL +
patternOptionSSHORTCUT +
patternOneOrMore +
patternEither
patternAll = patternLeaf + patternBranch
patternDefault = 0
)
func (pt patternType) String() string {
switch pt {
case patternArgument:
return "argument"
case patternCommand:
return "command"
case patternOption:
return "option"
case patternRequired:
return "required"
case patternOptionAL:
return "optional"
case patternOptionSSHORTCUT:
return "optionsshortcut"
case patternOneOrMore:
return "oneormore"
case patternEither:
return "either"
case patternLeaf:
return "leaf"
case patternBranch:
return "branch"
case patternAll:
return "all"
case patternDefault:
return "default"
}
return ""
}
type pattern struct {
t patternType
children patternList
name string
value interface{}
short string
long string
argcount int
}
type patternList []*pattern
func newBranchPattern(t patternType, pl ...*pattern) *pattern {
var p pattern
p.t = t
p.children = make(patternList, len(pl))
copy(p.children, pl)
return &p
}
func newRequired(pl ...*pattern) *pattern {
return newBranchPattern(patternRequired, pl...)
}
func newEither(pl ...*pattern) *pattern {
return newBranchPattern(patternEither, pl...)
}
func newOneOrMore(pl ...*pattern) *pattern {
return newBranchPattern(patternOneOrMore, pl...)
}
func newOptional(pl ...*pattern) *pattern {
return newBranchPattern(patternOptionAL, pl...)
}
func newOptionsShortcut() *pattern {
var p pattern
p.t = patternOptionSSHORTCUT
return &p
}
func newLeafPattern(t patternType, name string, value interface{}) *pattern {
// default: value=nil
var p pattern
p.t = t
p.name = name
p.value = value
return &p
}
func newArgument(name string, value interface{}) *pattern {
// default: value=nil
return newLeafPattern(patternArgument, name, value)
}
func newCommand(name string, value interface{}) *pattern {
// default: value=false
var p pattern
p.t = patternCommand
p.name = name
p.value = value
return &p
}
func newOption(short, long string, argcount int, value interface{}) *pattern {
// default: "", "", 0, false
var p pattern
p.t = patternOption
p.short = short
p.long = long
if long != "" {
p.name = long
} else {
p.name = short
}
p.argcount = argcount
if value == false && argcount > 0 {
p.value = nil
} else {
p.value = value
}
return &p
}
func (p *pattern) flat(types patternType) (patternList, error) {
if p.t&patternLeaf != 0 {
if types == patternDefault {
types = patternAll
}
if p.t&types != 0 {
return patternList{p}, nil
}
return patternList{}, nil
}
if p.t&patternBranch != 0 {
if p.t&types != 0 {
return patternList{p}, nil
}
result := patternList{}
for _, child := range p.children {
childFlat, err := child.flat(types)
if err != nil {
return nil, err
}
result = append(result, childFlat...)
}
return result, nil
}
return nil, newError("unknown pattern type: %d, %d", p.t, types)
}
func (p *pattern) fix() error {
err := p.fixIdentities(nil)
if err != nil {
return err
}
p.fixRepeatingArguments()
return nil
}
func (p *pattern) fixIdentities(uniq patternList) error {
// Make pattern-tree tips point to same object if they are equal.
if p.t&patternBranch == 0 {
return nil
}
if uniq == nil {
pFlat, err := p.flat(patternDefault)
if err != nil {
return err
}
uniq = pFlat.unique()
}
for i, child := range p.children {
if child.t&patternBranch == 0 {
ind, err := uniq.index(child)
if err != nil {
return err
}
p.children[i] = uniq[ind]
} else {
err := child.fixIdentities(uniq)
if err != nil {
return err
}
}
}
return nil
}
func (p *pattern) fixRepeatingArguments() {
// Fix elements that should accumulate/increment values.
var either []patternList
for _, child := range p.transform().children {
either = append(either, child.children)
}
for _, cas := range either {
casMultiple := patternList{}
for _, e := range cas {
if cas.count(e) > 1 {
casMultiple = append(casMultiple, e)
}
}
for _, e := range casMultiple {
if e.t == patternArgument || e.t == patternOption && e.argcount > 0 {
switch e.value.(type) {
case string:
e.value = strings.Fields(e.value.(string))
case []string:
default:
e.value = []string{}
}
}
if e.t == patternCommand || e.t == patternOption && e.argcount == 0 {
e.value = 0
}
}
}
}
func (p *pattern) match(left *patternList, collected *patternList) (bool, *patternList, *patternList) {
if collected == nil {
collected = &patternList{}
}
if p.t&patternRequired != 0 {
l := left
c := collected
for _, p := range p.children {
var matched bool
matched, l, c = p.match(l, c)
if !matched {
return false, left, collected
}
}
return true, l, c
} else if p.t&patternOptionAL != 0 || p.t&patternOptionSSHORTCUT != 0 {
for _, p := range p.children {
_, left, collected = p.match(left, collected)
}
return true, left, collected
} else if p.t&patternOneOrMore != 0 {
if len(p.children) != 1 {
panic("OneOrMore.match(): assert len(p.children) == 1")
}
l := left
c := collected
var lAlt *patternList
matched := true
times := 0
for matched {
// could it be that something didn't match but changed l or c?
matched, l, c = p.children[0].match(l, c)
if matched {
times++
}
if lAlt == l {
break
}
lAlt = l
}
if times >= 1 {
return true, l, c
}
return false, left, collected
} else if p.t&patternEither != 0 {
type outcomeStruct struct {
matched bool
left *patternList
collected *patternList
length int
}
outcomes := []outcomeStruct{}
for _, p := range p.children {
matched, l, c := p.match(left, collected)
outcome := outcomeStruct{matched, l, c, len(*l)}
if matched {
outcomes = append(outcomes, outcome)
}
}
if len(outcomes) > 0 {
minLen := outcomes[0].length
minIndex := 0
for i, v := range outcomes {
if v.length < minLen {
minIndex = i
}
}
return outcomes[minIndex].matched, outcomes[minIndex].left, outcomes[minIndex].collected
}
return false, left, collected
} else if p.t&patternLeaf != 0 {
pos, match := p.singleMatch(left)
var increment interface{}
if match == nil {
return false, left, collected
}
leftAlt := make(patternList, len((*left)[:pos]), len((*left)[:pos])+len((*left)[pos+1:]))
copy(leftAlt, (*left)[:pos])
leftAlt = append(leftAlt, (*left)[pos+1:]...)
sameName := patternList{}
for _, a := range *collected {
if a.name == p.name {
sameName = append(sameName, a)
}
}
switch p.value.(type) {
case int, []string:
switch p.value.(type) {
case int:
increment = 1
case []string:
switch match.value.(type) {
case string:
increment = []string{match.value.(string)}
default:
increment = match.value
}
}
if len(sameName) == 0 {
match.value = increment
collectedMatch := make(patternList, len(*collected), len(*collected)+1)
copy(collectedMatch, *collected)
collectedMatch = append(collectedMatch, match)
return true, &leftAlt, &collectedMatch
}
switch sameName[0].value.(type) {
case int:
sameName[0].value = sameName[0].value.(int) + increment.(int)
case []string:
sameName[0].value = append(sameName[0].value.([]string), increment.([]string)...)
}
return true, &leftAlt, collected
}
collectedMatch := make(patternList, len(*collected), len(*collected)+1)
copy(collectedMatch, *collected)
collectedMatch = append(collectedMatch, match)
return true, &leftAlt, &collectedMatch
}
panic("unmatched type")
}
func (p *pattern) singleMatch(left *patternList) (int, *pattern) {
if p.t&patternArgument != 0 {
for n, pat := range *left {
if pat.t&patternArgument != 0 {
return n, newArgument(p.name, pat.value)
}
}
return -1, nil
} else if p.t&patternCommand != 0 {
for n, pat := range *left {
if pat.t&patternArgument != 0 {
if pat.value == p.name {
return n, newCommand(p.name, true)
}
break
}
}
return -1, nil
} else if p.t&patternOption != 0 {
for n, pat := range *left {
if p.name == pat.name {
return n, pat
}
}
return -1, nil
}
panic("unmatched type")
}
func (p *pattern) String() string {
if p.t&patternOption != 0 {
return fmt.Sprintf("%s(%s, %s, %d, %+v)", p.t, p.short, p.long, p.argcount, p.value)
} else if p.t&patternLeaf != 0 {
return fmt.Sprintf("%s(%s, %+v)", p.t, p.name, p.value)
} else if p.t&patternBranch != 0 {
result := ""
for i, child := range p.children {
if i > 0 {
result += ", "
}
result += child.String()
}
return fmt.Sprintf("%s(%s)", p.t, result)
}
panic("unmatched type")
}
func (p *pattern) transform() *pattern {
/*
Expand pattern into an (almost) equivalent one, but with single Either.
Example: ((-a | -b) (-c | -d)) => (-a -c | -a -d | -b -c | -b -d)
Quirks: [-a] => (-a), (-a...) => (-a -a)
*/
result := []patternList{}
groups := []patternList{patternList{p}}
parents := patternRequired +
patternOptionAL +
patternOptionSSHORTCUT +
patternEither +
patternOneOrMore
for len(groups) > 0 {
children := groups[0]
groups = groups[1:]
var child *pattern
for _, c := range children {
if c.t&parents != 0 {
child = c
break
}
}
if child != nil {
children.remove(child)
if child.t&patternEither != 0 {
for _, c := range child.children {
r := patternList{}
r = append(r, c)
r = append(r, children...)
groups = append(groups, r)
}
} else if child.t&patternOneOrMore != 0 {
r := patternList{}
r = append(r, child.children.double()...)
r = append(r, children...)
groups = append(groups, r)
} else {
r := patternList{}
r = append(r, child.children...)
r = append(r, children...)
groups = append(groups, r)
}
} else {
result = append(result, children)
}
}
either := patternList{}
for _, e := range result {
either = append(either, newRequired(e...))
}
return newEither(either...)
}
func (p *pattern) eq(other *pattern) bool {
return reflect.DeepEqual(p, other)
}
func (pl patternList) unique() patternList {
table := make(map[string]bool)
result := patternList{}
for _, v := range pl {
if !table[v.String()] {
table[v.String()] = true
result = append(result, v)
}
}
return result
}
func (pl patternList) index(p *pattern) (int, error) {
for i, c := range pl {
if c.eq(p) {
return i, nil
}
}
return -1, newError("%s not in list", p)
}
func (pl patternList) count(p *pattern) int {
count := 0
for _, c := range pl {
if c.eq(p) {
count++
}
}
return count
}
func (pl patternList) diff(l patternList) patternList {
lAlt := make(patternList, len(l))
copy(lAlt, l)
result := make(patternList, 0, len(pl))
for _, v := range pl {
if v != nil {
match := false
for i, w := range lAlt {
if w.eq(v) {
match = true
lAlt[i] = nil
break
}
}
if match == false {
result = append(result, v)
}
}
}
return result
}
func (pl patternList) double() patternList {
l := len(pl)
result := make(patternList, l*2)
copy(result, pl)
copy(result[l:2*l], pl)
return result
}
func (pl *patternList) remove(p *pattern) {
(*pl) = pl.diff(patternList{p})
}
func (pl patternList) dictionary() map[string]interface{} {
dict := make(map[string]interface{})
for _, a := range pl {
dict[a.name] = a.value
}
return dict
}

View File

@@ -1,9 +0,0 @@
r"""usage: prog [NAME_-2]..."""
$ prog 10 20
{"NAME_-2": ["10", "20"]}
$ prog 10
{"NAME_-2": ["10"]}
$ prog
{"NAME_-2": []}

Some files were not shown because too many files have changed in this diff Show More