turbo-editors/turbo-corepublic Fork 0
d662cebdb65b319885da903daf7eff9ab1bfbb78
Commits
Clone
git clone https://git.rickub.com/turbo-editors/turbo-core.git
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

snippets.go · 231 lines · 6.5 KBGo Blame HistoryRaw
🛟 Updated. 28d5985 k33g 21h ago1// Package snippets reads the reusable pieces of text a project and a user keep
2// in TOML files, and groups them for a menu to show.
3//
4// It knows nothing about menus or editors: it reads files and returns values,
5// which is what lets it be tested by writing files and comparing them back.
6//
7// list, err := snippets.Load(p, ".")
8// if err != nil {
9// return err
10// }
11// for _, group := range list.Groups("go") {
12// fmt.Println(group.Name, len(group.Snippets))
13// }
14package snippets
15
16import (
17 "errors"
18 "fmt"
19 "os"
20 "path/filepath"
21
22 "github.com/BurntSushi/toml"
23
24 "codeberg.org/turbo-editors/turbo-core/profile"
25)
26
27// FileName is the file both the project and the user keep their snippets in.
28const FileName = "snippets.toml"
29
30// ungroupedName is the group a snippet with no group of its own falls into.
31const ungroupedName = "General"
32
33// ErrExists is returned by Create when the project already has a snippets
34// file, so that creating one never silently overwrites what someone wrote.
35var ErrExists = errors.New("snippets: project snippets already exist")
36
37// Snippet is one piece of text, with the name a menu shows for it.
38type Snippet struct {
39 // Name is what the menu shows.
40 Name string
41 // Body is the text inserted at the cursor.
42 Body string
43 // Group is the submenu it belongs to; empty means the general one.
44 Group string
45 // Languages restricts the snippet to files of those languages, by the
46 // names syntax.Language prints. Empty means every file.
47 Languages []string
48}
49
50// appliesTo reports whether the snippet should be offered for a language.
51//
52// A snippet that names no language is offered everywhere: the common case is a
53// note or a licence header that belongs in any file, and making people list
54// every language for that would be worse than showing a few too many.
55func (s Snippet) appliesTo(language string) bool {
56 if len(s.Languages) == 0 {
57 return true
58 }
59 for _, named := range s.Languages {
60 if named == language {
61 return true
62 }
63 }
64 return false
65}
66
67// Group is a submenu: a name and the snippets under it.
68type Group struct {
69 Name string
70 Snippets []Snippet
71}
72
73// List is every snippet available, in the order they were read.
74type List struct {
75 snippets []Snippet
76}
77
78// Len returns how many snippets there are in total.
79func (l List) Len() int { return len(l.snippets) }
80
81// Groups returns the snippets that apply to a language, gathered into groups.
82//
83// Groups come out in the order their first snippet was read, and so do the
84// snippets inside them, so the menu matches the file. A group left with nothing
85// after filtering does not appear at all.
86//
87// for _, group := range list.Groups("go") {
88// addSubmenu(group.Name, group.Snippets)
89// }
90func (l List) Groups(language string) []Group {
91 var groups []Group
92 index := map[string]int{}
93
94 for _, snippet := range l.snippets {
95 if !snippet.appliesTo(language) {
96 continue
97 }
98
99 name := snippet.Group
100 if name == "" {
101 name = ungroupedName
102 }
103 at, seen := index[name]
104 if !seen {
105 at = len(groups)
106 index[name] = at
107 groups = append(groups, Group{Name: name})
108 }
109 groups[at].Snippets = append(groups[at].Snippets, snippet)
110 }
111 return groups
112}
113
114// ProjectPath returns where a project keeps its snippets.
115//
116// snippets.ProjectPath(turboGo, "/src/p") // "/src/p/.turbo-go/snippets.toml"
117func ProjectPath(p profile.Profile, projectDir string) string {
118 return filepath.Join(projectDir, p.ProjectDir(), FileName)
119}
120
121// UserDir returns where a user's own snippets are read from, or "" when there
122// is nowhere to read them from.
123//
124// The profile's own SnippetDirEnvVar overrides it, which is what lets a test
125// point the editor somewhere of its own.
126func UserDir(p profile.Profile) string {
127 if dir := os.Getenv(p.SnippetDirEnvVar()); dir != "" {
128 return dir
129 }
130 return p.UserDir()
131}
132
133// UserPath returns the user's own snippets file, or "" when there is nowhere
134// to look for one.
135func UserPath(p profile.Profile) string {
136 dir := UserDir(p)
137 if dir == "" {
138 return ""
139 }
140 return filepath.Join(dir, FileName)
141}
142
143// Load reads a project's snippets and the user's own, and returns them
144// together.
145//
146// The **user's** come first and the **project's** after, so that a project can
147// add to what you already have; where a name clashes within a group, the
148// project's wins, because it is the more specific statement of the two.
149//
150// A file that is missing is not an error — most projects have none, and a user
151// may have none either. A file that is present but unreadable *is* an error, so
152// a typo in one is reported rather than silently dropping every snippet in it.
153//
154// list, err := snippets.Load(p, ".")
155func Load(p profile.Profile, projectDir string) (List, error) {
156 var list List
157
158 for _, path := range []string{UserPath(p), ProjectPath(p, projectDir)} {
159 if path == "" {
160 continue
161 }
162 read, err := loadFile(path)
163 if err != nil {
164 return List{}, err
165 }
166 list.snippets = merge(list.snippets, read)
167 }
168 return list, nil
169}
170
171// merge appends the later snippets, replacing any earlier one with the same
172// group and name.
173func merge(earlier, later []Snippet) []Snippet {
174 out := earlier
175 for _, snippet := range later {
176 if at := indexOf(out, snippet); at >= 0 {
177 out[at] = snippet
178 continue
179 }
180 out = append(out, snippet)
181 }
182 return out
183}
184
185// indexOf returns where a snippet with the same group and name already sits,
186// or -1.
187func indexOf(snippets []Snippet, wanted Snippet) int {
188 for i, snippet := range snippets {
189 if snippet.Group == wanted.Group && snippet.Name == wanted.Name {
190 return i
191 }
192 }
193 return -1
194}
195
196// file mirrors the snippets file's structure.
197type file struct {
198 Snippet []Snippet `toml:"snippet"`
199}
200
201// loadFile reads one snippets file, treating a missing one as empty.
202func loadFile(path string) ([]Snippet, error) {
203 data, err := os.ReadFile(path)
204 if err != nil {
205 if errors.Is(err, os.ErrNotExist) {
206 return nil, nil
207 }
208 return nil, fmt.Errorf("reading %s: %w", path, err)
209 }
210
211 var f file
212 if _, err := toml.Decode(string(data), &f); err != nil {
213 return nil, fmt.Errorf("reading %s: %w", path, err)
214 }
215 return usable(f.Snippet, path)
216}
217
218// usable drops nothing and refuses a snippet that could not be shown: one with
219// no name has nothing to put in a menu, and one with no body has nothing to
220// insert.
221func usable(snippets []Snippet, path string) ([]Snippet, error) {
222 for i, snippet := range snippets {
223 if snippet.Name == "" {
224 return nil, fmt.Errorf("reading %s: snippet %d has no name", path, i+1)
225 }
226 if snippet.Body == "" {
227 return nil, fmt.Errorf("reading %s: snippet %q has no body", path, snippet.Name)
228 }
229 }
230 return snippets, nil
231}