| 🛟 Updated. 28d5985 k33g 5h ago | 1 | package syntax |
| 2 | |
| 3 | import ( |
| 4 | "testing" |
| 5 | |
| 6 | "codeberg.org/turbo-editors/turbo-core/theme" |
| 7 | ) |
| 8 | |
| 9 | // classAt returns the class covering a rune column on a line, and whether any |
| 10 | // span covers it at all. It is how nearly every test below asks its question. |
| 11 | func classAt(spans [][]Span, line, col int) (Class, bool) { |
| 12 | if line < 0 || line >= len(spans) { |
| 13 | return 0, false |
| 14 | } |
| 15 | for _, s := range spans[line] { |
| 16 | if col >= s.Start && col < s.End { |
| 17 | return s.Class, true |
| 18 | } |
| 19 | } |
| 20 | return 0, false |
| 21 | } |
| 22 | |
| 23 | func TestLanguageOfRecognisesAFileByItsExtension(t *testing.T) { |
| 24 | tests := map[string]Language{ |
| 25 | "settings.toml": LanguageTOML, |
| 26 | ".turbo-go/settings.toml": LanguageTOML, |
| 27 | "Theme.TOML": LanguageTOML, |
| 28 | "README.md": LanguageMarkdown, |
| 29 | "notes.markdown": LanguageMarkdown, |
| 30 | "app.js": LanguageJavaScript, |
| 31 | "module.mjs": LanguageJavaScript, |
| 32 | "legacy.cjs": LanguageJavaScript, |
| 33 | "index.html": LanguageHTML, |
| 34 | "old.HTM": LanguageHTML, |
| 35 | "install.sh": LanguageBash, |
| 36 | "script.bash": LanguageBash, |
| 37 | "prompt.zsh": LanguageBash, |
| 38 | "Makefile": LanguageNone, |
| 39 | "go.mod": LanguageNone, |
| 40 | "noextension": LanguageNone, |
| 41 | "weird.md.backup": LanguageNone, |
| 42 | "": LanguageNone, |
| 43 | } |
| 44 | |
| 45 | for path, want := range tests { |
| 46 | if got := LanguageOf(path, ""); got != want { |
| 47 | t.Errorf("LanguageOf(%q, \"\") = %v, want %v", path, got, want) |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestAShebangMakesAFileWithNoExtensionAScript(t *testing.T) { |
| 53 | shebangs := []string{ |
| 54 | "#!/bin/sh", |
| 55 | "#!/bin/bash", |
| 56 | "#!/usr/bin/env bash", |
| 57 | "#!/usr/bin/env -S bash -e", |
| 58 | "#! /bin/zsh", |
| 59 | "#!/usr/bin/dash", |
| 60 | "#!/bin/ksh", |
| 61 | } |
| 62 | |
| 63 | for _, first := range shebangs { |
| 64 | t.Run(first, func(t *testing.T) { |
| 65 | if got := LanguageOf("configure", first); got != LanguageBash { |
| 66 | t.Errorf("LanguageOf(\"configure\", %q) = %v, want bash", first, got) |
| 67 | } |
| 68 | }) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | func TestAShebangForSomethingElseIsNotAScript(t *testing.T) { |
| 73 | notShells := []string{ |
| 74 | "#!/usr/bin/env python3", |
| 75 | "#!/usr/bin/perl", |
| 76 | "#!/usr/bin/env node", |
| 77 | "# not a shebang at all, just a comment about bash", |
| 78 | "", |
| 79 | "#!", |
| 80 | } |
| 81 | |
| 82 | for _, first := range notShells { |
| 83 | t.Run(first, func(t *testing.T) { |
| 84 | if got := LanguageOf("configure", first); got != LanguageNone { |
| 85 | t.Errorf("LanguageOf(\"configure\", %q) = %v, want none", first, got) |
| 86 | } |
| 87 | }) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | func TestTheExtensionWinsOverTheFirstLine(t *testing.T) { |
| 92 | // A .md file whose first line happens to be a shebang is still Markdown. |
| 93 | if got := LanguageOf("README.md", "#!/bin/sh"); got != LanguageMarkdown { |
| 94 | t.Errorf("LanguageOf() = %v, want markdown", got) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestAnUncolouredLanguageStillGivesOneEntryPerLine(t *testing.T) { |
| 99 | // The editor indexes the result by line number without checking, so a file |
| 100 | // this package cannot colour must still be as long as the file. |
| 101 | spans := Highlight(LanguageNone, "one\ntwo\nthree\n") |
| 102 | |
| 103 | if len(spans) != 4 { |
| 104 | t.Fatalf("Highlight() returned %d lines, want 4", len(spans)) |
| 105 | } |
| 106 | for i, line := range spans { |
| 107 | if len(line) != 0 { |
| 108 | t.Errorf("line %d was coloured: %v", i, line) |
| 109 | } |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | func TestLanguageString(t *testing.T) { |
| 114 | tests := map[Language]string{ |
| 115 | LanguageTOML: "toml", LanguageMarkdown: "markdown", |
| 116 | LanguageJavaScript: "javascript", LanguageHTML: "html", LanguageBash: "bash", |
| 117 | // A language this package never heard of prints its own name, because |
| 118 | // that name is what a snippets file writes down. |
| 119 | Language("rust"): "rust", |
| 120 | LanguageNone: "none", |
| 121 | } |
| 122 | for language, want := range tests { |
| 123 | if got := language.String(); got != want { |
| 124 | t.Errorf("%q.String() = %q, want %q", string(language), got, want) |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | func TestClassStyleKeysAreTheThemeKeys(t *testing.T) { |
| 130 | tests := map[Class]string{ |
| 131 | ClassKeyword: theme.KeySyntaxKeyword, |
| 132 | ClassString: theme.KeySyntaxString, |
| 133 | ClassComment: theme.KeySyntaxComment, |
| 134 | ClassPunctuation: theme.KeySyntaxPunctuation, |
| 135 | } |
| 136 | |
| 137 | for class, want := range tests { |
| 138 | if got := class.StyleKey(); got != want { |
| 139 | t.Errorf("%v.StyleKey() = %q, want %q", class, got, want) |
| 140 | } |
| 141 | } |
| 142 | if got := Class(200).StyleKey(); got != theme.KeySyntaxIdentifier { |
| 143 | t.Errorf("an out-of-range class gives %q, want the identifier key", got) |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | func TestClassString(t *testing.T) { |
| 148 | if got := ClassKeyword.String(); got != "keyword" { |
| 149 | t.Errorf("ClassKeyword.String() = %q", got) |
| 150 | } |
| 151 | if got := Class(200).String(); got != "unknown" { |
| 152 | t.Errorf("an out-of-range class prints %q, want %q", got, "unknown") |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // The registry is the extension point an editor built on this library uses to |
| 157 | // add its own language. The tests below drive it exactly as Turbo Go and Turbo |
| 158 | // Rust do, and restore the registry afterwards so they leave nothing behind for |
| 159 | // the tests in the other files here. |
| 160 | |
| 161 | // registerForTest adds a language and removes it again when the test ends. |
| 162 | func registerForTest(t *testing.T, d Definition) { |
| 163 | t.Helper() |
| 164 | previous, existed := registry[d.Language] |
| 165 | Register(d) |
| 166 | t.Cleanup(func() { |
| 167 | if existed { |
| 168 | registry[d.Language] = previous |
| 169 | return |
| 170 | } |
| 171 | delete(registry, d.Language) |
| 172 | }) |
| 173 | } |
| 174 | |
| 175 | // wholeLineAs colours every line of a document in one class, which is enough to |
| 176 | // tell "this scanner ran" from "it did not". |
| 177 | func wholeLineAs(class Class) func(string) [][]Span { |
| 178 | return func(src string) [][]Span { |
| 179 | return ScanLines(src, func(line []rune, carry struct{}) ([]Span, struct{}) { |
| 180 | s := NewLineScanner(line) |
| 181 | s.TakeRest(class) |
| 182 | return s.Spans(), carry |
| 183 | }) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func TestARegisteredLanguageIsRecognisedByItsExtension(t *testing.T) { |
| 188 | registerForTest(t, Definition{ |
| 189 | Language: "rust", |
| 190 | Extensions: []string{".rs"}, |
| 191 | Highlight: wholeLineAs(ClassKeyword), |
| 192 | }) |
| 193 | |
| 194 | if got := LanguageOf("main.rs", ""); got != "rust" { |
| 195 | t.Errorf("LanguageOf(\"main.rs\", \"\") = %q, want rust", got) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | func TestARegisteredLanguageIsColouredByItsOwnScanner(t *testing.T) { |
| 200 | registerForTest(t, Definition{ |
| 201 | Language: "rust", |
| 202 | Extensions: []string{".rs"}, |
| 203 | Highlight: wholeLineAs(ClassKeyword), |
| 204 | }) |
| 205 | |
| 206 | class, ok := classAt(Highlight("rust", "fn main() {}"), 0, 0) |
| 207 | if !ok || class != ClassKeyword { |
| 208 | t.Errorf("Highlight(\"rust\", …) gave %v (covered: %v), want keyword", class, ok) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | func TestARegisteredLanguageIsRecognisedByItsShebang(t *testing.T) { |
| 213 | registerForTest(t, Definition{ |
| 214 | Language: "python", |
| 215 | Extensions: []string{".py"}, |
| 216 | Shebangs: []string{"python3"}, |
| 217 | Highlight: wholeLineAs(ClassComment), |
| 218 | }) |
| 219 | |
| 220 | if got := LanguageOf("script", "#!/usr/bin/env python3"); got != "python" { |
| 221 | t.Errorf("LanguageOf(\"script\", …) = %q, want python", got) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestRegisteringALanguageTwiceReplacesIt(t *testing.T) { |
| 226 | // An editor overriding a built-in is the point: the second registration is |
| 227 | // the more specific statement, so it wins. |
| 228 | registerForTest(t, Definition{ |
| 229 | Language: LanguageMarkdown, |
| 230 | Extensions: []string{".md"}, |
| 231 | Highlight: wholeLineAs(ClassNumber), |
| 232 | }) |
| 233 | |
| 234 | class, ok := classAt(Highlight(LanguageMarkdown, "# Title"), 0, 0) |
| 235 | if !ok || class != ClassNumber { |
| 236 | t.Errorf("Highlight(markdown, …) gave %v (covered: %v), want the replacement scanner", class, ok) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func TestRegisteredListsWhatThisPackageColours(t *testing.T) { |
| 241 | got := Registered() |
| 242 | |
| 243 | // Presence rather than an exact list: the examples in this package's test |
| 244 | // binary register a language of their own, and a test that counted would |
| 245 | // pass or fail on the order the two happened to run in. |
| 246 | for _, want := range []Language{LanguageBash, LanguageHTML, LanguageJavaScript, LanguageMarkdown, LanguageTOML} { |
| 247 | if !containsLanguage(got, want) { |
| 248 | t.Errorf("Registered() = %v, want it to include %q", got, want) |
| 249 | } |
| 250 | } |
| 251 | for i := 1; i < len(got); i++ { |
| 252 | if got[i-1] >= got[i] { |
| 253 | t.Fatalf("Registered() = %v, want it sorted", got) |
| 254 | } |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // containsLanguage reports whether a language is in a list. |
| 259 | func containsLanguage(list []Language, want Language) bool { |
| 260 | for _, language := range list { |
| 261 | if language == want { |
| 262 | return true |
| 263 | } |
| 264 | } |
| 265 | return false |
| 266 | } |
| 267 | |
| 268 | func TestALanguageWithNoScannerStillGivesOneEntryPerLine(t *testing.T) { |
| 269 | // A Definition may name a language without colouring it, which is what an |
| 270 | // editor does when it wants a file recognised but has no scanner yet. |
| 271 | registerForTest(t, Definition{Language: "plain", Extensions: []string{".plain"}}) |
| 272 | |
| 273 | spans := Highlight("plain", "one\ntwo\n") |
| 274 | |
| 275 | if len(spans) != 3 { |
| 276 | t.Fatalf("Highlight() returned %d lines, want 3", len(spans)) |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | func TestTheEightBuiltInLanguagesAreRegistered(t *testing.T) { |
| 281 | // The five the library started with, plus the three ticket 8 added. A |
| 282 | // language dropped by accident would otherwise show up as a file quietly |
| 283 | // going plain, which nothing else here notices. |
| 284 | got := Registered() |
| 285 | |
| 286 | for _, want := range []Language{ |
| 287 | LanguageBash, LanguageDockerfile, LanguageHTML, LanguageJavaScript, |
| 288 | LanguageMarkdown, LanguageTOML, LanguageXML, LanguageYAML, |
| 289 | } { |
| 290 | if !containsLanguage(got, want) { |
| 291 | t.Errorf("Registered() = %v, want it to include %q", got, want) |
| 292 | } |
| 293 | } |
| 294 | } |
| 295 | |
| 296 | func TestEveryBuiltInLanguageActuallyColoursSomething(t *testing.T) { |
| 297 | // A Definition with a nil Highlight registers a name and colours nothing, |
| 298 | // which looks identical to a scanner that is simply quiet. |
| 299 | samples := map[Language]string{ |
| 300 | LanguageBash: "if true; then echo hi; fi", |
| 301 | LanguageDockerfile: "FROM alpine", |
| 302 | LanguageHTML: "<p>hi</p>", |
| 303 | LanguageJavaScript: "const a = 1;", |
| 304 | LanguageMarkdown: "# Title", |
| 305 | LanguageTOML: `a = "b"`, |
| 306 | LanguageXML: "<a/>", |
| 307 | LanguageYAML: "a: 1", |
| 308 | } |
| 309 | |
| 310 | for language, sample := range samples { |
| 311 | t.Run(string(language), func(t *testing.T) { |
| 312 | if spans := Highlight(language, sample); len(spans[0]) == 0 { |
| 313 | t.Errorf("Highlight(%q, %q) coloured nothing", language, sample) |
| 314 | } |
| 315 | }) |
| 316 | } |
| 317 | } |