package cache
import (
"bufio"
"errors"
"io"
"io/fs"
"os"
"path"
"pik/model"
"pik/paths"
"strings"
)
type Cache struct {
Entries []Entry
}
// Merge combines two caches and filters duplicate keys
func (c Cache) Merge(other Cache) Cache {
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
}
type Entry struct {
Path string
Label string
}
var Empty = Cache{}
// Path is the file path to the "contexts" cache file
var Path = path.Join(paths.Cache, "contexts")
// FsPath is the Path with the leading slash removed, to be opened from fs.FS
var FsPath = Path[1:]
var UnexpectedEntryError = errors.New("unexpected cache entry")
// 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])
default:
return c, UnexpectedEntryError
}
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.Path)
b.WriteString(" # ")
b.WriteString(e.Label)
b.WriteString("\n")
}
return []byte(b.String())
}
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
}
func SaveFile(path string, s *model.State, loaded Cache) error {
fd, err := os.Create(path)
if err != nil {
return err
}
if fd != nil {
defer fd.Close()
}
return Save(s, fd, loaded)
}
func Save(s *model.State, w io.Writer, loaded Cache) error {
result := New(s).Merge(loaded)
_, err := w.Write([]byte(result.Marshal()))
return err
}
func LoadState(f fs.FS, 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, indexers, runners)
}
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,
}
}
package crawl
import (
"path"
"path/filepath"
"slices"
"strings"
)
func Evaluated(loc string) (string, error) {
return filepath.EvalSymlinks(loc)
}
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
}
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
}
func ParentDir(origin string) string {
trimmedOrigin := strings.TrimSuffix(origin, "/")
dir, _ := path.Split(trimmedOrigin)
if dir == "" {
return origin
}
return dir
}
package describe
import (
"bufio"
"io"
"os"
"pik/model"
"strings"
)
var DescriptionPrefixes = []string{
"#",
"//",
}
var descriptions = make(map[model.Target]*string)
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
}
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 DescriptionPrefixes {
if strings.HasPrefix(text, p) {
hasPrefix = true
break
}
}
if !hasPrefix {
return "", nil
}
for _, c := range DescriptionPrefixes {
text = strings.TrimPrefix(text, c)
text = strings.TrimSpace(text)
}
return text, nil
}
package env
import (
"github.com/joho/godotenv"
"io/fs"
"os"
"path/filepath"
"pik/flags"
"pik/indexers/pikdex"
"pik/model"
"pik/spool"
"slices"
)
func IsEnv(file string) bool {
options := []string{
".env",
}
for _, e := range *flags.Env {
options = append(options,
".env-"+e,
".env."+e,
e+".env")
}
return slices.Contains(options, file)
}
func EnvFiles(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(pikdex.Roots, e.Name()) && deep {
result = append(result, EnvFiles(f, e.Name(), false)...)
}
if !e.IsDir() && IsEnv(e.Name()) {
result = append(result, filepath.Join(p, e.Name()))
}
}
return result
}
func Get(src *model.Source) []string {
f := os.DirFS(src.Path)
var result []string
files := EnvFiles(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"
"os"
"os/exec"
"path/filepath"
"pik/model"
"pik/spool"
"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{}
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
}
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")
func (g *gitMod) Diff(source *model.Source) (int, int, int, 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), ",")
changes := 0
insertions := 0
deletions := 0
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
}
func (i Identity) Is(input string) bool {
reduced := Reduce(input)
return i.Reduced == reduced
}
func New(input string) Identity {
reduced := Reduce(input)
return Identity{
Full: input,
Reduced: reduced,
}
}
func Reduce(input string) string {
reduced := input
if !strings.HasPrefix(reduced, ".") {
reduced = strings.Split(reduced, ".")[0]
}
reduced = strings.ToLower(reduced)
return reduced
}
package pikdex
import (
"pik/model"
"strings"
)
func (u *pikdex) Mod(src *model.Source, result *model.HydratedSource) error {
mod := u.mods[strings.TrimSuffix(src.Path, "/")]
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"
"io/fs"
"os"
"path"
"path/filepath"
"pik/model"
"pik/spool"
"slices"
"strings"
"sync"
)
var Roots = []string{
// current name
".pik",
"_pik",
// program names from a previous life
".godo",
"_godo",
".pik",
"_uwu",
//utility
".bin",
"_bin",
"tasks",
".tasks",
"_tasks",
}
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")
Roots = append(Roots, "."+self, "_"+self)
return nil
}
var Indexer = &pikdex{mods: make(map[string]*SourceData)}
type pikdex struct {
sync.Mutex
mods map[string]*SourceData
}
type SourceData struct {
Aliases []string
Icon string
Path string
}
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.mods[absPath]
u.Unlock()
if mod == nil {
u.mods[absPath] = &SourceData{
Path: absPath,
}
mod = u.mods[absPath]
}
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(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)
}
targets = append(targets, t)
return nil
}
if err != nil {
spool.Warn("%v\n", err)
}
}
return nil
})
u.Lock()
u.mods[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 Roots {
if e.Name() == r && e.IsDir() {
return true, r, nil
}
}
}
return false, "", nil
}
package pikdex
import (
"strings"
)
type MetaSetter func(s *SourceData, content string)
var MetaFiles = map[string]MetaSetter{
".alias": func(s *SourceData, content string) {
split := strings.Split(content, "\n")
s.Aliases = make([]string, 0, len(split))
for _, line := range split {
stripped := strip(line)
if stripped != "" {
s.Aliases = append(s.Aliases, stripped)
}
}
},
".icon": func(s *SourceData, content string) {
s.Icon = string([]rune(strip(content))[0:2])
},
}
func strip(input string) string {
return strings.TrimSpace(input)
}
package main
import (
_ "embed"
"fmt"
"github.com/spf13/pflag"
"os"
"pik/cache"
"pik/crawl"
"pik/flags"
"pik/git"
"pik/indexers/pikdex"
"pik/menu"
"pik/model"
"pik/paths"
"pik/run"
"pik/runner/gnumake"
"pik/runner/just"
"pik/runner/python"
"pik/runner/shell"
"pik/search"
"pik/spool"
"sync"
)
// preInitializers are ran before the initializers.
// useful for initializing stuff like paths, preparing directories, and reading the environment
var preInitializers = []model.Initializer{
paths.Paths,
}
// initializers are ran before indexing with the indexers,
// data from the preInitializers can be accessed at this time.
var initializers = []model.Initializer{
pikdex.Indexer,
python.Python,
git.Git,
}
// indexers are methods which scan a directory and return a number of targets.
var indexers = []model.Indexer{
pikdex.Indexer,
just.Indexer,
gnumake.Indexer,
}
// 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 = []model.Runner{
shell.Runner,
python.Python,
}
// hydrators are ran when the menu is required
// for example adding git info, descriptions, icons...
var hydrators = []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
//go:embed version.txt
var version string
func main() {
pflag.Parse()
switch {
case *flags.Version:
_, _ = spool.Print("%s\n", version)
os.Exit(0)
}
wg := sync.WaitGroup{}
for _, i := range preInitializers {
wg.Go(func() {
err := i.Init()
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
})
}
wg.Wait()
wg = sync.WaitGroup{}
for _, i := range initializers {
wg.Go(func() {
err := i.Init()
if err != nil {
_, _ = spool.Warn("%v\n", err)
}
})
}
wg.Wait()
here, err := os.Getwd()
if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
locs := crawl.RichLocations(here)
last := locs[len(locs)-1]
root, err := os.OpenRoot(last)
if root == nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
fs := root.FS()
if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
var st *model.State
var stateErrors []error
var c cache.Cache
if !*flags.All {
st, stateErrors = model.NewState(fs, locs, indexers, runners)
} else {
c, err = cache.LoadFile(fs, cache.Path[1:])
c.Strip(SourcesWithoutResults)
if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
st, stateErrors = cache.LoadState(fs, c, indexers, runners)
}
if stateErrors != nil {
_, _ = spool.Warn("%v\n", stateErrors)
} else {
err = cache.SaveFile(cache.Path, st, c)
if err != nil {
_, _ = spool.Warn("%v", err)
}
}
if *flags.List {
for _, s := range st.Sources {
for _, t := range s.Targets {
_, _ = spool.Print(t.ShortestId() + paths.Ifs)
}
}
os.Exit(0)
}
args := pflag.Args()
if len(args) == 0 {
source, target, err := menu.Show(st, hydrators)
if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
if target == nil {
_, _ = spool.Warn("no target selected.\n")
os.Exit(0)
}
err = run.Run(source.Source, target, args...)
if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
return
}
result := search.Search(st, args...)
// TODO: Move auto-all logic into Search?
if !*flags.All && result.Target == nil && len(result.Args) > 0 {
ForceConfirm = true
if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
SourcesWithoutResults = c
main()
return
}
if result.Target == nil {
_, _ = spool.Print("target not found.")
os.Exit(1)
return
}
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...) {
os.Exit(0)
}
}
if result.Overridden {
_, _ = fmt.Fprintln(os.Stderr, menu.OverrideWarning(result.Target))
}
err = run.Run(result.Source, result.Target, result.Args...)
if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
}
package menu
import (
"github.com/charmbracelet/lipgloss"
"os/exec"
"pik/flags"
"pik/menu/style"
"pik/model"
"pik/paths"
"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"
"github.com/charmbracelet/lipgloss"
"io"
"os"
"pik/flags"
"pik/menu/style"
"pik/model"
"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 (
"github.com/charmbracelet/lipgloss"
"pik/menu/style"
"strings"
)
var (
IconStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().Width(2).Height(1)
return st
})
)
func Icon(input string) string {
if strings.TrimSpace(input) == "" {
return ""
}
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) {
var cmd tea.Cmd
switch msg.String() {
case "h", "left":
m.Leap(-1)
case "l", "right":
m.Leap(1)
case "up", "k":
m.Index--
case "down", "j":
m.Index++
case "q", "esc", "ctrl+c":
m.Quit = true
cmd = tea.Quit
case "space", " ", "enter", "ctrl+d":
cmd = tea.Quit
}
m.Validate()
return cmd, nil
}
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"
tea "github.com/charmbracelet/bubbletea"
"pik/flags"
"pik/model"
"pik/spool"
)
var WrongModelTypeError = errors.New("wrong model type")
var NoSourcesIndexedError = errors.New("no sources indexed")
func Show(st *model.State, hydrators []model.Modder) (*model.HydratedSource, model.HydratedTarget, error) {
if len(st.Sources) == 0 {
return nil, nil, NoSourcesIndexedError
}
md := NewModel(st, hydrators)
var opts []tea.ProgramOption
if !*flags.Inline {
opts = append(opts, tea.WithAltScreen())
}
program := tea.NewProgram(md, opts...)
resultModel, err := program.Run()
if err != nil {
return nil, nil, err
}
result, ok := resultModel.(*Model)
if !ok {
return nil, nil, WrongModelTypeError
}
src, t := result.Result()
return src, t, 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 (
tea "github.com/charmbracelet/bubbletea"
"pik/model"
"pik/spool"
)
type Model struct {
*model.HydratedState
Index int
Indices map[int]model.HydratedTarget
SourceIndices map[int]*model.HydratedSource
Quit bool
}
func (m *Model) Init() tea.Cmd {
return nil
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var err error
var result tea.Cmd
switch mt := msg.(type) {
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 {
return m.State(m.HydratedState)
}
func (m *Model) Result() (*model.HydratedSource, model.HydratedTarget) {
if m.Quit {
return nil, nil
}
return m.SourceIndices[m.Index], m.Indices[m.Index]
}
func (m *Model) Validate() {
if m.Index < 0 {
m.Index = 0
}
if m.Index > len(m.Indices)-1 {
m.Index = len(m.Indices) - 1
}
}
func NewModel(st *model.State, hydrators []model.Modder) *Model {
m := &Model{
HydratedState: Hydrate(st, hydrators),
Index: 0,
Indices: make(map[int]model.HydratedTarget),
SourceIndices: make(map[int]*model.HydratedSource),
}
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 (
"github.com/charmbracelet/lipgloss"
"pik/menu/style"
"pik/model"
"strconv"
)
var (
SourceStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle().PaddingBottom(1)
return st
})
SourceHeaderBackground = lipgloss.Color("5")
SourceHeaderStyle = style.New(func() lipgloss.Style {
st := lipgloss.NewStyle()
return st
})
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
})
)
func (m *Model) Source(src *model.HydratedSource) string {
targets := make([]string, 0, len(src.Targets))
for _, t := range src.HydratedTargets {
targets = append(targets, m.Target(t))
}
targetContent := lipgloss.JoinVertical(lipgloss.Top, targets...)
icon := 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(lipgloss.JoinVertical(lipgloss.Top,
parts...,
))
}
var (
StateStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle().MarginBottom(1)
})
)
func (m *Model) State(st *model.HydratedState) string {
sources := make([]string, 0, len(st.Sources))
for _, hs := range st.HydratedSources {
sources = append(sources, m.Source(hs))
}
return StateStyle.Render(lipgloss.JoinVertical(lipgloss.Top, sources...))
}
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 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 (
"github.com/charmbracelet/lipgloss"
"pik/menu/style"
"pik/model"
)
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
})
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
})
TargetSubStyle = style.New(func() lipgloss.Style {
return lipgloss.NewStyle()
})
)
func (m *Model) Target(t model.HydratedTarget) string {
icon := TargetIconStyle.Render(PaddedIcon(t.Icon()))
selectionStyle := TargetStyle
selectionDescriptionStyle := TargetDescriptionStyle
_, sel := m.Result()
if sel != nil && sel.Target() == t.Target() {
selectionStyle = SelectedTargetStyle
selectionDescriptionStyle = SelectedTargetDescriptionStyle
}
var labelParts []string
labelParts = append(labelParts, icon)
if t.Sub() != nil {
labelParts = append(labelParts, TargetSubStyle.Render(t.Sub()...))
}
labelParts = append(labelParts, TargetLabelStyle.Render(t.Label()))
return lipgloss.JoinHorizontal(lipgloss.Left, selectionStyle.Render(labelParts...), selectionDescriptionStyle.Render(t.Description()))
}
package model
import (
"errors"
"io/fs"
"path/filepath"
"pik/flags"
"pik/identity"
"strings"
"sync"
)
func NewState(f fs.FS, locations []string, 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() {
_, dirName := filepath.Split(strings.TrimSuffix(loc, "/"))
src := &Source{
Path: loc,
Identity: identity.New(dirName),
}
sources[i] = src
loc = strings.TrimSuffix(loc, "/")
loc = strings.TrimPrefix(loc, "/")
if loc == "" {
return
}
myWg := sync.WaitGroup{}
var targets = make([][]Target, len(indexers), len(indexers))
for ti, indexer := range indexers {
myWg.Go(func() {
s, err := fs.Sub(f, loc)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
return
}
result, err := indexer.Index("/"+loc, s, runners)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
return
}
targets[ti] = result
})
}
myWg.Wait()
for _, t := range targets {
if t == nil {
continue
}
sources[i].Targets = append(sources[i].Targets, t...)
}
})
}
wg.Wait()
for _, s := range sources {
if s == nil || s.Targets == nil {
continue
}
st.Sources = append(st.Sources, s)
}
return st, errs
}
package model
import (
"pik/identity"
"pik/paths"
"pik/spool"
)
type Source struct {
identity.Identity
Tags
Path string
Targets []Target
}
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"
)
type Tag *string
type TagAction func(src *Source)
func New(input string) Tag {
result := &input
TagMap[input] = result
TagList = append(TagList, result)
return result
}
var (
Here = New("here")
Pre = New("pre")
Post = New("post")
Final = New("final")
Hidden = New("hidden")
Single = New("single")
Override = New("override")
)
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 paths
import (
"github.com/adrg/xdg"
"os"
"path/filepath"
"strings"
)
var (
Home = xdg.Home
This = "pik"
Cache = filepath.Join(xdg.CacheHome, This)
Config = filepath.Join(xdg.ConfigHome, This)
Ifs = os.Getenv("IFS")
)
type paths struct {
}
var Paths = &paths{}
func (p paths) Init() error {
err := os.MkdirAll(Cache, 0700)
if err != nil {
return err
}
err = os.MkdirAll(Config, 0700)
if err != nil {
return err
}
if Ifs == "" {
Ifs = "\n"
}
return nil
}
func ReplaceHome(input string) string {
return strings.Replace(input, Home, "~", 1)
}
package run
import (
"fmt"
"os"
"pik/env"
"pik/flags"
"pik/menu"
"pik/model"
"slices"
)
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)
}
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 (
"os/exec"
"pik/identity"
"pik/model"
)
// 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
Sub []string
}
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.Sub, b.Identity.Reduced)...)
}
func Hydrated[T model.Target](in T) BaseHydration[T] {
return BaseHydration[T]{
BaseTarget: in,
}
}
type BaseHydration[T model.Target] struct {
BaseTarget T
}
func (b BaseHydration[T]) Matches(input string) bool {
return b.BaseTarget.Matches(input)
}
func (b BaseHydration[T]) Create(s *model.Source) *exec.Cmd {
return b.BaseTarget.Create(s)
}
func (b BaseHydration[T]) Sub() []string {
return b.BaseTarget.Sub()
}
func (b BaseHydration[T]) Label() string {
return b.BaseTarget.Label()
}
func (b BaseHydration[T]) Hydrate(src *model.Source) (model.HydratedTarget, error) {
return b, nil
}
func (b BaseHydration[T]) Invocation(src *model.Source) []string {
return b.BaseTarget.Invocation(src)
}
func (b BaseHydration[T]) Visible() bool {
return b.BaseTarget.Visible()
}
func (b BaseHydration[T]) Tags() model.Tags {
return b.BaseTarget.Tags()
}
func (b BaseHydration[T]) ShortestId() string {
return b.BaseTarget.ShortestId()
}
func (b BaseHydration[T]) Icon() string {
return " "
}
func (b BaseHydration[T]) Description() string {
return ""
}
func (b BaseHydration[T]) Target() model.Target {
return b.BaseTarget
}
package gnumake
import (
"errors"
"io/fs"
"os/exec"
"pik/identity"
"pik/model"
"pik/runner"
"regexp"
"slices"
"strings"
)
type make struct {
path string
}
var Indexer = &make{}
var Makefiles = []string{
"Makefile",
"makefile",
}
func (m *make) 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())) {
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("^([a-zA-Z-]*):((.*?)# (.*))?")
func ParseOutput(input string) []model.Target {
var targets []string
match := makeRegex.FindAllString(input, len(input))
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 NoJustError = errors.New("no make in $PATH but source contains makefile")
func (m *make) findMake() error {
loc, err := exec.LookPath("make")
if errors.Is(err, exec.ErrNotFound) {
return NoJustError
} else if err != nil {
return err
}
m.path = loc
return nil
}
package gnumake
import (
"os/exec"
"pik/model"
"pik/runner"
)
type Target struct {
runner.BaseTarget
Name string
Description string
}
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) Icon() string {
return "\uE673"
}
func (h *Hydrated) Description() string {
return h.BaseTarget.Description
}
package just
import (
"errors"
"io/fs"
"os/exec"
"pik/identity"
"pik/model"
"pik/runner"
"strings"
)
type just struct {
path string
}
var Indexer = &just{}
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" {
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 (
"os/exec"
"pik/model"
"pik/runner"
)
type Target struct {
runner.BaseTarget
Category string
}
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) Icon() string {
return "\uF039"
}
package python
import (
"os/exec"
"path/filepath"
"pik/model"
"pik/runner"
)
type File struct {
runner.BaseTarget
File string
}
type HydratedFileTarget struct {
runner.BaseHydration[*File]
}
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.File}
} else if venv := Python.VenvFor(s); venv != "" {
cmd = []string{filepath.Join(s.Path, venv, "bin", "python3"), p.File}
} else {
sysPath, err := exec.LookPath("python3")
if err != nil {
return nil
}
cmd = []string{sysPath, p.File}
}
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 (
"github.com/pelletier/go-toml/v2"
"io/fs"
"os"
"path/filepath"
"pik/model"
)
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
}
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 (
"os/exec"
"path/filepath"
"pik/model"
"pik/runner"
)
type Project struct {
runner.BaseTarget
Cmd string
}
type Hydrated struct {
runner.BaseHydration[*Project]
}
func (h *Hydrated) Icon() string {
return "\uE606"
}
func (h *Hydrated) Description() string {
return h.BaseTarget.Cmd
}
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"
"io/fs"
"os/exec"
"path/filepath"
"pik/identity"
"pik/model"
"pik/runner"
)
type python struct {
Venvs map[string]string
Uv string
System 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) Hydrate(target model.Target) (model.HydratedTarget, error) {
//TODO implement me
panic("implement me")
}
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),
},
File: file,
}, nil
}
var VenvPaths = []string{
".venv",
"venv",
}
var Python = &python{
Venvs: map[string]string{},
}
package shell
import (
"errors"
"pik/describe"
"pik/model"
"pik/runner"
"pik/spool"
)
type Hydrated struct {
runner.BaseHydration[*Target]
}
func (h *Hydrated) Icon() string {
return "\uF489"
}
func (h *Hydrated) Description() string {
desc, err := describe.Describe(h.BaseTarget, h.BaseTarget.Script)
if err != nil {
spool.Warn("%v\n", err)
return ""
}
return desc
}
var WrongTargetError = errors.New("wrong target type")
func (s *shell) Hydrate(target model.Target) (model.HydratedTarget, error) {
cast, ok := target.(*Target)
if !ok {
return nil, WrongTargetError
}
hyd := &Hydrated{BaseHydration: runner.Hydrated(cast)}
return hyd, nil
}
package shell
import (
"bufio"
"errors"
"io/fs"
"os/exec"
"path/filepath"
"pik/identity"
"pik/indexers/pikdex"
"pik/model"
"pik/runner"
"pik/spool"
"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) {
if s.Locations[shell] != "" {
return s.Locations[shell], nil
}
if p, err := exec.LookPath(shell); err == nil {
s.Lock()
s.Locations[shell] = p
s.Unlock()
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(pikdex.Roots, p) {
continue
}
if filename == p {
continue
}
sub = append(sub, p)
}
return &Target{
BaseTarget: runner.BaseTarget{
Identity: identity.New(filename),
MyTags: model.TagsFromFilename(filename),
},
Shell: shell,
Script: filepath.Join(src, file),
SubValue: sub,
}, 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 (%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 (
"os/exec"
"pik/model"
"pik/runner"
)
type Target struct {
runner.BaseTarget
Shell string
Script string
SubValue []string
}
func (s *Target) String() string {
return s.Label()
}
func (s *Target) Hydrate(_ *model.Source) (model.HydratedTarget, error) {
return Runner.Hydrate(s)
}
func (s *Target) Sub() []string {
return s.SubValue
}
func (s *Target) Label() string {
return s.Identity.Full
}
func (s *Target) Create(src *model.Source) *exec.Cmd {
return exec.Command(s.Shell, s.Script)
}
//go:build test
package runner
import (
"os/exec"
"pik/model"
)
// 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) 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")
}
package search
import (
"pik/model"
"slices"
)
type Result struct {
Target model.Target
Source *model.Source
NeedsConfirmation bool
Overridden bool
Sub []string
Args []string
}
// Search is the meat of pik
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,
}
}
//go:build test
package testx
import (
"github.com/stretchr/testify/assert"
"os/exec"
"pik/identity"
"pik/model"
"pik/runner"
"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 {
runner.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) {
//TODO implement me
panic("implement me")
}
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)
}