package cache
import (
"bufio"
"errors"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/paths"
"io"
"io/fs"
"os"
"path"
"strings"
)
type Cache struct {
Entries []Entry
}
type cacheInit struct{}
// Path is the file path to the "contexts" cache file
var Path string
// FsPath is the Path with the leading slash removed, to be opened from fs.FS
var FsPath string
var Init model.Initializer = &cacheInit{}
func (i *cacheInit) Init() error {
Path = path.Join(paths.Cache, "contexts")
FsPath = Path[1:]
return nil
}
// Merge combines two caches and filters duplicate keys
func (c *Cache) Merge(other *Cache) *Cache {
if other == nil && c != nil {
return c
}
if c == nil && other != nil {
return other
}
if 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
}
type Entry struct {
Path string
Label string
}
var Empty = Cache{}
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 nil, nil
} else if err != nil {
return nil, 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 Insert(in *model.State) error {
f := os.DirFS("/")
loaded, err := LoadFile(f, FsPath)
if err != nil {
return err
}
insert := New(in)
result := loaded.Merge(insert)
return SaveFile(Path, 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, 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)
}
// 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,
}
}
package main
import (
"github.com/ewy1/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"
"github.com/ewy1/pik/model"
"io"
"os"
"strings"
)
var DescriptionPrefixes = []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 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/ewy1/pik/flags"
"github.com/ewy1/pik/indexers/pikdex"
"github.com/ewy1/pik/model"
"github.com/ewy1/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(pikdex.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"
"github.com/ewy1/pik/model"
"github.com/ewy1/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 main
import (
_ "embed"
"fmt"
"github.com/ewy1/pik/cache"
"github.com/ewy1/pik/crawl"
"github.com/ewy1/pik/flags"
"github.com/ewy1/pik/git"
"github.com/ewy1/pik/indexers/pikdex"
"github.com/ewy1/pik/menu"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/paths"
"github.com/ewy1/pik/run"
"github.com/ewy1/pik/runner/exc"
"github.com/ewy1/pik/runner/gnumake"
"github.com/ewy1/pik/runner/js"
"github.com/ewy1/pik/runner/just"
"github.com/ewy1/pik/runner/python"
"github.com/ewy1/pik/runner/shell"
"github.com/ewy1/pik/search"
"github.com/ewy1/pik/spool"
"github.com/spf13/pflag"
"os"
)
// syncInitializers are ran before the initializers.
// useful for initializing stuff like paths, preparing directories, and reading the environment
var syncInitializers = ComponentList[model.Initializer]{
paths.Paths,
cache.Init,
}
// 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
//go:embed version.txt
var version string
func main() {
pflag.Parse()
statelessModes.Traverse(func(in func() error) error {
return in()
})
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)
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)
err = cache.Insert(st)
if err != nil {
spool.Warn("%v\n", err)
}
} else {
c, err = cache.LoadFile(fs, cache.Path[1:])
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)
}
statefulModes.Traverse(func(in func(st *model.State) error) error {
return in(st)
})
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)
os.Exit(1)
}
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
if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
}
SourcesWithoutResults = c
main()
return
}
if cancelled {
_, _ = spool.Warn("no target selected\n")
os.Exit(0)
return
}
if result.Target == nil {
_, _ = spool.Warn("target not found\n")
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))
}
selectionModes.Traverse(func(in func(st *model.State, src *model.Source, t model.Target) error) error {
return in(st, result.Source, 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"
"github.com/ewy1/pik/flags"
"github.com/ewy1/pik/menu/style"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/paths"
"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"
"github.com/charmbracelet/lipgloss"
"github.com/ewy1/pik/flags"
"github.com/ewy1/pik/menu/style"
"github.com/ewy1/pik/model"
"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 (
"github.com/charmbracelet/lipgloss"
"github.com/ewy1/pik/menu/style"
"github.com/ewy1/pik/model"
"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 (
"github.com/charmbracelet/lipgloss"
"github.com/ewy1/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) {
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"
tea "github.com/charmbracelet/bubbletea"
"github.com/ewy1/pik/model"
"github.com/ewy1/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, error) {
if len(st.Sources) == 0 {
return nil, NoSourcesIndexedError
}
md := NewModel(st, hydrators)
var opts []tea.ProgramOption
program := tea.NewProgram(md, opts...)
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 (
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/x/term"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/motd"
"github.com/ewy1/pik/spool"
"github.com/ewy1/pik/viewport"
"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),
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 (
"github.com/charmbracelet/lipgloss"
"github.com/ewy1/pik/menu/style"
"github.com/ewy1/pik/model"
"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
})
)
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 := 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 (
"github.com/charmbracelet/lipgloss"
"github.com/ewy1/pik/menu/style"
)
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 (
"github.com/charmbracelet/lipgloss"
"github.com/ewy1/pik/menu/style"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/viewport"
)
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"
"github.com/ewy1/pik/flags"
"github.com/ewy1/pik/identity"
"io/fs"
"path/filepath"
"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 (
"github.com/ewy1/pik/identity"
"github.com/ewy1/pik/paths"
"github.com/ewy1/pik/spool"
)
// 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
Tags
Path string
Targets []Target
}
// 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 *string
type TagAction func(src *Source)
// New creates a new tag and registers it in the subsystems
func New(input string) Tag {
result := &input
TagMap[input] = result
TagList = append(TagList, result)
return result
}
var (
// Here will force the target to run in the current directory instead of the source directory
Here = New("here")
// Pre turns the target into a trigger, causing it to be triggered before another target gets ran
Pre = New("pre")
// Post turns the target into a trigger, causing it to be triggered after another target gets ran and exits succesfully
Post = New("post")
// Final turns the target into a trigger, causing it to be triggered after another target gets ran
Final = New("final")
// Hidden means the target is not visible in the menu
Hidden = New("hidden")
// Single means this target will not use any triggers
Single = New("single")
// Override means this should be selected instead of a non-override target, if possible
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 main
import (
"errors"
"github.com/ewy1/pik/completion"
"github.com/ewy1/pik/flags"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/paths"
"github.com/ewy1/pik/run"
"github.com/ewy1/pik/spool"
"os"
)
// ModeMap maps flags to specific operation modes
type ModeMap[T any] map[*bool]T
// Continue can be returned as an error to continue program flow
var Continue = errors.New("not an error; continue flow")
// Traverse checks the entries of the map. If any flags are set on,
// run 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)
// no additional error handling is required
func (m ModeMap[T]) Traverse(then func(in T) error) {
for enabled, mode := range m {
if !*enabled {
continue
}
err := then(mode)
if errors.Is(err, Continue) {
continue
} else if err != nil {
_, _ = spool.Warn("%v\n", err)
os.Exit(1)
} else {
os.Exit(0)
}
}
}
// statelessModes are program modes which do not require state to operate.
// like --version and --completion
var statelessModes = ModeMap[func() error]{
flags.Version: func() error {
_, err := spool.Print("%s\n", version)
return err
},
flags.Completion: func() error {
return completion.Echo()
},
}
// statefulModes are program modes which require a built state to be executed
var statefulModes = ModeMap[func(st *model.State) error]{
flags.List: func(st *model.State) error {
for _, s := range st.Sources {
_, _ = spool.Print("%v", s.Label()+paths.Ifs)
for _, t := range s.Targets {
_, _ = spool.Print("%v", t.ShortestId()+paths.Ifs)
}
}
return nil
},
}
// 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) error]{
flags.Edit: func(st *model.State, src *model.Source, t model.Target) error {
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 thin",
"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"
"github.com/ewy1/pik/describe"
"github.com/ewy1/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.DescriptionPrefixes {
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
import (
"github.com/adrg/xdg"
"os"
"path/filepath"
"strings"
)
var Home, This, Cache, Config, Ifs string
type paths struct {
Initialized bool
}
var Paths = &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()
}
Home = xdg.Home
This = "pik"
Cache = filepath.Join(xdg.CacheHome, This)
Config = filepath.Join(xdg.ConfigHome, This)
Ifs = os.Getenv("IFS")
err := os.MkdirAll(Cache, 0700)
if err != nil {
return err
}
err = os.MkdirAll(Config, 0700)
if err != nil {
return err
}
if Ifs == "" {
Ifs = "\n"
}
p.Initialized = true
return nil
}
func ReplaceHome(input string) string {
return strings.Replace(input, Home, "~", 1)
}
package run
import (
"errors"
"github.com/ewy1/pik/model"
"os"
"os/exec"
)
var NoEditorError = errors.New("$EDITOR not set")
func Edit(t model.Target, src *model.Source) error {
editor := os.Getenv("EDITOR")
if editor == "" {
return NoEditorError
}
cmd := exec.Command(editor, t.File(src))
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Dir = src.Path
return cmd.Run()
}
package run
import (
"fmt"
"github.com/ewy1/pik/env"
"github.com/ewy1/pik/flags"
"github.com/ewy1/pik/menu"
"github.com/ewy1/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 (
"github.com/ewy1/pik/identity"
"github.com/ewy1/pik/indexers/pikdex"
"github.com/ewy1/pik/model"
"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(pikdex.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 (
"github.com/ewy1/pik/identity"
"github.com/ewy1/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 (
"github.com/ewy1/pik/identity"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/runner"
"github.com/ewy1/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 (
"github.com/ewy1/pik/describe"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/runner"
"github.com/ewy1/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"
"github.com/ewy1/pik/identity"
"github.com/ewy1/pik/model"
"github.com/ewy1/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("^([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 gnumake in $PATH but source contains makefile")
func (m *gnumake) findMake() error {
loc, err := exec.LookPath("gnumake")
if errors.Is(err, exec.ErrNotFound) {
return NoJustError
} else if err != nil {
return err
}
m.path = loc
return nil
}
package gnumake
import (
"github.com/ewy1/pik/model"
"github.com/ewy1/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"
"github.com/ewy1/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"
"github.com/ewy1/pik/identity"
"github.com/ewy1/pik/model"
"github.com/ewy1/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 (
"github.com/ewy1/pik/identity"
"github.com/ewy1/pik/model"
"github.com/ewy1/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 (
"github.com/ewy1/pik/model"
"github.com/ewy1/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 (
"github.com/ewy1/pik/describe"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/runner"
"github.com/ewy1/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"
"github.com/ewy1/pik/identity"
"github.com/ewy1/pik/model"
"github.com/ewy1/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 (
"github.com/ewy1/pik/model"
"github.com/ewy1/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 (
"github.com/ewy1/pik/describe"
"github.com/ewy1/pik/model"
"github.com/ewy1/pik/runner"
"github.com/ewy1/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 (
"github.com/ewy1/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 (
"github.com/ewy1/pik/model"
"github.com/ewy1/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"
"github.com/ewy1/pik/identity"
"github.com/ewy1/pik/model"
"github.com/ewy1/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{},
}
//go:build test
package runner
import (
"github.com/ewy1/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 (
"github.com/ewy1/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 (
"fmt"
"os"
)
var (
Stderr = os.Stderr
Stdout = os.Stdout
)
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.Fprintf(Stderr, format, values...)
}
package viewport
import (
"github.com/charmbracelet/lipgloss"
"github.com/ewy1/pik/menu/style"
"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
}