package cache
import (
"bufio"
"errors"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/paths"
"io"
"io/fs"
"os"
"slices"
"strings"
)
// Cache is a wrapper for its entries. I guess it could be a plain type.
type Cache struct {
Entries []Entry
}
type cacheInit struct{}
// Merge combines two caches and filters duplicate keys
func (c *Cache) Merge(other *Cache) *Cache {
switch {
case other == nil && c != nil:
return c
case c == nil && other != nil:
return other
case c == nil:
return nil
}
mp := make(map[string]string)
for _, e := range append(c.Entries, other.Entries...) {
mp[e.Path] = e.Label
}
result := &Cache{}
for p, l := range mp {
result.Entries = append(result.Entries, Entry{Label: l, Path: p})
}
return result
}
// Entry is a cache entry containing a path and name
type Entry struct {
Path string
Label string
}
// String returns the string representation of this cache entry
func (e Entry) String() string {
return e.Path + " # " + e.Label
}
// LoadFile creates a Cache from a file or an empty one if the file does not exist
// this handles opening a reader for Unmarshal
func LoadFile(root fs.FS, path string) (Cache, error) {
fd, err := root.Open(path)
if errors.Is(err, fs.ErrNotExist) {
return Cache{}, nil
} else if err != nil {
return Cache{}, err
}
if fd != nil {
defer fd.Close()
}
return Unmarshal(fd)
}
// Unmarshal attempts to create a Cache from reader content
func Unmarshal(r io.Reader) (Cache, error) {
c := &Cache{}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || line[0] == '#' || line[0:2] == "//" {
continue
}
entry := &Entry{}
parts := strings.SplitN(line, "#", 2)
switch len(parts) {
case 2:
entry.Label = strings.TrimSpace(parts[1])
fallthrough
case 1:
entry.Path = strings.TrimSpace(parts[0])
}
c.Entries = append(c.Entries, *entry)
}
return *c, nil
}
// Marshal returns the file representation of the Cache
func (c *Cache) Marshal() []byte {
b := strings.Builder{}
for _, e := range c.Entries {
b.WriteString(e.String())
b.WriteString("\n")
}
return []byte(b.String())
}
// String returns the string representation of the Cache
func (c *Cache) String() string {
return string(c.Marshal())
}
func New(st *model.State) *Cache {
c := &Cache{}
for _, s := range st.Sources {
c.Entries = append(c.Entries, Entry{
Path: s.Path,
Label: s.Label(),
})
}
return c
}
// MergeAndSave creates a cache from a state, combines it with
// a potential saved context file, and writes it to disk
func MergeAndSave(in *model.State) error {
loaded, err := Get()
if err != nil {
return err
}
insert := New(in)
result := loaded.Merge(insert)
if loaded.Entries == nil {
return SaveFile(paths.ContextsFile.String(), result)
}
if slices.Equal(loaded.Entries, result.Entries) {
return nil
}
return SaveFile(paths.ContextsFile.String(), result)
}
// SaveFile helps you use Save with a file path instead of a reader
func SaveFile(path string, loaded *Cache) error {
fd, err := os.Create(path)
if err != nil {
return err
}
if fd != nil {
defer fd.Close()
}
return Save(fd, loaded)
}
// Save writes a cache to the contexts file
func Save(w io.Writer, loaded *Cache) error {
_, err := w.Write(loaded.Marshal())
return err
}
// LoadState creates a state with model.NewState based on cache content
func LoadState(f fs.FS, data map[string]*model.SourceData, cache Cache, indexers []model.Indexer, runners []model.Runner) (*model.State, []error) {
var locs []string
for _, e := range cache.Entries {
locs = append(locs, e.Path)
}
return model.NewState(f, locs, data, indexers, runners)
}
// Strip removes the needle's entries from the receiver's entries when they have matching paths.
// used to skip already indexed locations when auto-all-ing
func (c *Cache) Strip(needle Cache) Cache {
var result []Entry
outer:
for _, e := range c.Entries {
for _, t := range needle.Entries {
if t.Path == e.Path {
continue outer
}
}
result = append(result, e)
}
return Cache{
Entries: result,
}
}
func Get() (Cache, error) {
f := os.DirFS(paths.CacheDir.String())
loaded, err := LoadFile(f, strings.TrimPrefix(paths.ContextsFileName, "/"))
return loaded, err
}
package completion
import (
_ "embed"
"errors"
"fmt"
"git.sr.ht/~ewy/pik/paths"
"git.sr.ht/~ewy/pik/spool"
"os"
"path/filepath"
"strings"
)
//go:embed completion.sh
var completionCode string
var completionFormat = `
# %s
%s
`
var completionComment = "pik completion (installed by `pik --install-completion`)"
var completionCodeByShell = map[string]string{
"bash": ". <(pik --completion)",
"zsh": `autoload -Uz compinit && compinit && source <(pik --completion)`,
}
var completionFileByShell = map[string]string{
"bash": ".bashrc",
"zsh": ".zshrc",
}
// AlreadyInstalledError is returned when we already found completion code in your shell rc file
var AlreadyInstalledError = errors.New("completion already installed")
// Add finds the right file to append the completion code to and does that
func Add(shell string) *spool.ExitCode {
f := filepath.Join(paths.HomeDir.String(), completionFileByShell[shell])
content, err := os.ReadFile(f)
if err != nil {
return spool.FatalReadFailure
}
if strings.Contains(string(content), strings.TrimSpace(completionCodeByShell[shell])) {
return spool.CompletionAlreadyInstalledFailure
}
fd, err := os.OpenFile(f, os.O_APPEND|os.O_WRONLY, 0600)
defer fd.Close()
if err != nil {
return spool.FatalReadFailure
}
_, err = fd.Write([]byte(fmt.Sprintf(completionFormat, completionComment, completionCodeByShell[shell])))
if err != nil {
return spool.CompletionFailure
}
successMessage(shell, f)
return spool.Success
}
func successMessage(shell string, file string) {
_, _ = spool.Print("Installed completion for %s in %s\n", shell, file)
}
// Echo prints the actual completion script
// because it is baked in with the program it should always be version-appropriate
func Echo() *spool.ExitCode {
_, _ = spool.Print("%s", completionCode)
return spool.Success
}
package main
import (
"git.sr.ht/~ewy/pik/spool"
"sync"
)
// ComponentList is a list wrapper type which handles operation modes of the program
type ComponentList[T any] []T
// RunAsync checks components one by one and triggers the fire method in a goroutine.
// the method will return when the waitgroup is done (all initializers are finished)
func (c ComponentList[T]) RunAsync(fire func(T) error) {
wg := sync.WaitGroup{}
for _, i := range c {
wg.Go(func() {
err := fire(i)
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
})
}
wg.Wait()
}
// RunSync checks components one by one and fires them synchronously.
// important when the order of init matters (for example, paths needs to go before cache)
func (c ComponentList[T]) RunSync(fire func(T) error) {
for _, i := range c {
err := fire(i)
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
}
}
package crawl
import (
"path"
"path/filepath"
"slices"
"strings"
)
// Evaluated returns a path with evaluated symlinks
func Evaluated(loc string) (string, error) {
return filepath.EvalSymlinks(loc)
}
// RichLocations combines the path and Evaluated path Locations
func RichLocations(origin string) []string {
locs := Locations(origin)
eval, err := Evaluated(origin)
if err == nil && eval != origin {
evaledLocations := Locations(eval)
result := append(locs, evaledLocations...)
result = slices.Compact(result)
return result
}
return locs
}
// Locations returns a slice of increasingly shorter file paths,
// losing a segment each time.
func Locations(origin string) []string {
origin = path.Clean(origin)
var locs = []string{
origin,
}
for {
previous := locs[len(locs)-1]
parent := ParentDir(previous)
if previous == parent {
break
}
locs = append(locs, parent)
}
return locs
}
// ParentDir returns a path with the top element missing
func ParentDir(origin string) string {
trimmedOrigin := strings.TrimSuffix(origin, "/")
dir, _ := path.Split(trimmedOrigin)
if dir == "" {
return origin
}
return dir
}
package describe
import (
"bufio"
"git.sr.ht/~ewy/pik/model"
"io"
"os"
"strings"
)
var CommentPrefixes = []string{
"#",
"//",
}
var descriptions = make(map[model.Target]*string)
// Describe attempts to read a description from a file and stores it in the cache
func Describe(key model.Target, file string) (string, error) {
if d := descriptions[key]; d != nil {
return *d, nil
}
fd, err := os.Open(file)
if err != nil {
msg := err.Error()
descriptions[key] = &msg
return "", err
}
defer fd.Close()
text, err := FromReader(fd)
if err != nil {
return text, err
} else {
descriptions[key] = &text
}
return text, err
}
// FromReader reads a description from an io.Reader and returns it.
// this is not stored in the cache.
func FromReader(reader io.Reader) (string, error) {
scanner := bufio.NewScanner(reader)
scanner.Split(bufio.ScanLines)
scanner.Scan()
text := scanner.Text()
if strings.HasPrefix(text, "#!") {
scanner.Scan()
text = scanner.Text()
}
text = strings.TrimSpace(text)
hasPrefix := false
for _, p := range CommentPrefixes {
if strings.HasPrefix(text, p) {
hasPrefix = true
break
}
}
if !hasPrefix {
return "", nil
}
for _, c := range CommentPrefixes {
text = strings.TrimPrefix(text, c)
text = strings.TrimSpace(text)
}
return text, nil
}
package env
import (
"git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/paths"
"git.sr.ht/~ewy/pik/spool"
"github.com/joho/godotenv"
"io/fs"
"os"
"path/filepath"
"slices"
)
// IsEnv returns whether a given file is suitable for environment loading
// this method respects the --env flag
func IsEnv(file string) bool {
options := []string{
".env",
}
for _, e := range *flags.Env {
options = append(options,
".env-"+e,
".env."+e,
e+".env",
"."+e+".env")
}
return slices.Contains(options, file)
}
// Files returns a list of files (that exist) that should be indexed and used as environment files
func Files(f fs.FS, p string, deep bool) []string {
var result []string
dir, err := fs.ReadDir(f, p)
if err != nil {
return nil
}
for _, e := range dir {
if e.IsDir() && slices.Contains(paths.Roots, e.Name()) && deep {
result = append(result, Files(f, e.Name(), false)...)
}
if !e.IsDir() && IsEnv(e.Name()) {
result = append(result, filepath.Join(p, e.Name()))
}
}
return result
}
// Get returns all environment key-value pairs we should index for a source
func Get(src *model.Source) []string {
f := os.DirFS(src.Path)
var result []string
files := Files(f, ".", true)
for _, f := range files {
res, err := godotenv.Read(filepath.Join(src.Path, f))
if err != nil {
spool.Warn("%v", err)
continue
}
for k, v := range res {
result = append(result, k+"="+v)
}
}
return result
}
package git
import (
"errors"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/spool"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
type gitMod struct {
Git string
err error
}
func (g *gitMod) Init() error {
p, err := exec.LookPath("git")
if err != nil {
g.err = err
return nil
}
g.Git = p
return nil
}
var Git = &gitMod{}
// Mod is the git hydration mod
func (g *gitMod) Mod(source *model.Source, result *model.HydratedSource) error {
gitFolder := filepath.Join(source.Path, ".git")
if st, err := os.Stat(gitFolder); err == nil && st.IsDir() {
if g.Git == "" {
spool.Warn("source %v seems to be a git repository but git is not installed\n", source.Identity.Full)
return nil
}
branch, err := g.Branch(source)
if err != nil {
spool.Warn("%v", err)
return nil
}
ch, in, de, err := g.Diff(source)
if err != nil {
spool.Warn("%v", err)
return nil
}
result.Git = &model.GitInfo{
Branch: branch,
Insertions: in,
Deletions: de,
Changes: ch,
}
}
return nil
}
// Branch returns the git branch of a given source
func (g *gitMod) Branch(source *model.Source) (string, error) {
cmd := exec.Command(g.Git, "branch", "--show-current")
cmd.Dir = source.Path
b, err := cmd.CombinedOutput()
return strings.TrimSpace(string(b)), err
}
var UnknownResponseError = errors.New("unknown response")
// Diff returns the number of changes of a given source
func (g *gitMod) Diff(source *model.Source) (changes int, insertions int, deletions int, err error) {
cmd := exec.Command(g.Git, "diff", "--shortstat")
cmd.Dir = source.Path
b, err := cmd.CombinedOutput()
if err != nil {
return 0, 0, 0, err
}
split := strings.Split(string(b), ",")
for _, s := range split {
if strings.TrimSpace(s) == "" {
return 0, 0, 0, nil
}
var e error
pt := strings.Split(strings.TrimSpace(s), " ")
num, e := strconv.Atoi(pt[0])
switch {
case strings.Contains(s, "changed"):
changes = num
case strings.Contains(s, "insertion"):
insertions = num
case strings.Contains(s, "deletion"):
deletions = num
default:
return changes, insertions, deletions, UnknownResponseError
}
if e != nil {
return changes, insertions, deletions, e
}
}
return changes, insertions, deletions, nil
}
package identity
import "strings"
type Identity struct {
Full string
Reduced string
}
// I return whether the other identity "means the same" as this one
func (i Identity) I(other Identity) bool {
return i.Reduced == other.Reduced
}
// Is returns whether the other string "means the same" as this one
func (i Identity) Is(input string) bool {
reduced := Reduce(input)
return i.Reduced == reduced
}
// New creates a new Identity with default reduction
func New(input string) Identity {
reduced := Reduce(input)
return Identity{
Full: input,
Reduced: reduced,
}
}
// Reduce normalizes input (commands and filenames) to simplify them
func Reduce(input string) string {
reduced := input
reduced = strings.TrimPrefix(input, ".")
if !strings.HasPrefix(reduced, ".") {
reduced = strings.Split(reduced, ".")[0]
}
reduced = strings.ToLower(reduced)
return reduced
}
package pikdex
import (
"git.sr.ht/~ewy/pik/model"
"strings"
)
func (u *pikdex) Mod(src *model.Source, result *model.HydratedSource) error {
mod := u.Data[strings.TrimSuffix(src.Path, "/")]
if mod == nil {
return nil
}
if mod.Path != "" {
if mod.Aliases != nil {
result.Aliases = append(result.Aliases, mod.Aliases...)
}
if mod.Icon != "" {
result.Icon = mod.Icon
}
}
return nil
}
package pikdex
import (
"errors"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/paths"
"git.sr.ht/~ewy/pik/spool"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
"sync"
)
var SkippedFolders = []string{
".git",
".config",
".idea",
}
func (u *pikdex) Init() error {
// add own executable name to uwudexable dirs
self, err := os.Executable()
if strings.HasSuffix(self, ".test") {
return nil
}
if err != nil {
_, _ = spool.Warn("%v\n", err)
return nil
}
self = strings.TrimSuffix(self, ".exe")
paths.Roots = append(paths.Roots, "."+self, "_"+self)
return nil
}
var Indexer = &pikdex{Data: make(map[string]*model.SourceData)}
type pikdex struct {
sync.Mutex
Data map[string]*model.SourceData
}
func (u *pikdex) Index(absPath string, f fs.FS, runners []model.Runner) ([]model.Target, error) {
wants, root, err := u.WantsWalk(f)
if !wants {
return nil, err
}
var targets []model.Target
u.Lock()
mod := u.Data[absPath]
u.Unlock()
if mod == nil {
u.Lock()
u.Data[absPath] = &model.SourceData{
Path: absPath,
}
mod = u.Data[absPath]
u.Unlock()
}
err = fs.WalkDir(f, root, func(p string, d fs.DirEntry, err error) error {
if !d.IsDir() {
for trigger, applier := range MetaFiles {
// during the crawl, we might find meta files
expectedLocation := filepath.Join(absPath, root, trigger)
actualLocation := filepath.Join(absPath, p)
if expectedLocation != actualLocation {
continue
}
content, err := os.ReadFile(expectedLocation)
if err != nil {
_, _ = spool.Warn("%v\n", err)
continue
}
applier.MetaSetter(mod, string(content))
}
}
if d.IsDir() {
_, dirName := path.Split(p)
if slices.Contains(SkippedFolders, dirName) {
return fs.SkipDir
}
}
for _, r := range runners {
wants, err := r.Wants(f, p, d)
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
if wants {
t, err := r.CreateTarget(f, absPath, p, d)
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
sub := t.Sub()
if strings.Join(sub, " ") == t.ShortestId() {
desiredIndex := 0
for i := len(targets) - 1; i >= 0; i-- {
if slices.Equal(targets[i].Sub(), sub) {
desiredIndex = i
} else {
break
}
}
targets = slices.Insert(targets, desiredIndex, t)
} else {
targets = append(targets, t)
}
return nil
}
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
}
return nil
})
u.Lock()
u.Data[absPath] = mod
u.Unlock()
return targets, err
}
func (u *pikdex) WantsWalk(f fs.FS) (bool, string, error) {
entries, err := fs.ReadDir(f, ".")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return false, "", nil
} else {
return false, "", err
}
}
for _, e := range entries {
for _, r := range paths.Roots {
if e.Name() == r && e.IsDir() {
return true, r, nil
}
}
}
return false, "", nil
}
package pikdex
import (
"git.sr.ht/~ewy/pik/describe"
"git.sr.ht/~ewy/pik/model"
"github.com/charmbracelet/lipgloss"
"strings"
)
type MetaSetter func(s *model.SourceData, content string)
type MetaFile struct {
MetaSetter
Description string
}
var MetaFiles = map[string]*MetaFile{
".want": {MetaSetter: func(s *model.SourceData, content string) {
s.Wants = append(s.Wants, contentLines(content)...)
}, Description: "want[s] adds external sources to your pik state. Currently only local paths are supported, one per line. For example, including '../web' will ensure that folder will be included and callable in the pik state without having to provide the --all flag."},
".wants": {MetaSetter: func(s *model.SourceData, content string) {
s.Wants = append(s.Wants, contentLines(content)...)
}},
".include": {MetaSetter: func(s *model.SourceData, content string) {
s.Includes = append(s.Includes, contentLines(content)...)
}, Description: "include[s] adds items from external sources to this pik source. For example, adding '../shared' will add all targets from there to this source. Triggers from the included source will work, and all targets will be ran as if they were part of the including source."},
".includes": {MetaSetter: func(s *model.SourceData, content string) {
s.Includes = append(s.Includes, contentLines(content)...)
}},
".alias": {MetaSetter: func(s *model.SourceData, content string) {
s.Aliases = contentLines(content)
}, Description: "alias can contain multiple lines with alternate names for this source. The first non-comment line will be used as the display name. You can use this for, for example, abbreviations."},
".icon": {MetaSetter: func(s *model.SourceData, content string) {
lines := contentLines(content)
if len(lines) == 0 {
return
}
icon := string([]rune(lines[0])[:2])
desiredWidth := lipgloss.Width(icon)
diff := desiredWidth - len([]rune(icon))
if diff > 0 {
icon += strings.Repeat(" ", diff)
}
s.Icon = icon
}, Description: "icon can contain unicode symbols representing your source. It will be used in the TUI. It will be cropped to maximum two symbols and padded if shorter."},
}
func contentLines(input string) []string {
result := make([]string, 0, len(input))
nextLine:
for _, l := range strings.Split(input, "\n") {
l = strings.TrimSpace(l)
if l == "" {
continue nextLine
}
for _, c := range describe.CommentPrefixes {
if strings.HasPrefix(l, c) {
continue nextLine
}
}
result = append(result, l)
}
return result
}
package main
import (
_ "embed"
"fmt"
"git.sr.ht/~ewy/pik/cache"
"git.sr.ht/~ewy/pik/crawl"
"git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/git"
"git.sr.ht/~ewy/pik/indexers/pikdex"
"git.sr.ht/~ewy/pik/menu"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/paths"
"git.sr.ht/~ewy/pik/run"
"git.sr.ht/~ewy/pik/runner/exc"
"git.sr.ht/~ewy/pik/runner/gnumake"
"git.sr.ht/~ewy/pik/runner/js"
"git.sr.ht/~ewy/pik/runner/just"
"git.sr.ht/~ewy/pik/runner/python"
"git.sr.ht/~ewy/pik/runner/shell"
"git.sr.ht/~ewy/pik/search"
"git.sr.ht/~ewy/pik/spool"
"github.com/spf13/pflag"
"os"
"runtime/pprof"
)
// syncInitializers are ran before the initializers.
// useful for initializing stuff like paths, preparing directories, and reading the environment
var syncInitializers = ComponentList[model.Initializer]{
paths.Component,
}
// initializers are ran before indexing with the indexers,
// data from the syncInitializers can be accessed at this time.
var initializers = ComponentList[model.Initializer]{
pikdex.Indexer,
python.Python,
git.Git,
js.Js,
}
// indexers are methods which scan a directory and return a number of targets.
var indexers = ComponentList[model.Indexer]{
pikdex.Indexer,
just.Indexer,
gnumake.Indexer,
js.Js,
}
// runners are modules which know how to turn a file into an exec.Cmd
// all indexers have access to these but only pikdex uses it
var runners = ComponentList[model.Runner]{
shell.Runner,
python.Python,
exc.Exc,
js.Js,
}
// hydrators are ran when the menu is required
// for example adding git info, descriptions, icons...
var hydrators = ComponentList[model.Modder]{
pikdex.Indexer,
git.Git,
}
// ForceConfirm means we will have to ask for confirmation before running no matter what
var ForceConfirm = false
// SourcesWithoutResults is a failed cache from the previous iteration
// used for stripping out results to prevent double-index
var SourcesWithoutResults *cache.Cache
func main() {
result := pik()
if profileFd != nil {
pprof.StopCPUProfile()
err := profileFd.Close()
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
}
if result.Value != 0 {
result.Exit()
}
}
func mode[T any](list ModeMap[T], fire func(mode T) *spool.ExitCode) *spool.ExitCode {
code := list.Traverse(func(in T) *spool.ExitCode {
return fire(in)
})
if code != nil {
code.Exit()
}
return nil
}
func pik() *spool.ExitCode {
// initialize the flags outside the main method so it can re-run in case
// pik changes its own configuration at runtime
pflag.Usage = func() { _ = uninitializedModes[flags.Help]() }
pflag.Parse()
code := mode(uninitializedModes, func(mode func() *spool.ExitCode) *spool.ExitCode {
return mode()
})
if code != nil {
return code
}
code = mode(statelessModes, func(mode func() *spool.ExitCode) *spool.ExitCode {
return mode()
})
if code != nil {
return code
}
syncInitializers.RunSync(func(initializer model.Initializer) error {
return initializer.Init()
})
initializers.RunAsync(func(initializer model.Initializer) error {
return initializer.Init()
})
here, err := os.Getwd()
if err != nil {
_, _ = spool.Warn("%v\n", err)
return spool.WorkingDirectoryFailure
}
locs := append(crawl.RichLocations(here), paths.System.String())
root, err := os.OpenRoot("/")
defer func(root *os.Root) {
err := root.Close()
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
}(root)
if root == nil {
_, _ = spool.Warn("%v\n", err)
return spool.OpenRootFailure
}
fs := root.FS()
if err != nil {
_, _ = spool.Warn("%v\n", err)
return spool.RootFsFailure
}
var st *model.State
var stateErrors []error
var c cache.Cache
if !*flags.All {
st, stateErrors = model.NewState(fs, locs, pikdex.Indexer.Data, indexers, runners)
go func() {
if len(stateErrors) > 0 {
return
}
err := cache.MergeAndSave(st)
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
}()
} else {
c, err = cache.Get()
if err != nil {
_, _ = spool.Warn("%v\n", err)
return spool.CacheReadFailure
}
st, stateErrors = cache.LoadState(fs, pikdex.Indexer.Data, c, indexers, runners)
}
if stateErrors != nil {
_, _ = spool.Warn("%v\n", stateErrors)
}
code = mode(statefulModes, func(mode func(st *model.State) *spool.ExitCode) *spool.ExitCode {
return mode(st)
})
if code != nil {
return code
}
args := pflag.Args()
var result *search.Result
cancelled := false
if len(args) == 0 {
md, err := menu.Show(st, hydrators)
if err != nil {
_, _ = spool.Warn("%v\n", err)
return spool.HydrationFailure
}
cancelled = md.Cancel
source, target := md.Result()
if target != nil {
t := target.Target()
result = &search.Result{
Target: t,
Source: source.Source,
NeedsConfirmation: false,
Overridden: t.Tags().Has(model.Override),
Sub: t.Sub(),
}
}
}
if result == nil {
result = search.Search(st, args...)
}
// TODO: Move auto-all logic into Search?
if !*flags.All && result.Target == nil && len(result.Args) > 0 && SourcesWithoutResults == nil && !ForceConfirm {
ForceConfirm = true
SourcesWithoutResults = &c
return pik()
}
if cancelled {
_, _ = spool.Warn("operation cancelled\n")
return spool.Cancelled
}
if result.Target == nil {
_, _ = spool.Warn("target not found\n")
return spool.NotFoundFailure
}
if result.NeedsConfirmation || ForceConfirm {
_, _ = fmt.Fprintf(os.Stderr, "this target is out of tree\n")
if !menu.Confirm(os.Stdin, result.Source, result.Target, args...) {
return spool.Cancelled
}
}
if result.Overridden {
_, _ = fmt.Fprintln(os.Stderr, menu.OverrideWarning(result.Target))
}
code = mode(selectionModes, func(mode func(st *model.State, src *model.Source, t model.Target) *spool.ExitCode) *spool.ExitCode {
return mode(st, result.Source, result.Target)
})
if code != nil {
return code
}
err = run.Run(result.Source, result.Target, result.Args...)
if err != nil {
_, _ = spool.Warn("%v\n", err)
return spool.MenuFailure
}
return spool.Success
}
package manview
import (
_ "embed"
_ "git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/indexers/pikdex"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/spool"
"github.com/spf13/pflag"
"runtime"
"runtime/debug"
"time"
)
//go:embed version.txt
var version string
type ManData struct {
Flags []pflag.Flag
Now string
Revision string
Runtime string
Version string
ExitCodes map[int]*spool.ExitCode
MetaFiles map[string]*pikdex.MetaFile
TagMap map[string]*model.Tag
}
func NewData() ManData {
var flags []pflag.Flag
pflag.Parse()
pflag.VisitAll(func(flag *pflag.Flag) {
flags = append(flags, *flag)
})
info, ok := debug.ReadBuildInfo()
if !ok {
_, _ = spool.Panic(spool.NoDebugInfo, "could not read debug info\n")
}
return ManData{
Flags: flags,
Now: time.Now().Format(time.DateTime),
Runtime: runtime.Version(),
Version: version,
Revision: info.Main.Version,
ExitCodes: spool.CodeMap,
MetaFiles: pikdex.MetaFiles,
TagMap: model.TagMap,
}
}
package manview
import (
"embed"
_ "embed"
"git.sr.ht/~ewy/pik/spool"
"os"
"os/exec"
"strings"
"text/template"
)
//go:embed templates
var pageTemplates embed.FS
var page = template.Must(template.ParseFS(pageTemplates, "templates/*"))
func View(fallback string) error {
man, manErr := exec.LookPath("man")
if manErr != nil {
_, err := spool.Print("%v\n", fallback)
return err
} else {
t := &strings.Builder{}
d := NewData()
err := page.Execute(t, d)
if err != nil {
return err
}
reader := strings.NewReader(t.String())
cmd := exec.Command(man, "-l", "-")
cmd.Stdin = reader
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
}
package main
import (
"embed"
"git.sr.ht/~ewy/pik/man/manview"
"git.sr.ht/~ewy/pik/spool"
"github.com/spf13/pflag"
"os"
"path/filepath"
"strings"
"text/template"
)
//go:embed manview/templates
var templates embed.FS
var ManOutput = pflag.String(manFlagName, "out", "directory to write man pages to (gets created)")
const manFlagName = "man-output"
const templateDir = "templates"
const manExtension = ".man"
const templateExtension = ".tmpl"
func main() {
pflag.Parse()
tmpl, err := template.ParseFS(templates, "*/*/*.tmpl")
if err != nil {
_, _ = spool.Panic(spool.ManFailure, "%v\n", err)
return
}
err = os.MkdirAll(*ManOutput, os.ModePerm)
if err != nil {
_, _ = spool.Panic(spool.ManFailure, "%v\n", err)
}
d := manview.NewData()
for _, t := range tmpl.Templates() {
if !strings.HasSuffix(t.Name(), manExtension+templateExtension) {
continue
}
resultFile, err := os.OpenFile(filepath.Join(*ManOutput, strings.TrimSuffix(strings.TrimSuffix(t.Name(), templateExtension), manExtension)), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
_, _ = spool.Panic(spool.ManFailure, "%v\n", err)
}
err = t.Execute(resultFile, d)
if err != nil {
_, _ = spool.Panic(spool.ManFailure, "%v\n", err)
}
}
}
package menu
import (
"git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/menu/style"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/paths"
"github.com/charmbracelet/lipgloss"
"os/exec"
"strings"
)
var (
BannerStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
BannerSourceLabelStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Faint(true).MarginRight(1)
})
BannerSubItemStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Faint(true).MarginRight(1)
})
BannerSubStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
BannerSelfStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().MarginRight(1).Bold(true)
})
BannerPromptStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
BannerArgsStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().MarginLeft(1)
})
BannerArgStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
BannerTerminatorColor = lipgloss.Color("1")
BannerTerminatorStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Faint(true).Foreground(BannerTerminatorColor)
})
BannerDryColor = lipgloss.Color("1")
BannerDryStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(BannerDryColor).Bold(true).MarginRight(1)
})
BannerDefaultStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Faint(true).MarginLeft(1)
})
)
func Banner(source *model.Source, target model.Target, args ...string) string {
var parts, argParts []string
if *flags.Dry {
parts = append(parts, BannerDryStyle.Render("DRY"))
}
parts = append(parts, BannerPromptStyle.Render("> "))
parts = append(parts, BannerSelfStyle.Render("pik"))
parts = append(parts, BannerSourceLabelStyle.Render(source.Label()))
def := false
if sub := target.Sub(); sub != nil {
// remove "default" invocations
if sub[len(sub)-1] == target.ShortestId() {
sub = sub[:len(sub)-1]
def = true
}
for i, s := range sub {
sub[i] = BannerSubItemStyle.Render(s)
}
parts = append(parts, BannerSubStyle.Render(sub...))
}
parts = append(parts, target.ShortestId())
if args != nil {
needsTerminator := false
for _, a := range args {
if strings.HasPrefix(a, "-") {
needsTerminator = true
}
argParts = append(argParts, BannerArgStyle.Render(a))
}
if needsTerminator {
argParts = append([]string{BannerTerminatorStyle.Render("--")}, argParts...)
}
parts = append(parts, BannerArgsStyle.Render(argParts...))
}
if def {
parts = append(parts, BannerDefaultStyle.Render("# "+target.Label()))
}
result := BannerStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, parts...))
return result
}
var (
CmdStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Faint(true)
})
CmdDirStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
CmdArgStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
)
func InlineCmd(cmd *exec.Cmd) string {
var args []string
for _, a := range cmd.Args {
args = append(args, paths.ReplaceHome(a))
}
return CmdStyle.Render(" # "+CmdDirStyle.Render(paths.ReplaceHome(cmd.Dir)+":"), CmdArgStyle.Render(args...))
}
var (
OverrideStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
OverrideCaretColor = lipgloss.Color("1")
OverrideCaretStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(OverrideCaretColor).Bold(true)
})
OverrideTextStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Faint(true)
})
)
func OverrideWarning(t model.Target) string {
return OverrideStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left,
OverrideCaretStyle.Render("! "),
OverrideTextStyle.Render("overridden by "+t.Label()),
))
}
package menu
import (
"bufio"
"fmt"
"git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/menu/style"
"git.sr.ht/~ewy/pik/model"
"github.com/charmbracelet/lipgloss"
"io"
"os"
"slices"
)
var confirmations = []rune{
'y',
'Y',
' ',
'\n',
}
var (
PromptColor = lipgloss.Color("1")
PromptStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle()
if !*flags.Yes {
st.Foreground(PromptColor)
}
return st
})
ConfirmStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
AnswerStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
YesStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Faint(true)
})
)
func Confirm(r io.Reader, source *model.Source, target model.Target, args ...string) bool {
parts := []string{
ConfirmStyle.Render(PromptStyle.Render("[Y/n]")),
Banner(source, target, args...),
"? ",
}
banner := BannerStyle.Render(parts...)
_, _ = fmt.Fprint(os.Stderr, banner)
if *flags.Yes {
_, _ = fmt.Fprintln(os.Stderr, AnswerStyle.Render("Y", YesStyle.Render("(--yes)")))
return true
}
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanRunes)
scanner.Scan()
if slices.Contains(confirmations, []rune(scanner.Text())[0]) {
return true
} else {
_, _ = fmt.Fprint(os.Stderr, "confirmation was not given.")
}
return false
}
package menu
import (
"git.sr.ht/~ewy/pik/menu/style"
"git.sr.ht/~ewy/pik/model"
"github.com/charmbracelet/lipgloss"
"strconv"
)
var (
GitColor = lipgloss.Color("4")
GitInfoStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Background(GitColor).Border(lipgloss.OuterHalfBlockBorder(), false, false, false, true).BorderBackground(GitColor).Padding(0, 1)
})
GitStatusStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Background(GitColor).PaddingLeft(1)
})
GitAddColor = lipgloss.Color("2")
GitAddStyle = style.New(func() lipgloss.Style {
return GitStatusStyle.Get().Foreground(GitAddColor)
})
GitRemoveColor = lipgloss.Color("1")
GitRemoveStyle = style.New(func() lipgloss.Style {
return GitStatusStyle.Get().Foreground(GitRemoveColor)
})
GitChangeColor = lipgloss.Color("5")
GitChangeStyle = style.New(func() lipgloss.Style {
return GitStatusStyle.Get().Foreground(GitChangeColor)
})
)
func Git(info *model.GitInfo) string {
var parts = []string{
" ",
info.Branch,
}
if info.Insertions > 0 {
parts = append(parts, GitAddStyle.Render("+"+strconv.Itoa(info.Insertions)))
}
if info.Deletions > 0 {
parts = append(parts, GitRemoveStyle.Render("-"+strconv.Itoa(info.Deletions)))
}
if info.Changes > 0 {
parts = append(parts, GitChangeStyle.Render("~"+strconv.Itoa(info.Changes)))
}
if info.Changes == 0 && info.Deletions == 0 && info.Insertions == 0 {
parts = append(parts, GitAddStyle.Render("clean"))
}
return GitInfoStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left,
parts...,
))
}
package menu
import (
"git.sr.ht/~ewy/pik/menu/style"
"github.com/charmbracelet/lipgloss"
"strings"
)
var (
IconStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().Width(2).Height(1)
return st
})
)
func Icon(input string) string {
return IconStyle.Render(input)
}
func PaddedIcon(input string) string {
if strings.TrimSpace(input) == "" {
return Icon(" ")
}
return Icon(input)
}
package menu
import tea "github.com/charmbracelet/bubbletea"
func (m *Model) HandleInput(msg tea.KeyMsg) (tea.Cmd, error) {
if m.Search.Focused() {
var cmd tea.Cmd
switch msg.String() {
case "ctrl+c":
m.Search.SetValue("")
m.Search.Blur()
case "ctrl+d":
m.Search.Blur()
case "enter":
m.Search.Blur()
default:
result, c := m.Search.Update(msg)
cmd = c
m.Search = result
}
return cmd, nil
}
var cmd tea.Cmd
switch msg.String() {
case "/":
m.Search.SetValue("")
fallthrough
case "?":
return m.Search.Focus(), nil
case "i", "I":
if m.Alt {
m.Alt = false
m.AutoAlt = false
return tea.ExitAltScreen, nil
} else {
m.Alt = true
m.AutoAlt = false
return tea.EnterAltScreen, nil
}
case "h", "left":
m.Leap(-1)
case "l", "right":
m.Leap(1)
case "up", "k":
m.Index--
case "down", "j":
m.Index++
case "n":
m.LeapFilter(1)
case "N":
m.LeapFilter(-1)
case "q", "esc", "ctrl+c":
m.Cancel = true
return tea.Quit, nil
case "space", " ", "enter", "ctrl+d":
m.Done = true
return tea.Quit, nil
}
_ = m.Validate()
return cmd, nil
}
func (m *Model) LeapFilter(direction int) {
startIndex := m.Index
for {
m.Index += direction
clamped := m.Validate()
if clamped {
m.Index = startIndex
return
}
source, target := m.Result()
if m.Highlights(source, target) {
return
}
}
}
func (m *Model) Leap(direction int) {
for {
source, target := m.Result()
m.Index += direction
m.Validate()
newSource, newTarget := m.Result()
if target == newTarget {
return
}
if source != newSource {
return
}
}
}
package menu
import (
"errors"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/spool"
tea "github.com/charmbracelet/bubbletea"
)
var WrongModelTypeError = errors.New("wrong model type")
var NoSourcesIndexedError = errors.New("no sources indexed")
func Show(st *model.State, hydrators []model.Modder) (*Model, error) {
if len(st.Sources) == 0 {
return nil, NoSourcesIndexedError
}
md := NewModel(st, hydrators)
program := tea.NewProgram(md)
resultModel, err := program.Run()
if err != nil {
return nil, err
}
result, ok := resultModel.(*Model)
if !ok {
return nil, WrongModelTypeError
}
return result, nil
}
func Hydrate(st *model.State, hydrators []model.Modder) *model.HydratedState {
hyd := &model.HydratedState{
State: st,
HydratedSources: make([]*model.HydratedSource, len(st.Sources)),
}
for i, s := range st.Sources {
hydSrc := s.Hydrate(hydrators)
for _, h := range hydrators {
err := h.Mod(s, hydSrc)
if err != nil {
spool.Warn("%v\n", err)
continue
}
}
hyd.HydratedSources[i] = hydSrc
}
return hyd
}
package menu
import (
"git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/motd"
"git.sr.ht/~ewy/pik/spool"
"git.sr.ht/~ewy/pik/viewport"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/term"
"github.com/spf13/pflag"
"os"
"strings"
)
type Model struct {
*model.HydratedState
Index int
Indices map[int]model.HydratedTarget
SourceIndices map[int]*model.HydratedSource
Cancel bool
Done bool
Height int
Alt bool
AutoAlt bool
Search textinput.Model
Motd string
}
func (m *Model) Highlights(src *model.HydratedSource, t model.HydratedTarget) bool {
val := m.Search.Value()
if val == "" {
return false
}
if strings.Contains(t.Target().Label(), val) {
return true
}
if strings.Contains(t.Description(src), val) {
return true
}
if strings.Contains(strings.Join(append(t.Target().Sub(), t.Target().Label()), " "), val) {
return true
}
return false
}
func (m *Model) Init() tea.Cmd {
_, h, err := term.GetSize(0)
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
m.Height = h
wantsAlt := viewport.NeedsViewport(m.State(), m.Height)
if m.AutoAlt && wantsAlt {
return tea.EnterAltScreen
}
return nil
}
func (m *Model) HandleResize(msg tea.WindowSizeMsg) tea.Cmd {
if !m.AutoAlt {
return nil
}
m.Height = msg.Height
if viewport.NeedsViewport(m.State(), msg.Height) {
m.Alt = true
return tea.EnterAltScreen
} else {
m.Alt = false
return tea.ExitAltScreen
}
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var err error
var result tea.Cmd
switch mt := msg.(type) {
case tea.WindowSizeMsg:
result = m.HandleResize(mt)
case tea.KeyMsg:
result, err = m.HandleInput(mt)
case tea.Cmd:
result, err = m.HandleSignal(mt)
}
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
return m, result
}
func (m *Model) HandleSignal(cmd tea.Cmd) (tea.Cmd, error) {
return nil, nil
}
func (m *Model) View() string {
if m.Cancel || m.Done {
return ""
}
result := m.State()
result = viewport.Process(result, m.Height)
if m.Search.Focused() {
result = m.AddSearch(result)
}
return result
}
func (m *Model) Result() (*model.HydratedSource, model.HydratedTarget) {
if m.Cancel {
return nil, nil
}
return m.SourceIndices[m.Index], m.Indices[m.Index]
}
func (m *Model) Validate() (clamped bool) {
if m.Index < 0 {
m.Index = 0
return true
}
if m.Index > len(m.Indices)-1 {
m.Index = len(m.Indices) - 1
return true
}
return false
}
var ForcedInlineTerminals = map[string]string{
"TERMINAL_EMULATOR": "JetBrains-JediTerm",
}
func NewModel(st *model.State, hydrators []model.Modder) *Model {
isBanned := false
for k, v := range ForcedInlineTerminals {
if os.Getenv(k) == v {
isBanned = true
break
}
}
m := &Model{
HydratedState: Hydrate(st, hydrators),
Index: 0,
Indices: make(map[int]model.HydratedTarget),
SourceIndices: make(map[int]*model.HydratedSource),
Alt: !*flags.Inline,
AutoAlt: !pflag.Lookup("inline").Changed && !isBanned,
Motd: motd.One(),
Search: textinput.New(),
}
idx := 0
for _, src := range st.Sources {
hydSrc := src.Hydrate(hydrators)
for _, target := range src.Targets {
if !target.Visible() {
continue
}
hydTarget, err := target.Hydrate(src)
m.Indices[idx] = hydTarget
if err != nil {
spool.Warn("%v\n", err)
}
m.SourceIndices[idx] = hydSrc
idx++
}
}
return m
}
package menu
import "strings"
func (m *Model) AddSearch(croppedInput string) string {
lines := strings.Split(croppedInput, "\n")
lastIndex := len(lines) - 1
view := m.Search.View()
lines[lastIndex] = view
return strings.Join(lines, "\n")
}
package menu
import (
"git.sr.ht/~ewy/pik/menu/style"
"git.sr.ht/~ewy/pik/model"
"github.com/charmbracelet/lipgloss"
"slices"
"strings"
)
var (
SourceStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle()
return st
})
SourceHeaderBackground = lipgloss.Color("5")
SourceHeaderStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle()
return st
}).Debug()
SourceLabelStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().Border(lipgloss.OuterHalfBlockBorder(), false, false, false, true).Background(SourceHeaderBackground).BorderBackground(SourceHeaderBackground).PaddingRight(1).PaddingLeft(1).MarginRight(1)
return st
})
SourceTargetsStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle()
return st
})
SourcePathStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().Faint(true)
return st
})
SourceIconStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Width(3)
})
)
func (m *Model) Source(src *model.HydratedSource) string {
targets := make([]string, 0, len(src.Targets))
var sub []string
for _, t := range src.HydratedTargets {
ts := t.Target().Sub()
header := !slices.Equal(sub, ts)
if header {
sub = ts
}
if header && strings.Join(ts, " ") != t.Target().ShortestId() {
targets = append(targets, m.Category(strings.Join(ts, " "), ""))
header = false
}
targets = append(targets, m.Target(src, t, header))
}
targetContent := lipgloss.JoinVertical(lipgloss.Top, targets...)
icon := SourceIconStyle.Render(PaddedIcon(src.Icon))
parts := []string{
SourceHeaderStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, SourceLabelStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, icon, src.Label()), SourcePathStyle.Render(src.ShortPath())))),
SourceTargetsStyle.Render(targetContent),
}
if src.Git != nil {
parts = append(parts, Git(src.Git))
}
return SourceStyle.Render(strings.Join(parts, "\n"))
}
package menu
import (
"git.sr.ht/~ewy/pik/menu/style"
"github.com/charmbracelet/lipgloss"
)
var (
StateStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().MarginBottom(1)
})
MotdStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Faint(true).PaddingLeft(1)
})
)
func (m *Model) State() string {
st := m.HydratedState
sources := make([]string, 0, len(st.Sources))
for i, hs := range st.HydratedSources {
src := m.Source(hs)
// do not pad the bottom source, the motd goes there
if i != len(st.HydratedSources)-1 {
src += "\n"
}
sources = append(sources, src)
}
return StateStyle.Render(lipgloss.JoinVertical(lipgloss.Top, sources...), MotdStyle.Render("\n \U000F08B7 "+m.Motd))
}
package style
import "github.com/charmbracelet/lipgloss"
func (s Style) Debug() Style {
return New(func() lipgloss.Style {
return s.builder().Foreground(lipgloss.Color("2")).Background(lipgloss.Color("1"))
})
}
package style
import "github.com/charmbracelet/lipgloss"
type Builder func() lipgloss.Style
type Style struct {
style *lipgloss.Style
builder Builder
}
func New(builder Builder) Style {
return Style{
builder: builder,
}
}
func (s *Style) Get() lipgloss.Style {
if s.style == nil {
st := s.builder()
s.style = &st
}
return *s.style
}
func (s *Style) Render(input ...string) string {
return s.Get().Render(input...)
}
package menu
import (
"git.sr.ht/~ewy/pik/menu/style"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/viewport"
"github.com/charmbracelet/lipgloss"
)
var (
TargetBackgroundColor = lipgloss.Color("8")
SelectedTargetBackgroundColor = lipgloss.Color("2")
TargetStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().Border(lipgloss.OuterHalfBlockBorder(), false, false, false, true)
return st
})
TargetHighlightedColor = lipgloss.Color("1")
TargetHighlightedStyle = style.New(func() lipgloss.Style {
return TargetStyle.Get().Foreground(TargetHighlightedColor)
})
SelectedTargetStyle = style.New(func() lipgloss.Style {
return TargetStyle.Get().BorderBackground(SelectedTargetBackgroundColor).Background(SelectedTargetBackgroundColor)
})
TargetLabelStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().MarginRight(1)
return st
})
TargetDescriptionStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().Faint(true).MarginLeft(1)
return st
})
SelectedTargetDescriptionStyle = style.New(func() lipgloss.Style {
st := TargetDescriptionStyle.Get().Faint(false)
return st
})
TargetIconStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().PaddingLeft(1)
return st
})
TargetIconSelectedStyle = style.New(func() lipgloss.Style {
return TargetIconStyle.Get().MarginLeft(1).PaddingLeft(0)
})
TargetSubStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
CategoryColor = lipgloss.Color("7")
CategoryStyle = style.New(func() lipgloss.Style {
return TargetStyle.Get().BorderForeground(CategoryColor).Background(CategoryColor).BorderBackground(CategoryColor)
})
OrphanCategoryStyle = style.New(func() lipgloss.Style {
return TargetLabelStyle.Get().PaddingLeft(1)
})
)
func (m *Model) Target(src *model.HydratedSource, t model.HydratedTarget, header bool) string {
_, selection := m.Result()
selected := selection != nil && selection.Target() == t.Target()
icon := ""
if selected {
icon = TargetIconSelectedStyle.Render(PaddedIcon(viewport.Caret))
} else {
icon = TargetIconStyle.Render(PaddedIcon(t.Icon()))
}
selectionStyle := TargetStyle
selectionDescriptionStyle := TargetDescriptionStyle
if m.Highlights(src, t) {
selectionStyle = TargetHighlightedStyle
}
if selected {
selectionStyle = SelectedTargetStyle
selectionDescriptionStyle = SelectedTargetDescriptionStyle
} else if header {
selectionStyle = CategoryStyle
}
var labelParts []string
labelParts = append(labelParts, icon)
sub := t.Target().Sub()
if sub != nil && sub[len(sub)-1] != t.Target().ShortestId() {
labelParts = append(labelParts, TargetSubStyle.Render(sub...))
}
labelParts = append(labelParts, TargetLabelStyle.Render(t.Target().Label()))
return lipgloss.JoinHorizontal(lipgloss.Left, selectionStyle.Render(labelParts...), selectionDescriptionStyle.Render(t.Description(src)))
}
func (m *Model) Category(input string, desc string) string {
return CategoryStyle.Render(lipgloss.JoinHorizontal(lipgloss.Left, OrphanCategoryStyle.Render(input), TargetDescriptionStyle.Render(desc)))
}
package model
import (
"errors"
"io/fs"
"path/filepath"
"slices"
"strings"
)
func Include(root fs.FS, st *State, data map[string]*SourceData, indexers []Indexer, runners []Runner) error {
for _, s := range st.Sources {
if data[s.Path] == nil || data[s.Path].Includes == nil {
continue
}
for _, w := range data[s.Path].Includes {
if IsRelative(w) {
p, err := filepath.Abs(filepath.Join(s.Path, w))
if err != nil {
return err
}
// check if source is already included
for _, maybeSource := range st.Sources {
if maybeSource.Path == p {
continue
}
}
src, errs := NewSource(root, p, indexers, runners)
if len(errs) > 0 {
return errors.Join(errs...)
}
s.Targets = append(s.Targets, src.Targets...)
}
}
}
return nil
}
func Wants(root fs.FS, st *State, data map[string]*SourceData, indexers []Indexer, runners []Runner) error {
for i, s := range st.Sources {
if data[s.Path] == nil || data[s.Path].Wants == nil {
continue
}
for _, w := range data[s.Path].Wants {
if IsRelative(w) {
p, err := filepath.Abs(filepath.Join(s.Path, w))
if err != nil {
return err
}
// check if source is already included
for _, maybeSource := range st.Sources {
if maybeSource.Path == p {
continue
}
}
src, errs := NewSource(root, p, indexers, runners)
if len(errs) > 0 {
return errors.Join(errs...)
}
st.Sources = slices.Insert(st.Sources, i+1, src)
}
}
}
return nil
}
func IsRelative(path string) bool {
return strings.HasPrefix(path, ".")
}
package model
import (
"errors"
"git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/spool"
"io/fs"
"path/filepath"
"strings"
"sync"
)
func NewSource(rootFs fs.FS, loc string, indexers []Indexer, runners []Runner) (*Source, []error) {
var errs []error
_, dirName := filepath.Split(strings.TrimSuffix(loc, "/"))
src := &Source{
Path: loc,
Identity: identity.New(dirName),
Whitelists: make(map[string][]string),
}
loc = strings.TrimSuffix(loc, "/")
loc = strings.TrimPrefix(loc, "/")
if loc == "" {
return nil, nil
}
locationWg := sync.WaitGroup{}
var targets = make([][]Target, len(indexers), len(indexers))
for ti, indexer := range indexers {
locationWg.Go(func() {
subFs, err := fs.Sub(rootFs, loc)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
return
}
result, err := indexer.Index("/"+loc, subFs, runners)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
return
}
targets[ti] = result
})
}
locationWg.Wait()
for _, t := range targets {
if t == nil {
continue
}
src.Targets = append(src.Targets, t...)
}
return src, errs
}
func NewState(rootFs fs.FS, locations []string, data map[string]*SourceData, indexers []Indexer, runners []Runner) (*State, []error) {
var errs []error
st := &State{
All: *flags.All,
}
wg := sync.WaitGroup{}
var sources = make([]*Source, len(locations), len(locations))
for i, loc := range locations {
wg.Go(func() {
src, err := NewSource(rootFs, loc, indexers, runners)
errs = append(errs, err...)
for _, e := range err {
_, _ = spool.Warn("%v\n", e)
}
sources[i] = src
})
}
wg.Wait()
for _, s := range sources {
if s == nil || s.Targets == nil {
continue
}
st.Sources = append(st.Sources, s)
}
err := Wants(rootFs, st, data, indexers, runners)
if err != nil {
errs = append(errs, err)
}
err = Include(rootFs, st, data, indexers, runners)
if err != nil {
errs = append(errs, err)
}
return st, errs
}
package model
import (
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/paths"
"git.sr.ht/~ewy/pik/spool"
)
// SourceData is data we want to save from a Source before it has been properly instantiated
type SourceData struct {
Aliases []string
Icon string
Path string
Wants []string
Includes []string
}
// Source is a location containing stuff we can run
// these get created when we find a makefile, .pik folder, etc.
type Source struct {
identity.Identity
SourceData
Tags
Path string
Targets []Target
Whitelists map[string][]string
}
// HydratedSource is a Source with additional hydration
// for the menu.
// these do not get created unless we show the menu
type HydratedSource struct {
*Source
HydratedTargets []HydratedTarget
Aliases []string
Icon string
Git *GitInfo
}
func (s *Source) Label() string {
return s.Identity.Full
}
func (s *HydratedSource) Label() string {
if len(s.Aliases) > 0 {
return s.Aliases[0]
}
return s.Identity.Full
}
func (s *Source) Hydrate(hydrators []Modder) *HydratedSource {
hs := &HydratedSource{
Source: s,
HydratedTargets: make([]HydratedTarget, 0, len(s.Targets)),
}
for _, h := range hydrators {
err := h.Mod(s, hs)
if err != nil {
spool.Warn("%v", err)
}
}
for _, t := range s.Targets {
if !t.Visible() {
continue
}
ht, err := t.Hydrate(s)
if err != nil {
spool.Warn("%v", err)
continue
}
hs.HydratedTargets = append(hs.HydratedTargets, ht)
}
return hs
}
func (s *Source) ShortPath() string {
return paths.ReplaceHome(s.Path)
}
package model
import (
"slices"
"strings"
)
// Tag is some text which is contained in a filename which triggers pik functionality
type Tag struct {
Name string
Description string
}
type TagAction func(src *Source)
// New creates a new tag and registers it in the subsystems
func New(input string, description string) *Tag {
t := &Tag{
Name: input,
Description: description,
}
TagMap[input] = t
TagList = append(TagList, t)
return t
}
var (
// Here will force the target to run in the current directory instead of the source directory
Here = New("here", "run target in the currrent working directory instead of the source root. Same as invoking with `pik --here`.")
// Pre turns the target into a trigger, causing it to be triggered before another target gets ran
Pre = New("pre", "in non-single mode, run this target before the actually invoked target (this one must succeed)")
// Post turns the target into a trigger, causing it to be triggered after another target gets ran and exits succesfully
Post = New("post", "in non-single mode, run this target after the actually invoked target (if it succeeded)")
// Final turns the target into a trigger, causing it to be triggered after another target gets ran
Final = New("final", "in non-single mode, run this target after the actually invoked target regardless of failure")
// Hidden means the target is not visible in the menu
Hidden = New("hidden", "do not show this target in the TUI. Still invokable through the command line.")
// Single means this target will not use any triggers
Single = New("single", "do not run any triggers (like pre, post, and final). Same as invoking with `pik --single`.")
// Override means this should be selected instead of a non-override target, if possible
Override = New("override", "the .override tag will make this target get selected over other targets with the same name. Use this when your machine needs a custom workflow. It is discouraged to check these into version control, as everyone will receive your override.")
)
func (t *Tag) String() string {
return t.Name
}
var TagList []*Tag
var TagMap = map[string]*Tag{}
type Tags []*Tag
func (t Tags) AnyOf(expected ...*Tag) bool {
if len(expected) > 1 && len(t) == 0 {
return false
}
if len(expected) == 0 {
return true
}
for _, e := range expected {
if slices.Contains(t, e) {
return true
}
}
return false
}
func (t Tags) Has(expected *Tag) bool {
return slices.Contains(t, expected)
}
func TagsFromFilename(filename string) Tags {
var tags Tags
// if hidden
if strings.HasPrefix(filename, ".") {
filename = strings.TrimPrefix(filename, ".")
tags = append(tags, Hidden)
}
parts := strings.Split(filename, ".")
if len(parts) == 1 {
return nil
}
for _, p := range parts {
p = strings.ToLower(p)
if TagMap[p] != nil {
tags = append(tags, TagMap[p])
}
}
return tags
}
func (t Tags) Visible() bool {
return !t.AnyOf(Hidden, Pre, Post, Final)
}
package main
import (
_ "embed"
"git.sr.ht/~ewy/pik/completion"
"git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/man/manview"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/paths"
"git.sr.ht/~ewy/pik/run"
"git.sr.ht/~ewy/pik/spool"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
)
// ModeMap maps flags to specific operation modes
type ModeMap[T any] map[*bool]T
//go:embed man/manview/help.txt
var help string
//go:embed man/manview/version.txt
var version string
// Traverse checks the entries of the map. If any flags are set on,
// pik that mode. If Continue is returned, it's non-exclusive. Otherwise,
// we quit after one mode.
//
// `then` should simply be the method call (necessary due to generics)
func (m ModeMap[T]) Traverse(then func(in T) *spool.ExitCode) *spool.ExitCode {
for enabled, mode := range m {
if !*enabled {
continue
}
return then(mode)
}
return nil
}
var profileFd *os.File
// uninitializedModes are modes which can run before the program runs initializers
var uninitializedModes = ModeMap[func() *spool.ExitCode]{
flags.Version: func() *spool.ExitCode {
_, _ = spool.Print("%s\n", version)
return spool.Success
},
flags.Completion: func() *spool.ExitCode {
return completion.Echo()
},
flags.Help: func() *spool.ExitCode {
_ = manview.View(help)
return spool.Success
},
}
// statelessModes are program modes which do not require state to operate.
// like --version and --completion
var statelessModes = ModeMap[func() *spool.ExitCode]{
flags.InstallCompletion: func() *spool.ExitCode {
sh := os.Getenv("SHELL")
if sh == "" {
return spool.UnknownShellFailure
}
_, sh = filepath.Split(sh)
return completion.Add(sh)
},
flags.Profile: func() *spool.ExitCode {
fd, err := os.Create("pik-profile.out")
if err != nil {
return spool.ProfilingFailure
}
runtime.SetCPUProfileRate(1000)
err = pprof.StartCPUProfile(profileFd)
if err != nil {
return spool.ProfilingFailure
}
profileFd = fd
return nil
},
}
// statefulModes are program modes which require a built state to be executed
var statefulModes = ModeMap[func(st *model.State) *spool.ExitCode]{
flags.List: func(st *model.State) *spool.ExitCode {
count := 0
for _, s := range st.Sources {
count++
_, _ = spool.Print("%v", s.Label()+paths.Ifs)
for _, t := range s.Targets {
_, _ = spool.Print("%v", t.ShortestId()+paths.Ifs)
count++
}
}
if count == 0 {
return spool.NoTargetsFailure
}
return spool.Success
},
}
// selectionModes are program modes which require a selected target, through menu or args
var selectionModes = ModeMap[func(st *model.State, src *model.Source, t model.Target) *spool.ExitCode]{
flags.Edit: func(st *model.State, src *model.Source, t model.Target) *spool.ExitCode {
return run.Edit(t, src)
},
}
package motd
import "math/rand/v2"
var Messages = []string{
"use the -a flag to invoke from anywhere",
"combine -a with -y to invoke from anywhere without confirming",
"`--env dev` will include .env, .env.dev, .env-dev, and dev.env",
"pik will start in a viewport if the terminal is too short",
"instead of .pik you can use .tasks, or .bin, or any of these with _",
"include .pre. in a pik target filename to make it run as a prerequisite for other targets",
"a pik target filename with .post. will make it run after another targets completes successfully",
"you can always run a target after others by adding .final. to its name",
"you can 'invoke' a directory by having a target with the same name in it",
"if you're cool, use hjkl to browse me",
"left and right (h and l) can be used to scroll between sources",
"add .here. to a target filename to make it run in your shell working directory",
"the target's first line will be used as description, if it is a comment",
"create the .pik/.alias file to add additional names to the project for pik",
"put some utf-8 in .pik/.icon to add an icon to your pik-enabled project",
"copy and include .override. to prefer it over the other during invocations",
"pik crawls both the regular and symlink evaluated locations, if they are different",
"unsure about what you're doing? use --dry to check what you're running",
"use --edit to open a target in your $EDITOR",
"pik indexes executable files too, if you want to run something arbitrary",
}
func One() string {
idx := rand.Int() % len(Messages)
return Messages[idx]
}
package order
import (
"bufio"
"git.sr.ht/~ewy/pik/describe"
"git.sr.ht/~ewy/pik/identity"
"io"
"io/fs"
"os"
"strings"
)
type Element struct {
Identifier identity.Identity
Description string
}
type Order struct {
Elements []Element
}
var Empty = Order{}
func FromFile(f fs.FS, path string) (Order, error) {
fd, err := os.Open(path)
if err != nil {
return Empty, err
}
defer fd.Close()
return FromReader(fd)
}
func FromReader(r io.Reader) (Order, error) {
o := &Order{}
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
line := scanner.Text()
line = strings.TrimSpace(line)
if line == "" {
continue
}
for _, p := range describe.CommentPrefixes {
if strings.HasPrefix(line, p) {
continue
}
}
spl := strings.SplitN(line, "#", 2)
e := &Element{
Identifier: identity.New(spl[0]),
}
if len(spl) > 1 {
e.Description = spl[1]
}
o.Elements = append(o.Elements, *e)
}
return *o, nil
}
package paths
type Path struct {
Val *string
}
func (p *Path) Set(val string) {
p.Val = &val
}
func (p *Path) String() string {
if p == nil || p.Val == nil {
return "<empty>"
}
return *p.Val
}
func Empty() *Path {
return &Path{}
}
package paths
import (
"github.com/adrg/xdg"
"os"
"path"
"path/filepath"
"strings"
)
var (
Ifs string
ConfigDir = Empty()
CacheDir = Empty()
ContextsFile = Empty()
HomeDir = Empty()
System = Empty()
This = "pik"
ContextsFileName = "contexts"
)
var Paths = []*Path{
ConfigDir,
CacheDir,
ContextsFile,
HomeDir,
System,
}
var DirectoriesToMake = []*Path{
ConfigDir,
CacheDir,
}
type paths struct {
Initialized bool
}
var Component = &paths{
Initialized: false,
}
func (p *paths) Init() error {
// if we're asked to initialize for a second time,
// probably some environment has changed
if p.Initialized {
xdg.Reload()
}
HomeDir.Set(xdg.Home)
CacheDir.Set(filepath.Join(xdg.CacheHome, This))
ConfigDir.Set(filepath.Join(xdg.ConfigHome, This))
ContextsFile.Set(path.Join(CacheDir.String(), ContextsFileName))
System.Set(path.Join(ConfigDir.String()))
Ifs = os.Getenv("IFS")
for _, d := range DirectoriesToMake {
err := os.MkdirAll(d.String(), 0700)
if err != nil {
return err
}
}
if Ifs == "" {
Ifs = "\n"
}
p.Initialized = true
return nil
}
func ReplaceHome(input string) string {
return strings.Replace(input, HomeDir.String(), "~", 1)
}
//go:build test
package paths
var backups = make(map[*Path]string)
func SetAll(folder string) {
for _, p := range Paths {
Set(p, folder)
}
}
// Set temporarily sets the paths for unit test purposes
// remember to defer Reset
func Set(target *Path, value string) {
backups[target] = target.String()
target.Set(value)
}
// Reset sets the path variables back to before the unit test
func Reset() {
for path, oldValue := range backups {
path.Set(oldValue)
}
backups = make(map[*Path]string)
}
package run
import (
"errors"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/spool"
"os"
"os/exec"
)
var NoEditorError = errors.New("$EDITOR not set")
func Edit(t model.Target, src *model.Source) *spool.ExitCode {
editor := os.Getenv("EDITOR")
if editor == "" {
return spool.NoEditorFailure
}
cmd := exec.Command(editor, t.File(src))
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = src.Path
err := cmd.Run()
code := 0
msg := ""
var ex *exec.ExitError
if errors.As(err, &ex) {
code = ex.ExitCode()
msg = ex.String()
}
return &spool.ExitCode{
Value: code,
Message: msg,
}
}
package run
import (
"fmt"
"git.sr.ht/~ewy/pik/env"
"git.sr.ht/~ewy/pik/flags"
"git.sr.ht/~ewy/pik/menu"
"git.sr.ht/~ewy/pik/model"
"os"
"slices"
)
// Run creates an exec.Cmd from a model.Source, model.Target and args.
// 1. run pre-triggers, quit on fail
// 2. run target
// 3. if success, run post
// 4. run final
func Run(source *model.Source, target model.Target, args ...string) error {
tags := target.Tags()
skipTriggers := tags.Has(model.Single) || *flags.Single
if !skipTriggers {
err := Pre(source, target)
if err != nil {
return err
}
}
err := Exec(source, target, args...)
fmt.Println()
if err != nil {
return err
}
if !skipTriggers {
err := Post(source, target)
if err != nil {
return err
}
err = Final(source, target)
if err != nil {
return err
}
}
return nil
}
func Pre(source *model.Source, target model.Target) error {
return ExecWithTrigger(source, target, model.Pre)
}
func Post(source *model.Source, target model.Target) error {
return ExecWithTrigger(source, target, model.Post)
}
func Final(source *model.Source, target model.Target) error {
return ExecWithTrigger(source, target, model.Final)
}
// ExecWithTrigger loops through a model.Source and runs targets if they match the expected model.Tag
// triggers only run if their subdirectory is "in tree"
func ExecWithTrigger(source *model.Source, target model.Target, tag *model.Tag) error {
for _, t := range source.Targets {
if t.Tags().Has(tag) {
triggerSub := t.Sub()
targetSub := target.Sub()
for _, targetSubPart := range triggerSub {
if !slices.Contains(targetSub, targetSubPart) {
continue
}
}
err := Exec(source, t)
fmt.Println()
if err != nil {
return err
}
}
}
return nil
}
func Exec(source *model.Source, target model.Target, args ...string) error {
_, _ = fmt.Fprint(os.Stderr, menu.Banner(source, target, args...))
loc := source.Path
tags := target.Tags()
if *flags.At != "" {
loc = *flags.At
} else if tags.Has(model.Here) || *flags.Here {
wd, err := os.Getwd()
if err != nil {
return err
}
loc = wd
}
cmd := target.Create(source)
cmd.Dir = loc
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
cmd.Args = append(cmd.Args, args...)
e := env.Get(source)
if len(e) > 0 {
cmd.Env = append(os.Environ(), e...)
}
if *flags.Dry {
_, _ = fmt.Fprintln(os.Stderr, menu.InlineCmd(cmd))
return nil
}
if *flags.Root {
cmd.Args = append([]string{"sudo"}, cmd.Args...)
}
_, _ = fmt.Fprintln(os.Stderr)
return cmd.Run()
}
package runner
import (
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/paths"
"path/filepath"
"slices"
"strings"
)
// BaseTarget is an embeddable type which contains some of the information we need for (almost) every target.
type BaseTarget struct {
identity.Identity
MyTags model.Tags
MySub []string
}
func SubFromFile(file string) []string {
_, filename := filepath.Split(file)
var sub []string
split := strings.Split(file, "/")
for _, p := range split {
if slices.Contains(paths.Roots, p) {
continue
}
if filename == p {
continue
}
sub = append(sub, p)
}
return sub
}
func (t *BaseTarget) Tags() model.Tags {
return t.MyTags
}
func (t *BaseTarget) Matches(input string) bool {
return t.Identity.Is(input)
}
func (t *BaseTarget) ShortestId() string {
return t.Reduced
}
func (b *BaseTarget) Visible() bool {
return b.Tags().Visible()
}
func (b *BaseTarget) Invocation(src *model.Source) []string {
return append([]string{src.Identity.Reduced}, append(b.MySub, b.Identity.Reduced)...)
}
func Hydrated[T model.Target](in T) BaseHydration[T] {
return BaseHydration[T]{
Self: in,
}
}
type BaseHydration[T model.Target] struct {
Self T
}
func (b *BaseHydration[T]) Icon() string {
return ""
}
func (b *BaseHydration[T]) Description() string {
return ""
}
func (b *BaseHydration[T]) Target() model.Target {
return b.Self
}
func (b *BaseTarget) Sub() []string {
return b.MySub
}
//go:build test
package runner
import (
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"github.com/stretchr/testify/assert"
"os/exec"
"testing"
)
func TTarget(name string, sub ...string) model.Target {
t := TestTarget{Id: identity.New(name), MyTags: model.TagsFromFilename(name), SubValue: sub}
return &t
}
func TSource(name string, targets ...string) *model.Source {
src := &model.Source{
Path: name,
Identity: identity.New(name),
}
for _, t := range targets {
src.Targets = append(src.Targets, TTarget(t))
}
return src
}
func TState(sources ...*model.Source) *model.State {
return &model.State{
Sources: sources,
}
}
type TestTarget struct {
Stub
Id identity.Identity
SubValue []string
MyTags model.Tags
}
func (t TestTarget) Invocation(src *model.Source) []string {
return []string{src.Identity.Reduced, t.Id.Reduced}
}
func (t TestTarget) Matches(input string) bool {
return t.Id.Is(input)
}
func (t TestTarget) Visible() bool {
return true
}
func (t TestTarget) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return HydratedStub{}, nil
}
func (t TestTarget) Sub() []string {
return t.SubValue
}
func (t TestTarget) Label() string {
return t.Id.Full
}
func (t TestTarget) Create(s *model.Source) *exec.Cmd {
panic("whadafak")
}
func AssertTargetIs(t *testing.T, input string, target model.Target) {
assert.Equal(t, input, target.Label())
}
func AssertTargetIsNot(t *testing.T, input string, target model.Target) {
assert.NotEqual(t, input, target.Label())
}
func AssertSourceIs(t *testing.T, input string, src *model.Source) {
assert.NotNil(t, src.Identity)
assert.Equal(t, input, src.Identity.Reduced)
}
func AssertSourceIsNot(t *testing.T, input string, src *model.Source) {
assert.NotNil(t, src.Identity)
assert.NotEqual(t, input, src.Identity.Reduced)
}
package exc
import (
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"git.sr.ht/~ewy/pik/spool"
"io/fs"
"path/filepath"
)
func (e *exc) Wants(fs fs.FS, file string, entry fs.DirEntry) (bool, error) {
if entry.IsDir() {
return false, nil
}
info, err := entry.Info()
if err != nil {
spool.Warn("%v\n", err)
}
return info.Mode()&0100 != 0, nil
}
func (e *exc) CreateTarget(fs fs.FS, source string, file string, entry fs.DirEntry) (model.Target, error) {
_, filename := filepath.Split(file)
return &Executable{
BaseTarget: runner.BaseTarget{
Identity: identity.New(entry.Name()),
MyTags: model.TagsFromFilename(filename),
MySub: runner.SubFromFile(file),
},
Path: filepath.Join(source, file),
}, nil
}
package exc
import (
"git.sr.ht/~ewy/pik/describe"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"git.sr.ht/~ewy/pik/spool"
"os/exec"
)
type Executable struct {
runner.BaseTarget
Path string
}
type Hydrated struct {
*runner.BaseHydration[*Executable]
}
func (h *Hydrated) Icon() string {
return "\uEAE8"
}
func (h *Hydrated) Description(src *model.HydratedSource) string {
d, err := describe.Describe(h.Self, h.Self.Path)
if err != nil {
spool.Warn("%v\n", err)
}
return d
}
func (e *Executable) Create(s *model.Source) *exec.Cmd {
return exec.Command(e.Path)
}
func (e *Executable) Label() string {
return e.Identity.Full
}
func (e *Executable) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return &Hydrated{
BaseHydration: &runner.BaseHydration[*Executable]{
Self: e,
},
}, nil
}
func (e *Executable) File(src *model.Source) string {
return e.Path
}
package gnumake
import (
"errors"
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"io/fs"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
)
type gnumake struct {
path string
files map[string]string
}
var Indexer = &gnumake{
files: make(map[string]string),
}
var Makefiles = []string{
"Makefile",
"makefile",
}
func (m *gnumake) Index(path string, f fs.FS, _ []model.Runner) ([]model.Target, error) {
entries, err := fs.ReadDir(f, ".")
if err != nil {
return nil, err
}
makefile := ""
for _, e := range entries {
if !e.IsDir() && slices.Contains(Makefiles, strings.ToLower(e.Name())) {
m.files[path] = filepath.Join(path, e.Name())
content, err := fs.ReadFile(f, e.Name())
if err != nil {
return nil, err
}
makefile = string(content)
break
}
}
if makefile == "" {
return nil, nil
}
err = m.findMake()
if err != nil {
return nil, err
}
return ParseOutput(makefile), nil
}
var makeRegex = regexp.MustCompile("(?m)^([a-zA-Z]*):((.*?)# (.*))?")
func ParseOutput(input string) []model.Target {
var targets []string
match := makeRegex.FindAllString(input, strings.Count(input, "\n"))
for _, m := range match {
targets = append(targets, m)
}
var result []model.Target
for _, t := range targets {
split := strings.SplitN(t, "#", 2)
name := split[0]
name = strings.TrimSpace(name)
name = strings.TrimSuffix(name, ":")
tgt := &Target{
BaseTarget: runner.BaseTarget{
Identity: identity.New(name),
MyTags: nil,
},
Name: name,
}
if len(split) > 1 {
tgt.Description = strings.TrimSpace(split[1])
}
result = append(result, tgt)
}
return result
}
var NoMakeError = errors.New("source contains makefile but no gnumake in $PATH")
func (m *gnumake) findMake() error {
loc, err := exec.LookPath("make")
if errors.Is(err, exec.ErrNotFound) {
return NoMakeError
} else if err != nil {
return err
}
m.path = loc
return nil
}
package gnumake
import (
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"os/exec"
)
type Target struct {
runner.BaseTarget
Name string
Description string
}
func (j *Target) File(src *model.Source) string {
return Indexer.files[src.Path]
}
func (j *Target) Create(s *model.Source) *exec.Cmd {
return exec.Command(Indexer.path, j.Identity.Full)
}
var makeSub = []string{
"make",
}
func (j *Target) Sub() []string {
return makeSub
}
func (j *Target) Label() string {
return j.Identity.Full
}
func (j *Target) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return &Hydrated{
BaseHydration: runner.Hydrated(j),
}, nil
}
type Hydrated struct {
runner.BaseHydration[*Target]
}
func (h *Hydrated) Description(src *model.HydratedSource) string {
return h.Self.Description
}
func (h *Hydrated) Icon() string {
return "\uE673"
}
package js
import (
"encoding/json"
"errors"
"git.sr.ht/~ewy/pik/model"
"io/fs"
"os"
"path/filepath"
)
type Package struct {
Scripts map[string]string `json:"scripts"`
}
func (n *js) Index(path string, f fs.FS, runners []model.Runner) ([]model.Target, error) {
p := &Package{}
// are there any other package.jsons? i hope not, because i don't know them
content, err := os.ReadFile(filepath.Join(path, "package.json"))
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
} else if err != nil {
return nil, err
}
err = json.Unmarshal(content, p)
if err != nil {
return nil, err
}
var targets []model.Target
if n.Npm == "" {
return nil, NoNpm
}
for k, s := range p.Scripts {
targets = append(targets, n.CreateRun(k, s))
}
return targets, nil
}
package js
import (
"errors"
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"os/exec"
"path/filepath"
"slices"
)
var jsExtensions = []string{
".js",
".cjs",
}
var tsExtensions = []string{
".ts",
}
var extensions = append(jsExtensions, tsExtensions...)
var managers = []string{
"pnpm",
"yarn",
"npm",
}
var jsInterpreters = []string{
"node",
"bun",
}
var tsInterpeters = []string{
"ts",
"ts-node",
"bun",
}
type js struct {
JsInterpreter string
TsInterpreter string
Npm string
}
var Js = &js{}
var UnsupportedFile = errors.New("unsupported file")
var NoJsInterpreter = errors.New("no js interpreter found in $PATH")
var NoTsInterpreter = errors.New("no ts interpreter found in $PATH")
var NoNpm = errors.New("npm not found in $PATH")
func (n *js) Interpreter(file string) (string, error) {
ext := filepath.Ext(file)
if slices.Contains(jsInterpreters, ext) {
if n.JsInterpreter == "" {
return "", NoJsInterpreter
}
return n.JsInterpreter, nil
}
if slices.Contains(tsInterpeters, ext) {
if n.TsInterpreter == "" {
return "", NoTsInterpreter
}
return n.TsInterpreter, nil
}
return "", UnsupportedFile
}
func (n *js) Init() error {
for _, p := range jsInterpreters {
if r, err := exec.LookPath(p); err != nil {
n.JsInterpreter = r
}
}
for _, p := range tsInterpeters {
if r, err := exec.LookPath(p); err != nil {
n.TsInterpreter = r
}
}
for _, m := range managers {
if r, err := exec.LookPath(m); err == nil {
n.Npm = r
}
}
return nil
}
var npmSub = []string{
"npm",
}
func (n *js) CreateRun(name, cmd string) model.Target {
return &Npm{
BaseTarget: runner.BaseTarget{
Identity: identity.New(cmd),
MyTags: model.TagsFromFilename(cmd),
MySub: npmSub,
},
Name: name,
Cmd: cmd,
}
}
package js
import (
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"io/fs"
"path/filepath"
"slices"
)
func (n *js) Wants(fs fs.FS, file string, entry fs.DirEntry) (bool, error) {
ext := filepath.Ext(entry.Name())
return slices.Contains(extensions, ext), nil
}
func (n *js) CreateTarget(fs fs.FS, source string, file string, entry fs.DirEntry) (model.Target, error) {
ext := filepath.Ext(entry.Name())
typed := slices.Contains(tsExtensions, ext)
return &Script{
BaseTarget: runner.BaseTarget{
Identity: identity.New(entry.Name()),
MyTags: model.TagsFromFilename(entry.Name()),
MySub: runner.SubFromFile(file),
},
Typed: typed,
}, nil
}
package js
import (
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"os/exec"
"path/filepath"
)
type Npm struct {
runner.BaseTarget
Name string
Cmd string
}
func (n *Npm) Icon() string {
return "\uE60B"
}
func (n *Npm) Description(src *model.HydratedSource) string {
return n.Cmd
}
func (n *Npm) Target() model.Target {
return n
}
func (n *Npm) Create(s *model.Source) *exec.Cmd {
return exec.Command(Js.Npm, "run", n.Name)
}
func (n *Npm) Label() string {
return n.Name
}
func (n *Npm) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return n, nil
}
func (n *Npm) File(src *model.Source) string {
return filepath.Join(src.Path, "package.json")
}
package js
import (
"git.sr.ht/~ewy/pik/describe"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"git.sr.ht/~ewy/pik/spool"
"os/exec"
"path/filepath"
)
type Script struct {
runner.BaseTarget
Typed bool
}
func (t *Script) Icon() string {
if t.Typed {
return "\uE628"
} else {
return "\uE60C"
}
}
func (t *Script) Description(src *model.HydratedSource) string {
d, err := describe.Describe(t, t.File(src.Source))
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
return d
}
func (t *Script) Target() model.Target {
return t
}
func (t *Script) Create(s *model.Source) *exec.Cmd {
if t.Typed {
return exec.Command(Js.TsInterpreter, t.File(s))
} else {
return exec.Command(Js.JsInterpreter, t.File(s))
}
}
func (t *Script) Label() string {
return t.Identity.Full
}
func (t *Script) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return t, nil
}
func (t *Script) File(src *model.Source) string {
return filepath.Join(src.Path, "package.json")
}
package just
import (
"errors"
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"io/fs"
"os/exec"
"path/filepath"
"strings"
)
type just struct {
path string
files map[string]string
}
var Indexer = &just{
files: make(map[string]string),
}
func (j *just) Index(path string, f fs.FS, runners []model.Runner) ([]model.Target, error) {
entries, err := fs.ReadDir(f, ".")
if err != nil {
return nil, err
}
hasJustfile := false
for _, e := range entries {
if !e.IsDir() && strings.ToLower(e.Name()) == "justfile" {
j.files[path] = filepath.Join(path, e.Name())
hasJustfile = true
break
}
}
if !hasJustfile {
return nil, nil
}
err = j.findJust()
if err != nil {
return nil, err
}
cmd := exec.Command(j.path, "--list")
cmd.Dir = path
out, err := cmd.CombinedOutput()
if err != nil {
return nil, err
}
return ParseOutput(string(out)), nil
}
func ParseOutput(input string) []model.Target {
categories := make(map[string][]string)
currentCategory := ""
for _, line := range strings.Split(input, "\n") {
// strip comment
line = strings.SplitN(line, "#", 2)[0]
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
currentCategory = line[1 : len(line)-1]
continue
}
tgt := strings.SplitN(line, " ", 2)[0]
if tgt == "" {
continue
}
categories[currentCategory] = append(categories[currentCategory], tgt)
}
var result []model.Target
for c, targets := range categories {
for _, t := range targets {
result = append(result, &Target{
BaseTarget: runner.BaseTarget{
Identity: identity.New(t),
},
Category: c,
})
}
}
return result
}
var NoJustError = errors.New("no just in $PATH but source contains justfile")
func (j *just) findJust() error {
loc, err := exec.LookPath("just")
if errors.Is(err, exec.ErrNotFound) {
return NoJustError
} else if err != nil {
return err
}
j.path = loc
return nil
}
package just
import (
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"os/exec"
)
type Target struct {
runner.BaseTarget
Category string
}
func (j Target) File(src *model.Source) string {
return Indexer.files[src.Path]
}
func (j Target) Create(s *model.Source) *exec.Cmd {
return exec.Command(Indexer.path, j.Identity.Full)
}
func (j Target) Sub() []string {
if j.Category != "" {
return []string{j.Category}
}
return nil
}
func (j Target) Label() string {
return j.Identity.Full
}
func (j *Target) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return &Hydrated{
BaseHydration: runner.Hydrated(j),
}, nil
}
type Hydrated struct {
runner.BaseHydration[*Target]
}
func (h *Hydrated) Description(src *model.HydratedSource) string {
return ""
}
func (h *Hydrated) Icon() string {
return "\uF039"
}
package python
import (
"git.sr.ht/~ewy/pik/describe"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"git.sr.ht/~ewy/pik/spool"
"os/exec"
"path/filepath"
)
type File struct {
runner.BaseTarget
Path string
}
func (p *File) File(src *model.Source) string {
return p.Path
}
type HydratedFileTarget struct {
runner.BaseHydration[*File]
}
func (h *HydratedFileTarget) Description(src *model.HydratedSource) string {
desc, err := describe.Describe(h.Target(), h.Self.Path)
if err != nil {
spool.Warn("%v\n", err)
}
return desc
}
func (h *HydratedFileTarget) Icon() string {
return "\uE606"
}
func (p *File) Create(s *model.Source) *exec.Cmd {
var cmd []string
if Python.Uv != "" {
cmd = []string{Python.Uv, "run", "--", p.Path}
} else if venv := Python.VenvFor(s); venv != "" {
cmd = []string{filepath.Join(s.Path, venv, "bin", "python3"), p.Path}
} else {
sysPath, err := exec.LookPath("python3")
if err != nil {
return nil
}
cmd = []string{sysPath, p.Path}
}
return exec.Command(cmd[0], cmd[1:]...)
}
func (p *File) Sub() []string {
return nil
}
func (p *File) Label() string {
return p.Full
}
func (p *File) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return &HydratedFileTarget{
BaseHydration: runner.Hydrated(p),
}, nil
}
package python
import (
"git.sr.ht/~ewy/pik/model"
"github.com/pelletier/go-toml/v2"
"io/fs"
"os"
"path/filepath"
)
type pyproj struct {
Project struct {
Scripts map[string]string
}
}
func (p *python) Index(path string, f fs.FS, runners []model.Runner) ([]model.Target, error) {
for _, pt := range VenvPaths {
if stat, err := fs.Stat(f, filepath.Join(pt)); err == nil {
if stat.IsDir() {
p.Venvs[path] = filepath.Join(path, pt)
}
}
}
content, err := fs.ReadFile(f, "pyproject.toml")
if os.IsNotExist(err) {
return nil, nil
} else if err != nil {
return nil, err
}
p.files[path] = filepath.Join(path, "pyproject.toml")
pp := &pyproj{}
err = toml.Unmarshal(content, pp)
if err != nil {
return nil, err
}
var targets = make([]model.Target, 0, len(pp.Project.Scripts))
for n, s := range pp.Project.Scripts {
targets = append(targets, Python.CreateProjTarget(n, s))
}
return targets, nil
}
package python
import (
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"os/exec"
"path/filepath"
)
type Project struct {
runner.BaseTarget
Cmd string
}
func (p *Project) File(src *model.Source) string {
return Python.files[src.Path]
}
type Hydrated struct {
runner.BaseHydration[*Project]
}
func (h *Hydrated) Description(src *model.HydratedSource) string {
return h.Self.Cmd
}
func (h *Hydrated) Icon() string {
return "\uE606"
}
func (p *Project) Create(s *model.Source) *exec.Cmd {
var cmd []string
if Python.Uv != "" {
cmd = []string{Python.Uv, "run", "--", p.Cmd}
} else if venv := Python.VenvFor(s); venv != "" {
cmd = []string{filepath.Join(s.Path, venv, "bin", "python"), p.Cmd}
}
return exec.Command(cmd[0], cmd[1:]...)
}
func (p *Project) Sub() []string {
return nil
}
func (p *Project) Label() string {
return p.Cmd
}
func (p *Project) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return &Hydrated{
BaseHydration: runner.Hydrated(p),
}, nil
}
package python
import (
"errors"
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"io/fs"
"os/exec"
"path/filepath"
)
type python struct {
Venvs map[string]string
Uv string
System string
files map[string]string
}
func (p python) Init() error {
uv, err := exec.LookPath("uv")
if err != nil && !errors.Is(err, exec.ErrNotFound) {
return err
}
p.Uv = uv
sys, err := exec.LookPath("python3")
if err == nil {
p.System = sys
}
return err
}
func (p python) Wants(fs fs.FS, file string, entry fs.DirEntry) (bool, error) {
return !entry.IsDir() && filepath.Ext(entry.Name()) == ".py", nil
}
func (p python) VenvFor(src *model.Source) string {
venvPath := p.Venvs[src.Path]
if venvPath != "" {
return venvPath
}
return ""
}
func (p python) PyFor(src *model.Source) []string {
if p.Uv != "" {
return []string{p.Uv, "run", "--"}
}
if venv := p.VenvFor(src); venv != "" {
return []string{filepath.Join(src.Path, venv, "bin", "python")}
}
return nil
}
func (p python) CreateProjTarget(name string, cmd string) model.Target {
return &Project{
BaseTarget: runner.BaseTarget{
Identity: identity.New(name),
},
Cmd: cmd,
}
}
func (p python) CreateTarget(fs fs.FS, source string, file string, entry fs.DirEntry) (model.Target, error) {
_, filename := filepath.Split(file)
return &File{
BaseTarget: runner.BaseTarget{
Identity: identity.New(filename),
MyTags: model.TagsFromFilename(filename),
},
Path: file,
}, nil
}
var VenvPaths = []string{
".venv",
"venv",
}
var Python = &python{
Venvs: map[string]string{},
}
package shell
import (
"git.sr.ht/~ewy/pik/describe"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"git.sr.ht/~ewy/pik/spool"
)
type Hydrated struct {
runner.BaseHydration[*Target]
}
func (h *Hydrated) Icon() string {
return "\uF489"
}
func (h *Hydrated) Description(src *model.HydratedSource) string {
desc, err := describe.Describe(h.Target(), h.Target().File(src.Source))
if err != nil {
_, _ = spool.Warn("%v\n", err)
return ""
}
return desc
}
package shell
import (
"bufio"
"errors"
"git.sr.ht/~ewy/pik/identity"
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/paths"
"git.sr.ht/~ewy/pik/runner"
"git.sr.ht/~ewy/pik/spool"
"io/fs"
"os/exec"
"path/filepath"
"slices"
"strings"
"sync"
)
//TODO: Clean up shell selection? Maybe default to bash?
var NoContentError = errors.New("not enough content in target")
var NoShellError = errors.New("could not find any shell interpreters")
var ExtShellMap = map[string]string{
".sh": "bash",
".ps1": "powershell",
}
var Shells = []string{"bash", "bash.exe", "zsh", "fish", "powershell", "powershell.exe", "cmd.exe"}
var Runner = &shell{
Locations: map[string]string{},
}
type shell struct {
sync.Mutex
Locations map[string]string
}
func (s *shell) Wants(f fs.FS, file string, entry fs.DirEntry) (bool, error) {
if entry != nil && entry.IsDir() {
return false, nil
}
fd, err := f.Open(file)
if err != nil {
return false, err
}
scanner := bufio.NewScanner(fd)
scanner.Split(bufio.ScanRunes)
if !scanner.Scan() {
return false, nil
}
txt := scanner.Text()
if txt == "#" { //
return true, nil
}
for k, _ := range ExtShellMap {
if strings.HasSuffix(file, k) {
return true, nil
}
}
return false, nil
}
func (s *shell) Find(shell string) (string, error) {
s.Lock()
defer s.Unlock()
if s.Locations[shell] != "" {
return s.Locations[shell], nil
}
if p, err := exec.LookPath(shell); err == nil {
s.Locations[shell] = p
return shell, nil
} else {
return "", err
}
}
func (s *shell) CreateTarget(fs fs.FS, src string, file string, _ fs.DirEntry) (model.Target, error) {
shell, err := s.ShellFor(fs, file)
if err != nil {
return nil, err
}
_, filename := filepath.Split(file)
var sub []string
split := strings.Split(file, "/")
for _, p := range split {
if slices.Contains(paths.Roots, p) {
continue
}
if filename == p {
continue
}
sub = append(sub, p)
}
return &Target{
BaseTarget: runner.BaseTarget{
Identity: identity.New(filename),
MyTags: model.TagsFromFilename(filename),
MySub: sub,
},
Shell: shell,
Script: filepath.Join(src, file),
}, nil
}
func (s *shell) ShellFor(fs fs.FS, file string) (string, error) {
var shell, shebang string
// low-hanging fruit - indicative filename
if byFile := s.ShellByFilename(file); byFile != "" {
return byFile, nil
}
fd, err := fs.Open(file)
if err != nil {
return "", err
}
scanner := bufio.NewScanner(fd)
scanner.Split(bufio.ScanLines)
if !scanner.Scan() {
return "", NoContentError
}
txt := scanner.Text()
if strings.HasPrefix(txt, "#!") {
// shebang found
for _, potentialShell := range Shells {
if strings.Contains(txt, potentialShell) {
shebang = shell
if loc, err := s.Find(potentialShell); err == nil {
shell = loc
} else {
_, _ = spool.Warn("script has %s but could not find %s\n", shebang, potentialShell)
}
}
}
}
if shebang == "" {
// if no shebang, just send the first one we find
for _, s := range Shells {
if p, err := exec.LookPath(s); err != nil {
shell = p
}
}
}
if shell == "" {
return "", NoShellError
}
return shell, nil
}
func (s *shell) ShellByFilename(file string) string {
ext := filepath.Ext(file)
if ExtShellMap[ext] != "" {
sh, err := s.Find(ExtShellMap[ext])
if err == nil {
return sh
}
}
return ""
}
package shell
import (
"git.sr.ht/~ewy/pik/model"
"git.sr.ht/~ewy/pik/runner"
"os/exec"
)
type Target struct {
runner.BaseTarget
Shell string
Script string
}
func (s *Target) File(src *model.Source) string {
return s.Script
}
func (s *Target) String() string {
return s.Label()
}
func (s *Target) Hydrate(_ *model.Source) (model.HydratedTarget, error) {
return &Hydrated{
BaseHydration: runner.Hydrated(s),
}, nil
}
func (s *Target) Sub() []string {
return s.BaseTarget.MySub
}
func (s *Target) Label() string {
return s.Identity.Full
}
func (s *Target) Create(_ *model.Source) *exec.Cmd {
return exec.Command(s.Shell, s.Script)
}
//go:build test
package runner
import (
"git.sr.ht/~ewy/pik/model"
"os/exec"
)
// Stub is the most minimal and useless implementation of the target interface. It only panics. Use if you need a target-compliant struct.
type Stub struct {
}
func (s Stub) File(src *model.Source) string {
//TODO implement me
panic("implement me")
}
func (s Stub) Matches(input string) bool {
//TODO implement me
panic("implement me")
}
func (s Stub) Create(src *model.Source) *exec.Cmd {
//TODO implement me
panic("implement me")
}
func (s Stub) Sub() []string {
//TODO implement me
panic("implement me")
}
func (s Stub) Label() string {
//TODO implement me
panic("implement me")
}
func (s Stub) Hydrate(src *model.Source) (model.HydratedTarget, error) {
//TODO implement me
panic("implement me")
}
func (s Stub) Tags() model.Tags {
return nil
}
func (s Stub) ShortestId() string {
//TODO implement me
panic("implement me")
}
func (s Stub) Visible() bool {
//TODO implement me
panic("implement me")
}
func (s Stub) Invocation(src *model.Source) []string {
//TODO implement me
panic("implement me")
}
type HydratedStub struct {
}
func (h HydratedStub) Matches(input string) bool {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Create(s *model.Source) *exec.Cmd {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Sub() []string {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Label() string {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Hydrate(src *model.Source) (model.HydratedTarget, error) {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Tags() model.Tags {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) ShortestId() string {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Visible() bool {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Invocation(src *model.Source) []string {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Icon() string {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Description(src *model.HydratedSource) string {
//TODO implement me
panic("implement me")
}
func (h HydratedStub) Target() model.Target {
//TODO implement me
panic("implement me")
}
package search
import (
"git.sr.ht/~ewy/pik/model"
"slices"
)
// Result is a struct containing information about the search and search results
type Result struct {
// Target is the target selected by the search
Target model.Target
// Source is the source belonging to the selected Target
Source *model.Source
// NeedsConfirmation is true when there are discrepancies between expected and actual invocation
NeedsConfirmation bool
// Overridden is whether it was overridden by a .override target
Overridden bool
// Sub is the subcategory or -folder
Sub []string
// Args are the remaining arguments which we should pass to the target
Args []string
}
// Search is the meat of pik
// since there are a ton of different ways to invoke targets, leave a unit test
// when you change this
func Search(s *model.State, args ...string) *Result {
var target model.Target
var targetSource *model.Source
var confirm bool
var overridden bool
var subdir []string
var forward []string
var suspect model.Target
var suspectSource *model.Source
args_loop:
for i, arg := range args {
for _, src := range s.Sources {
if targetSource == nil {
if src.Is(arg) {
targetSource = src
// only try to find the default target if this is the last argument
if len(args)-1 != i {
continue args_loop
}
// try to look for arg target with the same name as the source
// "default target" of sorts
for _, t := range targetSource.Targets {
if t.Matches(arg) {
target = t
continue args_loop
}
}
continue args_loop
}
}
if target == nil && targetSource == nil {
// uncertain about source, check ours to see if any match
for _, t := range src.Targets {
if t.Matches(arg) {
if slices.Equal(t.Sub(), subdir) {
target = t
targetSource = src
} else {
suspect = t
suspectSource = src
}
continue args_loop
}
}
} else if target == nil { // && targetSource == nil (but it is always true)
// source located,
for _, t := range targetSource.Targets {
if t.Matches(arg) {
target = t
continue args_loop
}
}
// if we find the right target
for _, t := range src.Targets {
if t.Matches(arg) {
confirm = true
suspect = t
suspectSource = src
continue args_loop
}
}
}
}
if target == nil && suspect == nil {
subdir = append(subdir, arg)
continue args_loop
} else if targetSource != nil || suspect != nil {
forward = append(forward, arg)
continue args_loop
}
}
if suspect != nil && target == nil {
target = suspect
targetSource = suspectSource
if !(suspect.Sub() != nil && subdir == nil) {
confirm = true
}
}
if target != nil && target.Sub() != nil && subdir != nil && !slices.Equal(target.Sub(), subdir) {
confirm = true
}
if target == nil {
forward = args
}
if target != nil && targetSource != nil {
for _, t := range targetSource.Targets {
if slices.Equal(t.Invocation(targetSource), target.Invocation(targetSource)) {
if t.Tags().Has(model.Override) {
overridden = true
target = t
}
}
}
}
return &Result{
Target: target,
Source: targetSource,
NeedsConfirmation: confirm,
Overridden: overridden,
Sub: subdir,
Args: forward,
}
}
package spool
import (
"git.sr.ht/~ewy/pik/paths"
"os"
)
type ExitCode struct {
Value int
Message string
}
var (
Success = New(0, "succesful")
Cancelled = New(0, "operation cancelled by user")
NotFoundFailure = Cancelled.Next("target not found")
FatalReadFailure = NotFoundFailure.Next("fatal failure during initialization")
FatalWriteFailure = FatalReadFailure.Next("fatal failure during file write")
WorkingDirectoryFailure = FatalWriteFailure.Next("could not get current working directory")
OpenRootFailure = WorkingDirectoryFailure.Next("failed to init root directory")
RootFsFailure = OpenRootFailure.Next("failed to make fs from root")
CacheReadFailure = RootFsFailure.Next("failed to read cache (from " + paths.ContextsFile.String())
HydrationFailure = CacheReadFailure.Next("a hydrator failed")
MenuFailure = HydrationFailure.Next("error during menu")
NoTargetsFailure = MenuFailure.Next("no targets found")
NoEditorFailure = NoTargetsFailure.Next("$EDITOR not set")
NoDebugInfo = NoEditorFailure.Next("could not read debug info")
ManFailure = New(120, "failure to generate manual pages")
UnknownShellFailure = New(110, "unable to detect shell type through $SHELL")
CompletionAlreadyInstalledFailure = UnknownShellFailure.Next("completion seems already installed")
CompletionFailure = CompletionAlreadyInstalledFailure.Next("failed to install completion")
ProfilingFailure = CompletionFailure.Next("failed to initialize profiler")
)
var Codes []ExitCode
var CodeMap = make(map[int]*ExitCode)
func New(num int, message string) *ExitCode {
if CodeMap[num] != nil && num != 0 {
_, _ = Warn("redefined error code: %v", num)
}
c := ExitCode{
Value: num,
Message: message,
}
Codes = append(Codes, c)
CodeMap[num] = &c
return &c
}
func (e *ExitCode) Exit() {
os.Exit(e.Value)
}
func (e *ExitCode) Next(message string) *ExitCode {
return New(
e.Value+1,
message,
)
}
package spool
import (
"fmt"
"git.sr.ht/~ewy/pik/menu/style"
"github.com/charmbracelet/lipgloss"
"os"
"reflect"
"runtime/debug"
"strings"
)
var (
Stderr = os.Stderr
Stdout = os.Stdout
StyleErr = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
})
StyleWarn = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
})
)
type empty struct{}
const PanicErrorCode = 8
var Print = func(format string, values ...any) (any, error) {
return fmt.Fprintf(Stdout, format, values...)
}
var Warn = func(format string, values ...any) (any, error) {
return fmt.Fprint(Stderr, StyleWarn.Render(fmt.Sprintf(format, values...)))
}
var Panic = func(code *ExitCode, format string, values ...any) (any, error) {
pkg := reflect.TypeOf(empty{}).PkgPath()
v, err := fmt.Fprint(Stderr, StyleErr.Render(fmt.Sprintf(format, values...)))
st := strings.Split(string(debug.Stack()), "\n")
for i, l := range st {
if strings.Contains(l, pkg) {
st = st[i:]
break
}
}
_, _ = Warn("%s\n", strings.Join(st, "\n"))
os.Exit(PanicErrorCode)
return v, err
}
package viewport
import (
"git.sr.ht/~ewy/pik/menu/style"
"github.com/charmbracelet/lipgloss"
"strings"
)
var (
ScrollTop = StyleBarBackground.Render("╷")
ScrollSpace = StyleBarBackground.Render("│")
ScrollBarTopEnd = StyleBar.Render("╓")
ScrollBar = StyleBar.Render("║")
ScrollBarBottomEnd = StyleBar.Render("╙")
ScrollBottom = StyleBarBackground.Render("╵")
)
var (
StyleBar = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
StyleBarBackground = style.New(func() lipgloss.Style {
return StyleBar.Get().Faint(true)
})
)
func WithScroll(input string, barBegin int, barEnd int) string {
lines := strings.Split(input, "\n")
for i, line := range lines {
selection := ScrollSpace
switch {
case i == barBegin:
selection = StyleBar.Render(ScrollBarTopEnd)
case i == 0:
selection = StyleBarBackground.Render(ScrollTop)
case i == barEnd:
selection = StyleBar.Render(ScrollBarBottomEnd)
case i > barBegin && i < barEnd:
selection = StyleBar.Render(ScrollBar)
case i == len(lines)-1:
selection = StyleBar.Render(ScrollBottom)
}
lines[i] = selection + " " + line
}
return strings.Join(lines, "\n")
}
package viewport
import (
"strings"
)
const Caret = "⏵"
func NeedsViewport(input string, height int) bool {
lines := strings.Split(input, "\n")
return len(lines)-1 > height
}
func Process(input string, height int) string {
lines := strings.Split(input, "\n")
if len(lines) > height {
cropped, top, bottom := Crop(input, lines, height)
return WithScroll(cropped, int(top*float32(height)), int(bottom*float32(height)))
}
return TrimSpaceRight(input)
}
func TrimSpaceRight(input string) string {
lines := strings.Split(input, "\n")
for i, l := range lines {
lines[i] = strings.TrimRight(l, " ")
}
return strings.Join(lines, "\n")
}
func Focus(lines []string, needle string) int {
for i, l := range lines {
if strings.Contains(l, needle) {
return i
}
}
return -1
}
func Crop(input string, lines []string, height int) (output string, scrollStart float32, scrollEnd float32) {
output = input
selectionIndex := Focus(lines, Caret)
size := len(lines)
if size <= height {
return output, 0, 1
}
linesAbove := height / 2
linesBelow := height - linesAbove
if linesAbove*2 < selectionIndex {
linesBelow++
}
start := selectionIndex - linesAbove
end := selectionIndex + linesBelow
if start < 0 {
end += -start
start = 0
}
if end >= size {
diff := end - size
start -= diff
end -= diff
}
scrollStart = float32(start) / float32(size)
scrollEnd = float32(end)/float32(size) + float32(1)/float32(size)
if scrollEnd > 1 {
scrollEnd = 1
}
return strings.Join(lines[start:end], "\n"), scrollStart, scrollEnd
}