| 🛟 Updated. 28d5985 k33g 15h ago | 1 | package snippets |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "codeberg.org/turbo-editors/turbo-core/profile" |
| 11 | ) |
| 12 | |
| 13 | // project builds a project directory holding a snippets file. |
| 14 | func project(t *testing.T, contents string) string { |
| 15 | t.Helper() |
| 16 | |
| 17 | dir := t.TempDir() |
| 18 | if contents == "" { |
| 19 | return dir |
| 20 | } |
| 21 | path := ProjectPath(testProfile(), dir) |
| 22 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { |
| 23 | t.Fatalf("creating the snippets directory: %v", err) |
| 24 | } |
| 25 | if err := os.WriteFile(path, []byte(contents), 0o644); err != nil { |
| 26 | t.Fatalf("writing the snippets file: %v", err) |
| 27 | } |
| 28 | return dir |
| 29 | } |
| 30 | |
| 31 | // testProfile is a fictional editor. Its template is a real snippets file, but |
| 32 | // a small one: what is in Turbo Go's template is Turbo Go's own test. |
| 33 | func testProfile() profile.Profile { |
| 34 | return profile.Profile{ |
| 35 | Name: "Turbo Test", |
| 36 | Slug: "turbo-test", |
| 37 | Templates: profile.Templates{Snippets: testSnippetsTemplate}, |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | // testSnippetsTemplate takes the name of the ungrouped group and the user's own |
| 42 | // snippets path, in that order, as profile.Templates says it must. |
| 43 | const testSnippetsTemplate = `# turbo-test snippets. |
| 44 | # |
| 45 | # A snippet with no group goes into %s. |
| 46 | # Your own snippets live in: |
| 47 | # %s |
| 48 | |
| 49 | [[snippet]] |
| 50 | name = "guard" |
| 51 | group = "Test" |
| 52 | languages = ["test"] |
| 53 | body = """ |
| 54 | if broken { |
| 55 | return |
| 56 | }""" |
| 57 | |
| 58 | [[snippet]] |
| 59 | name = "TODO" |
| 60 | body = "TODO: " |
| 61 | ` |
| 62 | |
| 63 | // noUserSnippets points the user's snippets at an empty directory, so a test |
| 64 | // never reads whoever is running it. |
| 65 | func noUserSnippets(t *testing.T) { |
| 66 | t.Helper() |
| 67 | t.Setenv(testProfile().SnippetDirEnvVar(), t.TempDir()) |
| 68 | } |
| 69 | |
| 70 | // userSnippets writes a user-level snippets file and points the package at it. |
| 71 | func userSnippets(t *testing.T, contents string) { |
| 72 | t.Helper() |
| 73 | |
| 74 | dir := t.TempDir() |
| 75 | t.Setenv(testProfile().SnippetDirEnvVar(), dir) |
| 76 | if err := os.WriteFile(filepath.Join(dir, FileName), []byte(contents), 0o644); err != nil { |
| 77 | t.Fatalf("writing the user snippets file: %v", err) |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | // load reads a project's snippets, failing the test if it cannot. |
| 82 | func load(t *testing.T, dir string) List { |
| 83 | t.Helper() |
| 84 | |
| 85 | list, err := Load(testProfile(), dir) |
| 86 | if err != nil { |
| 87 | t.Fatalf("Load(testProfile(), %q) error = %v", dir, err) |
| 88 | } |
| 89 | return list |
| 90 | } |
| 91 | |
| 92 | // summary renders the groups as "Group/name" strings, in order. |
| 93 | func summary(groups []Group) []string { |
| 94 | var out []string |
| 95 | for _, group := range groups { |
| 96 | for _, snippet := range group.Snippets { |
| 97 | out = append(out, group.Name+"/"+snippet.Name) |
| 98 | } |
| 99 | } |
| 100 | return out |
| 101 | } |
| 102 | |
| 103 | // wantGroups compares the grouped snippets against what they should be. |
| 104 | func wantGroups(t *testing.T, groups []Group, want ...string) { |
| 105 | t.Helper() |
| 106 | |
| 107 | got := summary(groups) |
| 108 | if len(got) != len(want) { |
| 109 | t.Fatalf("got\n %v\nwant\n %v", got, want) |
| 110 | } |
| 111 | for i := range want { |
| 112 | if got[i] != want[i] { |
| 113 | t.Errorf("entry %d = %q, want %q (all: %v)", i, got[i], want[i], got) |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestLoadReadsAProjectsSnippets(t *testing.T) { |
| 119 | noUserSnippets(t) |
| 120 | dir := project(t, ` |
| 121 | [[snippet]] |
| 122 | name = "if err" |
| 123 | group = "Go" |
| 124 | body = "if err != nil {}" |
| 125 | |
| 126 | [[snippet]] |
| 127 | name = "TODO" |
| 128 | body = "TODO: " |
| 129 | `) |
| 130 | |
| 131 | list := load(t, dir) |
| 132 | |
| 133 | if list.Len() != 2 { |
| 134 | t.Fatalf("Len() = %d, want 2", list.Len()) |
| 135 | } |
| 136 | wantGroups(t, list.Groups("go"), "Go/if err", "General/TODO") |
| 137 | } |
| 138 | |
| 139 | func TestASnippetWithNoGroupGoesIntoTheGeneralOne(t *testing.T) { |
| 140 | noUserSnippets(t) |
| 141 | dir := project(t, "[[snippet]]\nname = \"x\"\nbody = \"y\"\n") |
| 142 | |
| 143 | wantGroups(t, load(t, dir).Groups(""), ungroupedName+"/x") |
| 144 | } |
| 145 | |
| 146 | func TestGroupsKeepTheOrderOfTheFile(t *testing.T) { |
| 147 | // The menu should match the file, so someone reordering the file sees the |
| 148 | // menu reorder. |
| 149 | noUserSnippets(t) |
| 150 | dir := project(t, ` |
| 151 | [[snippet]] |
| 152 | name = "b" |
| 153 | group = "Second" |
| 154 | body = "x" |
| 155 | |
| 156 | [[snippet]] |
| 157 | name = "a" |
| 158 | group = "First" |
| 159 | body = "x" |
| 160 | |
| 161 | [[snippet]] |
| 162 | name = "c" |
| 163 | group = "Second" |
| 164 | body = "x" |
| 165 | `) |
| 166 | |
| 167 | wantGroups(t, load(t, dir).Groups(""), "Second/b", "Second/c", "First/a") |
| 168 | } |
| 169 | |
| 170 | func TestALanguageFiltersTheSnippetsOffered(t *testing.T) { |
| 171 | noUserSnippets(t) |
| 172 | dir := project(t, ` |
| 173 | [[snippet]] |
| 174 | name = "go thing" |
| 175 | group = "Go" |
| 176 | languages = ["go"] |
| 177 | body = "x" |
| 178 | |
| 179 | [[snippet]] |
| 180 | name = "shell thing" |
| 181 | group = "Shell" |
| 182 | languages = ["bash"] |
| 183 | body = "x" |
| 184 | |
| 185 | [[snippet]] |
| 186 | name = "anywhere" |
| 187 | body = "x" |
| 188 | `) |
| 189 | list := load(t, dir) |
| 190 | |
| 191 | wantGroups(t, list.Groups("go"), "Go/go thing", "General/anywhere") |
| 192 | wantGroups(t, list.Groups("bash"), "Shell/shell thing", "General/anywhere") |
| 193 | wantGroups(t, list.Groups("markdown"), "General/anywhere") |
| 194 | } |
| 195 | |
| 196 | func TestAGroupLeftEmptyByFilteringDoesNotAppear(t *testing.T) { |
| 197 | noUserSnippets(t) |
| 198 | dir := project(t, "[[snippet]]\nname = \"x\"\ngroup = \"Go\"\nlanguages = [\"go\"]\nbody = \"y\"\n") |
| 199 | |
| 200 | if groups := load(t, dir).Groups("markdown"); len(groups) != 0 { |
| 201 | t.Errorf("Groups() = %v, want no group at all", summary(groups)) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | func TestASnippetForSeveralLanguagesAppliesToEach(t *testing.T) { |
| 206 | noUserSnippets(t) |
| 207 | dir := project(t, "[[snippet]]\nname = \"x\"\nlanguages = [\"go\", \"bash\"]\nbody = \"y\"\n") |
| 208 | list := load(t, dir) |
| 209 | |
| 210 | for _, language := range []string{"go", "bash"} { |
| 211 | if got := len(list.Groups(language)); got != 1 { |
| 212 | t.Errorf("Groups(%q) gave %d groups, want 1", language, got) |
| 213 | } |
| 214 | } |
| 215 | if got := len(list.Groups("html")); got != 0 { |
| 216 | t.Errorf("Groups(\"html\") gave %d groups, want none", got) |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | func TestTheUsersSnippetsAndTheProjectsAreBothOffered(t *testing.T) { |
| 221 | userSnippets(t, "[[snippet]]\nname = \"mine\"\ngroup = \"Mine\"\nbody = \"x\"\n") |
| 222 | dir := project(t, "[[snippet]]\nname = \"theirs\"\ngroup = \"Theirs\"\nbody = \"x\"\n") |
| 223 | |
| 224 | // The user's come first, so a project adds to what you already have. |
| 225 | wantGroups(t, load(t, dir).Groups(""), "Mine/mine", "Theirs/theirs") |
| 226 | } |
| 227 | |
| 228 | func TestTheProjectWinsWhenANameClashes(t *testing.T) { |
| 229 | // The project's file is the more specific statement of the two. |
| 230 | userSnippets(t, "[[snippet]]\nname = \"header\"\ngroup = \"Go\"\nbody = \"mine\"\n") |
| 231 | dir := project(t, "[[snippet]]\nname = \"header\"\ngroup = \"Go\"\nbody = \"theirs\"\n") |
| 232 | |
| 233 | groups := load(t, dir).Groups("") |
| 234 | if len(groups) != 1 || len(groups[0].Snippets) != 1 { |
| 235 | t.Fatalf("got %v, want one snippet", summary(groups)) |
| 236 | } |
| 237 | if got := groups[0].Snippets[0].Body; got != "theirs" { |
| 238 | t.Errorf("body = %q, want the project's", got) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | func TestTheSameNameInADifferentGroupIsADifferentSnippet(t *testing.T) { |
| 243 | userSnippets(t, "[[snippet]]\nname = \"header\"\ngroup = \"Go\"\nbody = \"a\"\n") |
| 244 | dir := project(t, "[[snippet]]\nname = \"header\"\ngroup = \"Shell\"\nbody = \"b\"\n") |
| 245 | |
| 246 | wantGroups(t, load(t, dir).Groups(""), "Go/header", "Shell/header") |
| 247 | } |
| 248 | |
| 249 | func TestAProjectWithNoSnippetsFileIsNotAnError(t *testing.T) { |
| 250 | noUserSnippets(t) |
| 251 | |
| 252 | list := load(t, t.TempDir()) |
| 253 | |
| 254 | if list.Len() != 0 { |
| 255 | t.Errorf("Len() = %d, want 0", list.Len()) |
| 256 | } |
| 257 | if got := len(list.Groups("go")); got != 0 { |
| 258 | t.Errorf("Groups() gave %d groups", got) |
| 259 | } |
| 260 | } |
| 261 | |
| 262 | func TestAFileThatIsNotTOMLIsReported(t *testing.T) { |
| 263 | // A typo must be reported rather than silently dropping every snippet. |
| 264 | noUserSnippets(t) |
| 265 | dir := project(t, "[[snippet]\nname = ") |
| 266 | |
| 267 | _, err := Load(testProfile(), dir) |
| 268 | if err == nil { |
| 269 | t.Fatal("Load(testProfile()) accepted a file that is not TOML") |
| 270 | } |
| 271 | if !strings.Contains(err.Error(), ProjectPath(testProfile(), dir)) { |
| 272 | t.Errorf("the error does not name the file: %v", err) |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | func TestASnippetWithNoNameOrNoBodyIsRefused(t *testing.T) { |
| 277 | noUserSnippets(t) |
| 278 | |
| 279 | for name, contents := range map[string]string{ |
| 280 | "no name": "[[snippet]]\nbody = \"x\"\n", |
| 281 | "no body": "[[snippet]]\nname = \"x\"\n", |
| 282 | } { |
| 283 | t.Run(name, func(t *testing.T) { |
| 284 | if _, err := Load(testProfile(), project(t, contents)); err == nil { |
| 285 | t.Errorf("Load(testProfile()) accepted a snippet with %s", name) |
| 286 | } |
| 287 | }) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | func TestADirectoryWhereTheFileGoesCountsAsAbsent(t *testing.T) { |
| 292 | noUserSnippets(t) |
| 293 | dir := t.TempDir() |
| 294 | if err := os.MkdirAll(ProjectPath(testProfile(), dir), 0o755); err != nil { |
| 295 | t.Fatalf("creating a directory where the file goes: %v", err) |
| 296 | } |
| 297 | |
| 298 | if Exists(testProfile(), dir) { |
| 299 | t.Error("Exists(testProfile()) called a directory a snippets file") |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | func TestCreateWritesAFileThatLoadsBack(t *testing.T) { |
| 304 | noUserSnippets(t) |
| 305 | dir := t.TempDir() |
| 306 | |
| 307 | path, err := Create(testProfile(), dir) |
| 308 | if err != nil { |
| 309 | t.Fatalf("Create(testProfile()) error = %v", err) |
| 310 | } |
| 311 | if path != ProjectPath(testProfile(), dir) { |
| 312 | t.Errorf("Create(testProfile()) returned %q, want %q", path, ProjectPath(testProfile(), dir)) |
| 313 | } |
| 314 | if !Exists(testProfile(), dir) { |
| 315 | t.Fatal("Create(testProfile()) reported success but wrote no file") |
| 316 | } |
| 317 | |
| 318 | list := load(t, dir) |
| 319 | if list.Len() == 0 { |
| 320 | t.Fatal("the created file holds no snippets") |
| 321 | } |
| 322 | // The examples in it must be usable, not just parseable. |
| 323 | for _, group := range list.Groups("go") { |
| 324 | for _, snippet := range group.Snippets { |
| 325 | if snippet.Name == "" || snippet.Body == "" { |
| 326 | t.Errorf("the created file holds an unusable snippet %+v", snippet) |
| 327 | } |
| 328 | } |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | func TestCreateRefusesToOverwriteAnExistingFile(t *testing.T) { |
| 333 | noUserSnippets(t) |
| 334 | original := "[[snippet]]\nname = \"mine\"\nbody = \"hand written\"\n" |
| 335 | dir := project(t, original) |
| 336 | |
| 337 | _, err := Create(testProfile(), dir) |
| 338 | if !errors.Is(err, ErrExists) { |
| 339 | t.Fatalf("Create(testProfile()) error = %v, want ErrExists", err) |
| 340 | } |
| 341 | if got := readFile(t, ProjectPath(testProfile(), dir)); got != original { |
| 342 | t.Errorf("the existing file was changed:\n%s", got) |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | func TestUserDirFollowsTheEnvironmentVariable(t *testing.T) { |
| 347 | t.Setenv(testProfile().SnippetDirEnvVar(), "/somewhere") |
| 348 | |
| 349 | if got := UserDir(testProfile()); got != "/somewhere" { |
| 350 | t.Errorf("UserDir(testProfile()) = %q", got) |
| 351 | } |
| 352 | if got, want := UserPath(testProfile()), filepath.Join("/somewhere", FileName); got != want { |
| 353 | t.Errorf("UserPath(testProfile()) = %q, want %q", got, want) |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | // readFile returns a file's contents. |
| 358 | func readFile(t *testing.T, path string) string { |
| 359 | t.Helper() |
| 360 | |
| 361 | data, err := os.ReadFile(path) |
| 362 | if err != nil { |
| 363 | t.Fatalf("reading %s: %v", path, err) |
| 364 | } |
| 365 | return string(data) |
| 366 | } |
| 367 | |
| 368 | func TestCreateWritesTheProfilesTemplateWithItsBlanksFilledIn(t *testing.T) { |
| 369 | // Two things the template asks for and cannot know itself: the name of the |
| 370 | // group an ungrouped snippet falls into, and where the user's own file is. |
| 371 | // What is *in* Turbo Go's template is Turbo Go's own test. |
| 372 | userDir := t.TempDir() |
| 373 | t.Setenv(testProfile().SnippetDirEnvVar(), userDir) |
| 374 | dir := t.TempDir() |
| 375 | |
| 376 | if _, err := Create(testProfile(), dir); err != nil { |
| 377 | t.Fatalf("Create() error = %v", err) |
| 378 | } |
| 379 | |
| 380 | contents := readFile(t, ProjectPath(testProfile(), dir)) |
| 381 | for _, want := range []string{ungroupedName, UserPath(testProfile())} { |
| 382 | if !strings.Contains(contents, want) { |
| 383 | t.Errorf("the created file never mentions %q:\n%s", want, contents) |
| 384 | } |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | func TestCreateSaysSoWhenThereIsNowhereForTheUsersOwnFile(t *testing.T) { |
| 389 | // A system with no configuration directory leaves UserPath empty, and a |
| 390 | // comment reading "Your own snippets go in:" followed by nothing is worse |
| 391 | // than one that says there is nowhere. |
| 392 | t.Setenv(testProfile().SnippetDirEnvVar(), "") |
| 393 | t.Setenv("XDG_CONFIG_HOME", "") |
| 394 | t.Setenv("HOME", "") |
| 395 | if UserPath(testProfile()) != "" { |
| 396 | t.Skip("this system still reports a configuration directory") |
| 397 | } |
| 398 | dir := t.TempDir() |
| 399 | |
| 400 | if _, err := Create(testProfile(), dir); err != nil { |
| 401 | t.Fatalf("Create() error = %v", err) |
| 402 | } |
| 403 | |
| 404 | contents := readFile(t, ProjectPath(testProfile(), dir)) |
| 405 | if !strings.Contains(contents, "no configuration directory") { |
| 406 | t.Errorf("the created file does not say there is nowhere to put user snippets:\n%s", contents) |
| 407 | } |
| 408 | } |