turbo-editors/turbo-corepublic Fork 0
v1.0.0
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.

📦 Turbo Core f3ade8d · on v1.0.0 · k33g · 9h ago
snippets.go · 231 lines · 6.5 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
// Package snippets reads the reusable pieces of text a project and a user keep
// in TOML files, and groups them for a menu to show.
//
// It knows nothing about menus or editors: it reads files and returns values,
// which is what lets it be tested by writing files and comparing them back.
//
//	list, err := snippets.Load(p, ".")
//	if err != nil {
//		return err
//	}
//	for _, group := range list.Groups("go") {
//		fmt.Println(group.Name, len(group.Snippets))
//	}
package snippets

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/BurntSushi/toml"

	"rickub.com/turbo-editors/turbo-core/profile"
)

// FileName is the file both the project and the user keep their snippets in.
const FileName = "snippets.toml"

// ungroupedName is the group a snippet with no group of its own falls into.
const ungroupedName = "General"

// ErrExists is returned by Create when the project already has a snippets
// file, so that creating one never silently overwrites what someone wrote.
var ErrExists = errors.New("snippets: project snippets already exist")

// Snippet is one piece of text, with the name a menu shows for it.
type Snippet struct {
	// Name is what the menu shows.
	Name string
	// Body is the text inserted at the cursor.
	Body string
	// Group is the submenu it belongs to; empty means the general one.
	Group string
	// Languages restricts the snippet to files of those languages, by the
	// names syntax.Language prints. Empty means every file.
	Languages []string
}

// appliesTo reports whether the snippet should be offered for a language.
//
// A snippet that names no language is offered everywhere: the common case is a
// note or a licence header that belongs in any file, and making people list
// every language for that would be worse than showing a few too many.
func (s Snippet) appliesTo(language string) bool {
	if len(s.Languages) == 0 {
		return true
	}
	for _, named := range s.Languages {
		if named == language {
			return true
		}
	}
	return false
}

// Group is a submenu: a name and the snippets under it.
type Group struct {
	Name     string
	Snippets []Snippet
}

// List is every snippet available, in the order they were read.
type List struct {
	snippets []Snippet
}

// Len returns how many snippets there are in total.
func (l List) Len() int { return len(l.snippets) }

// Groups returns the snippets that apply to a language, gathered into groups.
//
// Groups come out in the order their first snippet was read, and so do the
// snippets inside them, so the menu matches the file. A group left with nothing
// after filtering does not appear at all.
//
//	for _, group := range list.Groups("go") {
//		addSubmenu(group.Name, group.Snippets)
//	}
func (l List) Groups(language string) []Group {
	var groups []Group
	index := map[string]int{}

	for _, snippet := range l.snippets {
		if !snippet.appliesTo(language) {
			continue
		}

		name := snippet.Group
		if name == "" {
			name = ungroupedName
		}
		at, seen := index[name]
		if !seen {
			at = len(groups)
			index[name] = at
			groups = append(groups, Group{Name: name})
		}
		groups[at].Snippets = append(groups[at].Snippets, snippet)
	}
	return groups
}

// ProjectPath returns where a project keeps its snippets.
//
//	snippets.ProjectPath(turboGo, "/src/p") // "/src/p/.turbo-go/snippets.toml"
func ProjectPath(p profile.Profile, projectDir string) string {
	return filepath.Join(projectDir, p.ProjectDir(), FileName)
}

// UserDir returns where a user's own snippets are read from, or "" when there
// is nowhere to read them from.
//
// The profile's own SnippetDirEnvVar overrides it, which is what lets a test
// point the editor somewhere of its own.
func UserDir(p profile.Profile) string {
	if dir := os.Getenv(p.SnippetDirEnvVar()); dir != "" {
		return dir
	}
	return p.UserDir()
}

// UserPath returns the user's own snippets file, or "" when there is nowhere
// to look for one.
func UserPath(p profile.Profile) string {
	dir := UserDir(p)
	if dir == "" {
		return ""
	}
	return filepath.Join(dir, FileName)
}

// Load reads a project's snippets and the user's own, and returns them
// together.
//
// The **user's** come first and the **project's** after, so that a project can
// add to what you already have; where a name clashes within a group, the
// project's wins, because it is the more specific statement of the two.
//
// A file that is missing is not an error — most projects have none, and a user
// may have none either. A file that is present but unreadable *is* an error, so
// a typo in one is reported rather than silently dropping every snippet in it.
//
//	list, err := snippets.Load(p, ".")
func Load(p profile.Profile, projectDir string) (List, error) {
	var list List

	for _, path := range []string{UserPath(p), ProjectPath(p, projectDir)} {
		if path == "" {
			continue
		}
		read, err := loadFile(path)
		if err != nil {
			return List{}, err
		}
		list.snippets = merge(list.snippets, read)
	}
	return list, nil
}

// merge appends the later snippets, replacing any earlier one with the same
// group and name.
func merge(earlier, later []Snippet) []Snippet {
	out := earlier
	for _, snippet := range later {
		if at := indexOf(out, snippet); at >= 0 {
			out[at] = snippet
			continue
		}
		out = append(out, snippet)
	}
	return out
}

// indexOf returns where a snippet with the same group and name already sits,
// or -1.
func indexOf(snippets []Snippet, wanted Snippet) int {
	for i, snippet := range snippets {
		if snippet.Group == wanted.Group && snippet.Name == wanted.Name {
			return i
		}
	}
	return -1
}

// file mirrors the snippets file's structure.
type file struct {
	Snippet []Snippet `toml:"snippet"`
}

// loadFile reads one snippets file, treating a missing one as empty.
func loadFile(path string) ([]Snippet, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return nil, nil
		}
		return nil, fmt.Errorf("reading %s: %w", path, err)
	}

	var f file
	if _, err := toml.Decode(string(data), &f); err != nil {
		return nil, fmt.Errorf("reading %s: %w", path, err)
	}
	return usable(f.Snippet, path)
}

// usable drops nothing and refuses a snippet that could not be shown: one with
// no name has nothing to put in a menu, and one with no body has nothing to
// insert.
func usable(snippets []Snippet, path string) ([]Snippet, error) {
	for i, snippet := range snippets {
		if snippet.Name == "" {
			return nil, fmt.Errorf("reading %s: snippet %d has no name", path, i+1)
		}
		if snippet.Body == "" {
			return nil, fmt.Errorf("reading %s: snippet %q has no body", path, snippet.Name)
		}
	}
	return snippets, nil
}