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}