| 🛟 Updated. 28d5985 k33g 15h ago | 1 | // 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 | // } |
| 14 | package snippets |
| 15 | |
| 16 | import ( |
| 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. |
| 28 | const FileName = "snippets.toml" |
| 29 | |
| 30 | // ungroupedName is the group a snippet with no group of its own falls into. |
| 31 | const 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. |
| 35 | var ErrExists = errors.New("snippets: project snippets already exist") |
| 36 | |
| 37 | // Snippet is one piece of text, with the name a menu shows for it. |
| 38 | type 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. |
| 55 | func (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. |
| 68 | type Group struct { |
| 69 | Name string |
| 70 | Snippets []Snippet |
| 71 | } |
| 72 | |
| 73 | // List is every snippet available, in the order they were read. |
| 74 | type List struct { |
| 75 | snippets []Snippet |
| 76 | } |
| 77 | |
| 78 | // Len returns how many snippets there are in total. |
| 79 | func (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 | // } |
| 90 | func (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" |
| 117 | func 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. |
| 126 | func 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. |
| 135 | func 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, ".") |
| 155 | func 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. |
| 173 | func 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. |
| 187 | func 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. |
| 197 | type file struct { |
| 198 | Snippet []Snippet `toml:"snippet"` |
| 199 | } |
| 200 | |
| 201 | // loadFile reads one snippets file, treating a missing one as empty. |
| 202 | func 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. |
| 221 | func 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 | } |