// 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" "rickub.com/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, ", ") }