elvish/edit/location/location.go

356 lines
8.7 KiB
Go

// Package location implements the location mode for the editor.
package location
import (
"bytes"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/elves/elvish/edit/eddefs"
"github.com/elves/elvish/edit/ui"
"github.com/elves/elvish/eval"
"github.com/elves/elvish/eval/vals"
"github.com/elves/elvish/eval/vars"
"github.com/elves/elvish/parse"
"github.com/elves/elvish/store/storedefs"
"github.com/elves/elvish/util"
"github.com/xiaq/persistent/hashmap"
"github.com/xiaq/persistent/vector"
)
var logger = util.GetLogger("[edit/location] ")
// pinnedScore is a special value of Score in storedefs.Dir to represent that the
// directory is pinned.
var pinnedScore = math.Inf(1)
type mode struct {
editor eddefs.Editor
binding eddefs.BindingMap
hidden vector.Vector
pinned vector.Vector
workspaces hashmap.Map
matcher eval.Callable
}
var matchDirPatternBuiltin = eval.NewBuiltinFn("edit:location:match-dir-pattern", matchDirPattern)
// Init initializes the location mode for an Editor.
func Init(ed eddefs.Editor, ns eval.Ns) {
m := &mode{ed, eddefs.EmptyBindingMap,
vals.EmptyList, vals.EmptyList, vals.EmptyMap, matchDirPatternBuiltin}
ns.AddNs("location",
eval.Ns{
"binding": vars.FromPtr(&m.binding),
"hidden": vars.FromPtr(&m.hidden),
"pinned": vars.FromPtr(&m.pinned),
"matcher": vars.FromPtr(&m.matcher),
"workspaces": vars.FromPtr(&m.workspaces),
}.AddBuiltinFn("edit:location:", "start", m.start).
AddFn("match-dir-pattern", matchDirPatternBuiltin))
ed.Evaler().AddAfterChdir(func(string) {
store := ed.Daemon()
if store == nil {
return
}
pwd, err := os.Getwd()
if err != nil {
logger.Println("Failed to get pwd in after-chdir hook:", err)
}
go addDir(store, pwd, m.workspaces)
})
}
func addDir(store storedefs.Store, pwd string, workspaces hashmap.Map) {
err := store.AddDir(pwd, 1)
if err != nil {
logger.Println("add dir in after-chdir hook:", err)
return
}
ws := matchWorkspace(pwd, workspaces)
if ws == nil {
return
}
err = store.AddDir(ws.workspacify(pwd), 1)
if err != nil {
logger.Println("add workspacified dir in after-chdir hook:", err)
}
}
type wsInfo struct {
name string
root string
}
func matchWorkspace(dir string, workspaces hashmap.Map) *wsInfo {
for it := workspaces.Iterator(); it.HasElem(); it.Next() {
k, v := it.Elem()
name, ok := k.(string)
if !ok {
// TODO: Surface to user
logger.Println("$workspaces key not string", k)
continue
}
if strings.HasPrefix(name, "/") {
// TODO: Surface to user
logger.Println("$workspaces key starts with /", k)
continue
}
pattern, ok := v.(string)
if !ok {
// TODO: Surface to user
logger.Println("$workspaces value not string", v)
continue
}
if !strings.HasPrefix(pattern, "^") {
pattern = "^" + pattern
}
re, err := regexp.Compile(pattern)
if err != nil {
// TODO: Surface to user
logger.Println("$workspaces pattern invalid", pattern)
continue
}
if ws := re.FindString(dir); ws != "" {
return &wsInfo{name, ws}
}
}
return nil
}
func (w *wsInfo) workspacify(dir string) string {
return w.name + dir[len(w.root):]
}
func (w *wsInfo) unworkspacify(dir string) string {
return w.root + dir[len(w.name):]
}
func (m *mode) start() {
ed := m.editor
daemon := ed.Daemon()
if daemon == nil {
ed.Notify("store offline, cannot start location mode")
return
}
// Pinned directories are also blacklisted to prevent them from showing up
// twice.
black := convertListsToSet(m.hidden, m.pinned)
pwd, err := os.Getwd()
if err == nil {
black[pwd] = struct{}{}
}
stored, err := daemon.Dirs(black)
if err != nil {
ed.Notify("store error: %v", err)
return
}
// TODO: Move workspace filtering to the daemon.
ws := matchWorkspace(pwd, m.workspaces)
wsName := ""
if ws != nil {
wsName = ws.name
}
wsNameSlash := wsName + string(filepath.Separator)
var filtered []storedefs.Dir
for _, dir := range stored {
if filepath.IsAbs(dir.Path) || (wsName != "" && (dir.Path == wsName || strings.HasPrefix(dir.Path, wsNameSlash))) {
filtered = append(filtered, dir)
}
}
// Prepend pinned dirs.
pinnedDirs := convertListToDirs(m.pinned)
dirs := make([]storedefs.Dir, len(pinnedDirs)+len(filtered))
copy(dirs, pinnedDirs)
copy(dirs[len(pinnedDirs):], filtered)
// Drop the error. When there is an error, home is "", which is used to
// signify "no home known" in location.
home, _ := util.GetHome("")
ed.SetModeListing(m.binding,
newProvider(dirs, home, ws, ed.Evaler(), m.matcher))
}
// convertListToDirs converts a list of strings to []storedefs.Dir. It uses the
// special score of pinnedScore to signify that the directory is pinned.
func convertListToDirs(li vector.Vector) []storedefs.Dir {
pinned := make([]storedefs.Dir, 0, li.Len())
// XXX(xiaq): silently drops non-string items.
for it := li.Iterator(); it.HasElem(); it.Next() {
if s, ok := it.Elem().(string); ok {
pinned = append(pinned, storedefs.Dir{s, pinnedScore})
}
}
return pinned
}
func convertListsToSet(lis ...vector.Vector) map[string]struct{} {
set := make(map[string]struct{})
// XXX(xiaq): silently drops non-string items.
for _, li := range lis {
for it := li.Iterator(); it.HasElem(); it.Next() {
if s, ok := it.Elem().(string); ok {
set[s] = struct{}{}
}
}
}
return set
}
type provider struct {
all []storedefs.Dir
filtered []storedefs.Dir
home string // The home directory; leave empty if unknown.
ws *wsInfo
ev *eval.Evaler
matcher eval.Callable
}
func newProvider(dirs []storedefs.Dir, home string, ws *wsInfo, ev *eval.Evaler, matcher eval.Callable) *provider {
return &provider{dirs, nil, home, ws, ev, matcher}
}
func (*provider) ModeTitle(i int) string {
return " LOCATION "
}
func (*provider) CursorOnModeLine() bool {
return true
}
func (p *provider) Len() int {
return len(p.filtered)
}
func (p *provider) Show(i int) (string, ui.Styled) {
var header string
score := p.filtered[i].Score
if score == pinnedScore {
header = "*"
} else {
header = fmt.Sprintf("%.0f", score)
}
return header, ui.Unstyled(showPath(p.filtered[i].Path, p.home))
}
func showPath(path, home string) string {
if home != "" && path == home {
return "~"
} else if home != "" && strings.HasPrefix(path, home+"/") {
return "~/" + parse.Quote(path[len(home)+1:])
} else {
return parse.Quote(path)
}
}
func (p *provider) Filter(filter string) int {
p.filtered = nil
// TODO: this is just a replica of `filterRawCandidates`.
matcherInput := make(chan interface{}, len(p.all))
stopCollector := make(chan struct{})
go func() {
defer close(matcherInput)
for _, item := range p.all {
select {
case matcherInput <- showPath(item.Path, p.home):
logger.Printf("put %s\n", item.Path)
case <-stopCollector:
return
}
}
}()
defer close(stopCollector)
ports := []*eval.Port{
{Chan: matcherInput, File: eval.DevNull}, {File: os.Stdout}, {File: os.Stderr}}
ec := eval.NewTopFrame(p.ev, eval.NewInternalSource("[editor matcher]"), ports)
args := []interface{}{filter}
values, err := ec.CaptureOutput(p.matcher, args, eval.NoOpts)
if err != nil {
logger.Printf("failed to match %s: %v", filter, err)
return -1
} else if got, expect := len(values), len(p.all); got != expect {
logger.Printf("wrong match count: got %d, want %d", got, expect)
return -1
}
for i, value := range values {
if vals.Bool(value) {
p.filtered = append(p.filtered, p.all[i])
}
}
if len(p.filtered) == 0 {
return -1
}
return 0
}
var emptyRegexp = regexp.MustCompile("")
func makeLocationFilterPattern(s string, ignoreCase bool) *regexp.Regexp {
var b bytes.Buffer
if ignoreCase {
b.WriteString("(?i)")
}
b.WriteString(".*")
segs := strings.Split(s, "/")
for i, seg := range segs {
if i > 0 {
b.WriteString(".*/.*")
}
b.WriteString(regexp.QuoteMeta(seg))
}
b.WriteString(".*")
p, err := regexp.Compile(b.String())
if err != nil {
logger.Printf("failed to compile regexp %q: %v", b.String(), err)
return emptyRegexp
}
return p
}
func (p *provider) Accept(i int, ed eddefs.Editor) {
path := p.filtered[i].Path
if !filepath.IsAbs(path) {
path = p.ws.unworkspacify(path)
}
err := ed.Evaler().Chdir(path)
if err != nil {
ed.Notify("%v", err)
}
ed.SetModeInsert()
}
type matchDirPatternOpts struct {
IgnoreCase bool
}
func (*matchDirPatternOpts) SetDefaultOptions() {}
func matchDirPattern(fm *eval.Frame, opts matchDirPatternOpts, pattern string, inputs eval.Inputs) {
p := makeLocationFilterPattern(pattern, opts.IgnoreCase)
out := fm.OutputChan()
inputs(func(v interface{}) {
s, ok := v.(string)
if !ok {
logger.Printf("input item must be string, but got %#v", v)
return
}
out <- vals.Bool(p.MatchString(s))
})
}