master completions.go
  1package senpai
  2
  3import (
  4	"fmt"
  5	"os"
  6	"path/filepath"
  7	"sort"
  8	"strings"
  9
 10	"git.sr.ht/~delthas/senpai/irc"
 11	"git.sr.ht/~delthas/senpai/ui"
 12)
 13
 14type members []irc.Member
 15
 16func (m members) Len() int {
 17	return len(m)
 18}
 19
 20func (m members) Less(i, j int) bool {
 21	if c := m[i].LastActive.Compare(m[j].LastActive); c != 0 {
 22		return c > 0
 23	}
 24	return strings.ToLower(m[i].Name.Name) < strings.ToLower(m[j].Name.Name)
 25}
 26
 27func (m members) Swap(i, j int) {
 28	m[i], m[j] = m[j], m[i]
 29}
 30
 31type completionAsync func(e irc.Event) []ui.Completion
 32
 33func (app *App) completionsChannelMembers(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
 34	if hasPrefix(text, []rune("/msg ")) {
 35		return cs
 36	}
 37	var start int
 38	for start = cursorIdx - 1; 0 <= start; start-- {
 39		if text[start] == ' ' {
 40			break
 41		}
 42	}
 43	start++
 44	word := text[start:cursorIdx]
 45	if len(word) == 0 {
 46		return cs
 47	}
 48	netID, buffer := app.win.CurrentBuffer()
 49	s := app.sessions[netID] // is not nil
 50	wordCf := s.Casemap(string(word))
 51	names := members(s.Names(buffer))
 52	sort.Sort(names)
 53	for _, name := range names {
 54		if strings.HasPrefix(s.Casemap(name.Name.Name), wordCf) {
 55			nickComp := []rune(name.Name.Name)
 56			if start == 0 {
 57				nickComp = append(nickComp, ':')
 58			}
 59			nickComp = append(nickComp, ' ')
 60			c := make([]rune, len(text)+len(nickComp)-len(word))
 61			copy(c[:start], text[:start])
 62			if cursorIdx < len(text) {
 63				copy(c[start+len(nickComp):], text[cursorIdx:])
 64			}
 65			copy(c[start:], nickComp)
 66			cs = append(cs, ui.Completion{
 67				StartIdx:  start,
 68				EndIdx:    cursorIdx,
 69				Text:      c,
 70				Display:   []rune(name.Name.Name),
 71				CursorIdx: start + len(nickComp),
 72			})
 73		}
 74	}
 75	return cs
 76}
 77
 78func (app *App) completionsJoin(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
 79	if !hasPrefix(text[:cursorIdx], []rune("/join #")) {
 80		return cs
 81	}
 82	netID, _ := app.win.CurrentBuffer()
 83	s := app.sessions[netID] // is not nil
 84	if s == nil {
 85		return cs
 86	}
 87	if !s.HasListMask() {
 88		return cs
 89	}
 90
 91	post := append([]rune{}, text[cursorIdx:]...)
 92	channel := append([]rune{}, text[6:]...)
 93	if len(channel) < 3 {
 94		// Require at least 2 characters for a search, to avoid triggering large LISTs in completions
 95		return cs
 96	}
 97
 98	s.List(string(channel) + "*")
 99
100	cs = append(cs, ui.Completion{
101		Async: completionAsync(func(e irc.Event) []ui.Completion {
102			l, ok := e.(irc.ListEvent)
103			if !ok {
104				return nil
105			}
106			cs := make([]ui.Completion, len(l))
107			for i, e := range l {
108				text := []rune("/join ")
109				text = append(text, []rune(e.Channel)...)
110				text = append(text, post...)
111				cs[i] = ui.Completion{
112					StartIdx:  6,
113					EndIdx:    6 + len([]rune(e.Channel)),
114					Text:      text,
115					CursorIdx: cursorIdx + len([]rune(e.Channel)) - len(channel),
116				}
117			}
118			return cs
119		}),
120	})
121	return cs
122}
123
124func (app *App) completionsChannelTopic(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
125	if !hasPrefix(text, []rune("/topic ")) {
126		return cs
127	}
128	netID, buffer := app.win.CurrentBuffer()
129	s := app.sessions[netID] // is not nil
130	topic, _, _ := s.Topic(buffer)
131	if cursorIdx == len(text) {
132		compText := append(text, []rune(topic)...)
133		cs = append(cs, ui.Completion{
134			StartIdx:  cursorIdx,
135			EndIdx:    cursorIdx,
136			Text:      compText,
137			CursorIdx: len(compText),
138		})
139	}
140	return cs
141}
142
143func (app *App) completionsMsg(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
144	if !hasPrefix(text, []rune("/msg ")) {
145		return cs
146	}
147	s := app.CurrentSession() // is not nil
148	// Check if the first word (target) is already written and complete (in
149	// which case we don't have completions to provide).
150	var word string
151	hasMetALetter := false
152	for i := 5; i < cursorIdx; i += 1 {
153		if hasMetALetter && text[i] == ' ' {
154			return cs
155		}
156		if !hasMetALetter && text[i] != ' ' {
157			word = s.Casemap(string(text[i:cursorIdx]))
158			hasMetALetter = true
159		}
160	}
161	if word == "" {
162		return cs
163	}
164	users := s.Users()
165	sort.Strings(users)
166	for _, user := range users {
167		if strings.HasPrefix(s.Casemap(user), word) {
168			nickComp := append([]rune(user), ' ')
169			c := make([]rune, len(text)+5+len(nickComp)-cursorIdx)
170			copy(c[:5], []rune("/msg "))
171			copy(c[5:], nickComp)
172			if cursorIdx < len(text) {
173				copy(c[5+len(nickComp):], text[cursorIdx:])
174			}
175			cs = append(cs, ui.Completion{
176				StartIdx:  5,
177				EndIdx:    cursorIdx,
178				Text:      c,
179				Display:   []rune(user),
180				CursorIdx: 5 + len(nickComp),
181			})
182		}
183	}
184	return cs
185}
186
187func (app *App) completionsUpload(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
188	if !hasPrefix(text, []rune("/upload ")) {
189		return cs
190	}
191	if app.cfg.Transient || !app.cfg.LocalIntegrations {
192		return cs
193	}
194	_, path, ok := strings.Cut(string(text[:cursorIdx]), " ")
195	if !ok {
196		return cs
197	}
198
199	var home string
200	if h, err := os.UserHomeDir(); err == nil {
201		home = h
202	}
203	dirPath := ""
204	dirPrefix := ""
205	if path == "" {
206		if home != "" {
207			dirPath = home
208		} else {
209			if filepath.Separator != '/' {
210				return cs
211			}
212			dirPath = "/"
213		}
214	} else {
215		isDir := strings.HasSuffix(path, string(filepath.Separator))
216		if home != "" && !filepath.IsAbs(path) {
217			path = filepath.Join(home, path)
218		}
219		if isDir {
220			dirPath = path
221		} else {
222			dirPath = filepath.Dir(path)
223			dirPrefix = filepath.Base(path)
224		}
225	}
226	dir, err := os.ReadDir(dirPath)
227	if err != nil {
228		return cs
229	}
230
231	for _, e := range dir {
232		if !strings.HasPrefix(e.Name(), dirPrefix) {
233			continue
234		}
235		name := e.Name()
236		var isDir bool
237		if e.IsDir() {
238			isDir = true
239		} else if e.Type() == os.ModeSymlink {
240			if fi, err := os.Stat(filepath.Join(dirPath, name)); err == nil && fi.IsDir() {
241				isDir = true
242			}
243		}
244		if isDir {
245			name += string(filepath.Separator)
246		}
247		if dirPrefix == "/" {
248			name = "/" + name
249		}
250		if name == dirPrefix {
251			continue
252		}
253		cs = append(cs, ui.Completion{
254			StartIdx:  cursorIdx - len([]rune(dirPrefix)),
255			EndIdx:    cursorIdx,
256			Text:      []rune(string(text[:cursorIdx]) + name[len(dirPrefix):] + string(text[cursorIdx:])),
257			CursorIdx: cursorIdx + len([]rune(name)) - len([]rune(dirPrefix)),
258		})
259	}
260	return cs
261}
262
263func (app *App) completionsCommands(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
264	if !hasPrefix(text, []rune("/")) {
265		return cs
266	}
267	for i := 0; i < cursorIdx; i++ {
268		if text[i] == ' ' {
269			return cs
270		}
271	}
272	if cursorIdx < len(text) && text[cursorIdx] != ' ' {
273		return cs
274	}
275
276	uText := strings.ToUpper(string(text[1:cursorIdx]))
277	names := make([]string, 0, len(commands))
278	for name := range commands {
279		names = append(names, name)
280	}
281	sort.Strings(names)
282	for _, name := range names {
283		if strings.HasPrefix(name, uText) {
284			c := make([]rune, len(text)+len(name)-len(uText))
285			copy(c[:1], []rune("/"))
286			copy(c[1:], []rune(strings.ToLower(name)))
287			copy(c[1+len(name):], text[cursorIdx:])
288
289			cs = append(cs, ui.Completion{
290				StartIdx:  0,
291				EndIdx:    cursorIdx,
292				Text:      c,
293				CursorIdx: 1 + len(name),
294			})
295		}
296	}
297	return cs
298}
299
300func (app *App) completionsEmoji(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
301	var start int
302	for start = cursorIdx - 1; start >= 0; start-- {
303		r := text[start]
304		if r == ':' {
305			break
306		}
307		if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '_' || (r >= '0' && r <= '9')) {
308			return cs
309		}
310	}
311	if start < 0 {
312		return cs
313	}
314	start++
315	word := text[start:cursorIdx]
316	if len(word) == 0 {
317		return cs
318	}
319	w := strings.ToLower(string(word))
320	for _, emoji := range findEmoji(w) {
321		c := make([]rune, 0, len(text)+len([]rune(emoji.Emoji))-len(word)-1)
322		c = append(c, text[:start-1]...)
323		c = append(c, []rune(emoji.Emoji)...)
324		if cursorIdx < len(text) {
325			c = append(c, text[cursorIdx:]...)
326		}
327		cs = append(cs, ui.Completion{
328			StartIdx:  start - 1,
329			EndIdx:    cursorIdx,
330			Text:      c,
331			Display:   []rune(fmt.Sprintf("%v (%v)", emoji.Emoji, emoji.Alias)),
332			CursorIdx: start - 1 + len([]rune(emoji.Emoji)),
333		})
334	}
335	return cs
336}
337
338func hasPrefix(s, prefix []rune) bool {
339	return len(prefix) <= len(s) && equal(prefix, s[:len(prefix)])
340}
341
342func equal(a, b []rune) bool {
343	if len(a) != len(b) {
344		return false
345	}
346	for i := 0; i < len(a); i++ {
347		if a[i] != b[i] {
348			return false
349		}
350	}
351	return true
352}