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

🛟 Updated. 28d5985 · on main · k33g · 4h ago
tools.go · 271 lines · 8.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
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
// Package tools reads the commands a project keeps in its editor directory's
// tools.toml — its formatter, its linter, its build, its tests — so that a menu
// can offer them.
//
// It knows nothing about menus or terminals: it reads a file and returns
// values, which is what lets it be tested by writing files and reading them
// back.
//
//	list, err := tools.Load(p, ".")
//	if err != nil {
//		return err
//	}
//	for _, tool := range list.Tools() {
//		fmt.Println(tool.Name, "→", tool.Command)
//	}
package tools

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

	"github.com/BurntSushi/toml"

	"codeberg.org/turbo-editors/turbo-core/profile"
)

// FileName is the file a project keeps its commands in. It sits in the editor's
// own directory, the same one settings.toml and snippets.toml live in.
const FileName = "tools.toml"

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

// Output says where a command's output goes.
//
// It is a string rather than a number so the file reads as prose, and an
// unknown value is refused rather than quietly falling back — a typo in
// "termnial" should say so, not silently change where the output went.
type Output string

// The places a command's output can go.
const (
	// OutputPopup shows it in a dialog that fills in as the command runs. It
	// is the default: most commands say something short and are read once.
	OutputPopup Output = "popup"
	// OutputTerminal runs the command in a terminal window, which is what a
	// program that reads the keyboard or takes a long time wants.
	OutputTerminal Output = "terminal"
	// OutputEditor puts the finished output in an editing window, so it can be
	// searched with Ctrl-F and kept beside the code.
	OutputEditor Output = "editor"
)

// outputs is every value the file may name, and is what an unknown one is
// reported against.
var outputs = []Output{OutputPopup, OutputTerminal, OutputEditor}

// Tool is one command the editor's toolchain menu offers.
type Tool struct {
	// Name is what the menu shows.
	Name string
	// Command is the shell command to run. It goes to `sh -c`, so pipes and
	// `&&` work and one entry can be a whole sequence.
	//
	// A `{{label}}` in it is a value the editor asks for before running — see
	// Placeholders and Fill.
	Command string
	// Output says where its output goes. Empty means OutputPopup.
	Output Output
	// Menu is the menu it appears in. A tool that names none is put into the
	// editor's own toolchain menu when the file is loaded, so this is never
	// empty on a Tool that came out of Load.
	Menu string
}

// Where returns the tool's output destination, filling in the default.
//
//	switch tool.Where() {
//	case tools.OutputTerminal:
//		runInATerminalWindow(tool.Command)
//	}
func (t Tool) Where() Output {
	if t.Output == "" {
		return OutputPopup
	}
	return t.Output
}

// List is a project's tools, in the order they were read.
//
// The order is the file's, so someone reordering the file sees the menu
// reorder.
type List struct {
	tools []Tool
	// defaultMenu is the editor's own toolchain menu — "Go", "Rust" — which a
	// tool that names no menu was put into. It is remembered because that menu
	// heads MenuNames whether or not any tool asked for it.
	defaultMenu string
}

// DefaultMenu returns the menu a tool with no menu of its own was put into.
func (l List) DefaultMenu() string { return l.defaultMenu }

// Tools returns the commands, in file order.
func (l List) Tools() []Tool { return l.tools }

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

// MenuNames returns the menus the tools ask for, in the order their first tool
// appears in the file.
//
// The editor's own toolchain menu is always first, whether or not any tool
// named it: the menu that creates the tools file has to exist even when there
// is no file.
//
//	for _, name := range list.MenuNames() {
//		addMenu(name, list.In(name))
//	}
func (l List) MenuNames() []string {
	names := []string{l.defaultMenu}
	seen := map[string]bool{l.defaultMenu: true}

	for _, tool := range l.tools {
		if !seen[tool.Menu] {
			seen[tool.Menu] = true
			names = append(names, tool.Menu)
		}
	}
	return names
}

// In returns the tools belonging to one menu, in file order.
func (l List) In(menu string) []Tool {
	var out []Tool
	for _, tool := range l.tools {
		if tool.Menu == menu {
			out = append(out, tool)
		}
	}
	return out
}

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

// Exists reports whether the project has a tools file that can be read.
//
// A directory in its place counts as absent: it is not something Load could
// have read.
func Exists(p profile.Profile, projectDir string) bool {
	info, err := os.Stat(Path(p, projectDir))
	return err == nil && info.Mode().IsRegular()
}

// Load reads a project's tools.
//
// There is no user-level tools file, unlike snippets. Snippets are your habits
// and should follow you between projects; a project's tools belong to its own
// toolchain, and a global one would offer `go build` in a Rust repository.
//
// A missing file is not an error — a project that has never asked for one has
// none. A file that is present but unreadable *is* an error, so a typo is
// reported rather than silently leaving the menu empty.
//
// A tool that names no menu is put into the editor's own toolchain menu here,
// so that nothing downstream has to remember what the default was.
//
//	list, err := tools.Load(p, ".")
func Load(p profile.Profile, projectDir string) (List, error) {
	path := Path(p, projectDir)
	defaultMenu := DefaultMenuName(p)

	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return List{defaultMenu: defaultMenu}, nil
		}
		return List{defaultMenu: defaultMenu}, fmt.Errorf("reading %s: %w", path, err)
	}

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

	for i := range f.Tool {
		if f.Tool[i].Menu == "" {
			f.Tool[i].Menu = defaultMenu
		}
	}
	return List{tools: f.Tool, defaultMenu: defaultMenu}, nil
}

// DefaultMenuName is the plain name of the editor's toolchain menu: the
// profile's label with its hot-key markers taken out.
//
// A tools file writes the plain name — `menu = "Go"` — because a hot key is the
// editor's to assign, not the file's.
//
//	tools.DefaultMenuName(profile.Profile{ToolsMenu: "~G~o"})  // "Go"
//	tools.DefaultMenuName(profile.Profile{ToolsMenu: "Rus~t~"}) // "Rust"
func DefaultMenuName(p profile.Profile) string {
	return strings.ReplaceAll(p.ToolsMenu, "~", "")
}

// file mirrors the tools file's structure.
type file struct {
	Tool []Tool `toml:"tool"`
}

// check refuses a tool that could not be shown, could not be run, whose output
// has nowhere to go, or whose command asks for a value it does not name.
//
// One with no name has nothing to put in a menu; one with no command has
// nothing to do; one naming an output that does not exist is a typo whose
// silent correction would send the output somewhere the file did not ask for;
// and a half-typed placeholder would otherwise reach the shell with its braces
// still in it.
func check(list []Tool, path string) error {
	for i, tool := range list {
		if tool.Name == "" {
			return fmt.Errorf("reading %s: tool %d has no name", path, i+1)
		}
		if tool.Command == "" {
			return fmt.Errorf("reading %s: tool %q has no command", path, tool.Name)
		}
		if !knownOutput(tool.Output) {
			return fmt.Errorf("reading %s: tool %q has output %q; want one of %s",
				path, tool.Name, tool.Output, outputNames())
		}
		if err := checkPlaceholders(tool, path); err != nil {
			return err
		}
	}
	return nil
}

// knownOutput reports whether an output value is one this package understands.
// The empty string is, and means the default.
func knownOutput(output Output) bool {
	if output == "" {
		return true
	}
	for _, known := range outputs {
		if output == known {
			return true
		}
	}
	return false
}

// outputNames lists the valid outputs for an error message.
func outputNames() string {
	names := make([]string, len(outputs))
	for i, output := range outputs {
		names[i] = string(output)
	}
	return strings.Join(names, ", ")
}