package moonbitlang import ( "fmt" "os" "strings" "testing" "rickub.com/turbo-editors/turbo-core/settings" "rickub.com/turbo-editors/turbo-core/snippets" "rickub.com/turbo-editors/turbo-core/syntax" "rickub.com/turbo-editors/turbo-core/tools" ) // The starter files Turbo MoonBit writes are the one part of a project's // .turbo-moonbit directory that is about MoonBit, so this is where what is *in* // them is checked. That the file written is the profile's template at all is // turbo-core's test. // noUserSnippets points the user's own snippets at an empty directory, so a // test never reads whoever is running it. func noUserSnippets(t *testing.T) { t.Helper() t.Setenv(Profile().SnippetDirEnvVar(), t.TempDir()) } // createTools writes a project's tools file and returns the project directory. func createTools(t *testing.T) string { t.Helper() dir := t.TempDir() if _, err := tools.Create(Profile(), dir); err != nil { t.Fatalf("tools.Create() error = %v", err) } return dir } // createSnippets writes a project's snippets file and returns the directory. func createSnippets(t *testing.T) string { t.Helper() noUserSnippets(t) dir := t.TempDir() if _, err := snippets.Create(Profile(), dir); err != nil { t.Fatalf("snippets.Create() error = %v", err) } return dir } // createSettings writes a project's settings file and returns the directory. func createSettings(t *testing.T) string { t.Helper() dir := t.TempDir() if _, err := settings.Create(Profile(), dir, "turbo-classic"); err != nil { t.Fatalf("settings.Create() error = %v", err) } return dir } // readFile returns a file's contents. func readFile(t *testing.T, path string) string { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("reading %s: %v", path, err) } return string(data) } // --- the formatting contract ------------------------------------------------ // profile.Templates documents how many verbs each template takes, and nothing // enforces it. A template with the wrong number produces %!q(MISSING) or // %!(EXTRA …) in a file that is written into somebody's project, opened, and // wrong — Go writes the marker into the output rather than failing. func TestEachTemplateTakesTheVerbsItsContractSays(t *testing.T) { cases := []struct { name string template string verb string want int }{ {"Settings", settingsTemplate, "%q", 2}, {"Snippets", snippetsTemplate, "%s", 2}, {"Tools", toolsTemplate, "%", 0}, } for _, c := range cases { if got := strings.Count(c.template, c.verb); got != c.want { t.Errorf("%s template has %d %q verbs, want %d", c.name, got, c.verb, c.want) } } } func TestFillingATemplateLeavesNoMissingMarker(t *testing.T) { filled := map[string]string{ "settings": fmt.Sprintf(settingsTemplate, "turbo-classic", "500ms"), "snippets": fmt.Sprintf(snippetsTemplate, "Snippets", "/home/someone/.config/turbo-moonbit/snippets.toml"), "tools": toolsTemplate, } for name, text := range filled { if at := strings.Index(text, "%!"); at >= 0 { t.Errorf("the %s template filled in with %q — the wrong number of verbs", name, text[at:min(at+24, len(text))]) } } } // --- what the files say ----------------------------------------------------- func TestNoTemplateNamesTheEditorThisOneWasAdaptedFrom(t *testing.T) { // A leftover turbo-python in a file written into somebody's MoonBit // project is invisible to every other test here. strangers := []string{"turbo-python", "turbo-rust", "turbo-go", "pythonlang", "rustlang", "golang", "pyproject", "Cargo", "cargo", "pytest", "uv run", "clippy"} for name, template := range map[string]string{ "settings": settingsTemplate, "snippets": snippetsTemplate, "tools": toolsTemplate, } { for _, stranger := range strangers { if strings.Contains(template, stranger) { t.Errorf("the %s template still says %q", name, stranger) } } } } func TestTheSettingsFileTurnsAutosaveOn(t *testing.T) { // A project that has gone to the trouble of creating a settings file has // said what it wants. settings.Default() — what applies with no file at // all — stays off, and that is checked below. dir := createSettings(t) loaded, err := settings.Load(Profile(), dir) if err != nil { t.Fatalf("settings.Load() error = %v", err) } if !loaded.Autosave { t.Error("the starter settings file leaves autosave off, want it on") } if settings.Default().Autosave { t.Error("settings.Default() has autosave on; the two statements have drifted together") } } func TestTheSettingsFileNamesTheThemeItWasCreatedWith(t *testing.T) { dir := createSettings(t) loaded, err := settings.Load(Profile(), dir) if err != nil { t.Fatalf("settings.Load() error = %v", err) } if loaded.Theme != "turbo-classic" { t.Errorf("theme = %q, want %q", loaded.Theme, "turbo-classic") } } func TestTheSnippetsCommentNamesEveryLanguageTheEditorKnows(t *testing.T) { // The comment is where a user finds out what they may write in a // `languages` key. It fell behind the registry once already in this family, // when turbo-core learnt YAML, XML and Dockerfiles — so the list is read // from the registry rather than written down here. Register() list := languageListOf(t, snippetsTemplate) for _, language := range syntax.Registered() { if !strings.Contains(list, language.String()) { t.Errorf("the snippets template's languages comment does not name %q; it reads %q", language, list) } } } // languageListOf returns the one sentence of the snippets template that lists // the language names, with its comment marks stripped. // // Only that sentence will do. Every snippet body below it carries a languages // key naming MoonBit, and the file's own first line names turbo-moonbit — so a // check against the whole template, or even against all of its comments, would // pass with the list itself saying nothing at all. func languageListOf(t *testing.T, template string) string { t.Helper() const marker = "editor uses:" at := strings.Index(template, marker) if at < 0 { t.Fatalf("the snippets template no longer introduces its language list with %q", marker) } rest := template[at+len(marker):] end := strings.Index(rest, ".") if end < 0 { t.Fatal("the snippets template's language list does not end in a full stop") } return strings.ReplaceAll(rest[:end], "#", "") } func TestEverySnippetLoadsAndIsForMoonBit(t *testing.T) { Register() dir := createSnippets(t) list, err := snippets.Load(Profile(), dir) if err != nil { t.Fatalf("snippets.Load() error = %v", err) } if list.Len() == 0 { t.Fatal("the starter snippets file holds none") } groups := list.Groups(Language.String()) var found bool for _, group := range groups { if group.Name == "MoonBit" { found = true } } if !found { t.Errorf("no MoonBit group among %v", groups) } } func TestSnippetBodiesAreIndentedTheWayMoonFmtIndents(t *testing.T) { // `moon fmt` writes two spaces. A snippet that disagrees with the // formatter turns one insertion into a whole-file diff the next time // anybody runs it, and never a tab: MoonBit's formatter does not emit one. Register() dir := createSnippets(t) list, err := snippets.Load(Profile(), dir) if err != nil { t.Fatalf("snippets.Load() error = %v", err) } for _, group := range list.Groups(Language.String()) { for _, snippet := range group.Snippets { for _, line := range strings.Split(snippet.Body, "\n") { if strings.Contains(line, "\t") { t.Errorf("snippet %q has a tab in %q", snippet.Name, line) } indent := len(line) - len(strings.TrimLeft(line, " ")) if indent%2 != 0 { t.Errorf("snippet %q indents %q by %d spaces, want a multiple of two", snippet.Name, line, indent) } } } } } func TestTheSnippetsFileIsTOMLWithLiteralBodies(t *testing.T) { // MoonBit interpolates with \{…}. A backslash before a brace is not one of // TOML's escapes, so a body written in basic strings would not parse — and // the file the editor had just offered to create would be refused the // moment it was read back. That it parses at all is what createSnippets // proves; that it really does hold a backslash is what makes the proof // mean something. Register() dir := createSnippets(t) written := readFile(t, snippets.ProjectPath(Profile(), dir)) if !strings.Contains(written, `\{`) { t.Fatal("no snippet in the starter file interpolates, so nothing here tests the literal-string decision") } for _, line := range strings.Split(written, "\n") { if strings.HasPrefix(line, `body = """`) { t.Errorf("a body is opened with a TOML basic multi-line string: %q", line) } } } func TestEveryToolLoadsAndRunsMoon(t *testing.T) { dir := createTools(t) list, err := tools.Load(Profile(), dir) if err != nil { t.Fatalf("tools.Load() error = %v", err) } if list.Len() == 0 { t.Fatal("the starter tools file holds none") } for _, tool := range list.In("MoonBit") { if !strings.HasPrefix(tool.Command, "moon ") { t.Errorf("tool %q in the MoonBit menu runs %q, which is not a moon command", tool.Name, tool.Command) } } } func TestTheToolsFileShowsBothInvisibleFeatures(t *testing.T) { // A {{placeholder}} and the `menu` key are invisible unless the starter // file demonstrates them, and the starter file is where anyone learns they // exist at all. dir := createTools(t) list, err := tools.Load(Profile(), dir) if err != nil { t.Fatalf("tools.Load() error = %v", err) } var asks, elsewhere int for _, tool := range list.Tools() { if len(tool.Placeholders()) > 0 { asks++ } if tool.Menu != list.DefaultMenu() { elsewhere++ } } if asks == 0 { t.Error("no tool asks for a value, so nothing shows the {{placeholder}} form") } if elsewhere == 0 { t.Error("no tool names a menu of its own, so nothing shows the menu key") } } func TestTheDefaultMenuIsTheMoonBitOne(t *testing.T) { dir := createTools(t) list, err := tools.Load(Profile(), dir) if err != nil { t.Fatalf("tools.Load() error = %v", err) } if got := list.DefaultMenu(); got != "MoonBit" { t.Errorf("DefaultMenu() = %q, want %q", got, "MoonBit") } } func TestNoTwoToolsInOneMenuClaimTheSameHotKey(t *testing.T) { dir := createTools(t) list, err := tools.Load(Profile(), dir) if err != nil { t.Fatalf("tools.Load() error = %v", err) } for _, menu := range list.MenuNames() { taken := map[rune]string{} for _, tool := range list.In(menu) { key, ok := hotKey(tool.Name) if !ok { continue } if other, clash := taken[key]; clash { t.Errorf("in the %s menu, %q and %q both claim %q", menu, other, tool.Name, key) } taken[key] = tool.Name } } } // hotKey returns the upper-case letter a tool's name marks between tildes. func hotKey(name string) (rune, bool) { open := strings.Index(name, "~") if open < 0 || len(name) < open+3 || name[open+2] != '~' { return 0, false } return []rune(strings.ToUpper(name[open+1 : open+2]))[0], true } func TestTheRunToolGetsATerminal(t *testing.T) { // A program that reads the keyboard has to be answerable, and one that runs // long has to be interruptible. A popup is neither. dir := createTools(t) list, err := tools.Load(Profile(), dir) if err != nil { t.Fatalf("tools.Load() error = %v", err) } for _, tool := range list.Tools() { if strings.HasPrefix(tool.Command, "moon run") && tool.Output != tools.OutputTerminal { t.Errorf("the run tool sends its output to %q, want a terminal", tool.Output) } } } func TestEveryPlaceholderAsksForSomething(t *testing.T) { // A half-typed {{ is refused when the file is read, which tools.Load // already proves. This checks the other half: that each label says what it // wants, because the label is the whole of what the box shows. dir := createTools(t) list, err := tools.Load(Profile(), dir) if err != nil { t.Fatalf("tools.Load() error = %v", err) } for _, tool := range list.Tools() { for _, placeholder := range tool.Placeholders() { if strings.TrimSpace(placeholder.Label) == "" { t.Errorf("tool %q has a placeholder with no label", tool.Name) } } } } // The tools reference prints the starter file's table. Turbo Python's shipped // five rows for a file that had six, and claimed `Alt-T` for a menu whose key // is `Alt-P` — both inherited from Turbo Rust by a mechanical substitution that // only looked at identifiers. Nothing in either repository could see it. // // So the table is read out of the page and held to the file the editor // actually writes, in both languages. func TestTheToolsReferenceMatchesTheStarterFile(t *testing.T) { dir := createTools(t) list, err := tools.Load(Profile(), dir) if err != nil { t.Fatalf("tools.Load() error = %v", err) } for _, page := range []string{"../../docs/en/reference/moonbit-tools.md", "../../docs/fr/reference/moonbit-tools.md"} { raw, err := os.ReadFile(page) if err != nil { t.Fatalf("reading %s: %v", page, err) } text := string(raw) for _, tool := range list.Tools() { if !strings.Contains(text, "| `"+tool.Name+"` |") { t.Errorf("%s has no row for the tool %q", page, tool.Name) } if !strings.Contains(text, "`"+tool.Command+"`") { t.Errorf("%s does not print the command %q", page, tool.Command) } } if !strings.Contains(text, "`Alt-M`") { t.Errorf("%s never names Alt-M, the key the MoonBit menu really answers to", page) } if strings.Contains(text, "`Alt-T`, then") || strings.Contains(text, "`Alt-T`, puis") { t.Errorf("%s still opens the toolchain menu with Alt-T, which belongs to Turbo Rust", page) } } }