// 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 }