diff --git a/cmd/micro/initlua.go b/cmd/micro/initlua.go index 2f7b2c625b..9d052ada43 100644 --- a/cmd/micro/initlua.go +++ b/cmd/micro/initlua.go @@ -49,7 +49,11 @@ func luaImportMicro() *lua.LTable { ulua.L.SetField(pkg, "Log", luar.New(ulua.L, log.Println)) ulua.L.SetField(pkg, "SetStatusInfoFn", luar.New(ulua.L, display.SetStatusInfoFnLua)) ulua.L.SetField(pkg, "CurPane", luar.New(ulua.L, func() *action.BufPane { - return action.MainTab().CurPane() + t := action.MainTab() + if t == nil { + return nil + } + return t.CurPane() })) ulua.L.SetField(pkg, "CurTab", luar.New(ulua.L, action.MainTab)) ulua.L.SetField(pkg, "Tabs", luar.New(ulua.L, func() *action.TabList { diff --git a/cmd/micro/initlua_test.go b/cmd/micro/initlua_test.go new file mode 100644 index 0000000000..c1f05a8ed3 --- /dev/null +++ b/cmd/micro/initlua_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "testing" + + "github.com/micro-editor/micro/v2/internal/action" + ulua "github.com/micro-editor/micro/v2/internal/lua" + "github.com/stretchr/testify/assert" + lua "github.com/yuin/gopher-lua" +) + +// callMicroFn calls micro.() and returns its single result. +func callMicroFn(t *testing.T, pkg *lua.LTable, name string) lua.LValue { + t.Helper() + + err := ulua.L.CallByParam(lua.P{ + Fn: ulua.L.GetField(pkg, name), + NRet: 1, + Protect: true, + }) + if err != nil { + t.Fatalf("micro.%s(): %v", name, err) + } + + got := ulua.L.Get(-1) + ulua.L.Pop(1) + return got +} + +// Plugin hooks can run before InitTabs, so these wrappers must return nil +// instead of dereferencing the nil tab that MainTab() returns. +func TestLuaCurrentPaneAndTab(t *testing.T) { + savedTabs := action.Tabs + t.Cleanup(func() { action.Tabs = savedTabs }) + + for _, tc := range []struct { + name string + tabs *action.TabList + wantNil bool + }{ + // TestMain has already run startup, so savedTabs is a live tab list. + {"initialized", savedTabs, false}, + {"uninitialized", nil, true}, + {"empty", &action.TabList{}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + action.Tabs = tc.tabs + pkg := luaImportMicro() + assert.Equal(t, tc.wantNil, callMicroFn(t, pkg, "CurPane") == lua.LNil) + assert.Equal(t, tc.wantNil, callMicroFn(t, pkg, "CurTab") == lua.LNil) + }) + } +} diff --git a/cmd/micro/micro.go b/cmd/micro/micro.go index 44596a391b..73c7d674d9 100644 --- a/cmd/micro/micro.go +++ b/cmd/micro/micro.go @@ -303,6 +303,56 @@ func exit(rc int) { os.Exit(rc) } +// initEditor runs the startup sequence shared by main and the tests, in the +// order the editor depends on: globals exist before any buffer is loaded, and +// tabs exist before plugins run init. +// +// - onErr reports a failure the editor survives; startup continues past it. +// - A returned error means startup cannot continue. +// - The returned buffers are empty when there is nothing to open. +func initEditor(args []string, onErr func(error)) ([]*buffer.Buffer, error) { + if err := config.LoadAllPlugins(); err != nil { + onErr(err) + } + + if err := checkBackup("bindings.json"); err != nil { + return nil, err + } + + action.InitBindings() + action.InitCommands() + + timerChan = make(chan func()) + + if err := config.RunPluginFn("preinit"); err != nil { + onErr(err) + } + + action.InitGlobals() + buffer.SetMessager(action.InfoBar) + + b := LoadInput(args) + if len(b) == 0 { + return b, nil + } + + action.InitTabs(b) + + if err := config.RunPluginFn("init"); err != nil { + onErr(err) + } + + if err := config.RunPluginFn("postinit"); err != nil { + onErr(err) + } + + if err := config.InitColorscheme(); err != nil { + onErr(err) + } + + return b, nil +} + func main() { defer func() { if util.Stdout.Len() > 0 { @@ -405,55 +455,18 @@ func main() { } }() - err = config.LoadAllPlugins() - if err != nil { - screen.TermMessage(err) - } - - err = checkBackup("bindings.json") + b, err := initEditor(flag.Args(), func(err error) { screen.TermMessage(err) }) if err != nil { screen.TermMessage(err) exit(1) } - action.InitBindings() - action.InitCommands() - - timerChan = make(chan func()) - - err = config.RunPluginFn("preinit") - if err != nil { - screen.TermMessage(err) - } - - action.InitGlobals() - buffer.SetMessager(action.InfoBar) - args := flag.Args() - b := LoadInput(args) - if len(b) == 0 { // No buffers to open screen.Screen.Fini() runtime.Goexit() } - action.InitTabs(b) - - err = config.RunPluginFn("init") - if err != nil { - screen.TermMessage(err) - } - - err = config.RunPluginFn("postinit") - if err != nil { - screen.TermMessage(err) - } - - err = config.InitColorscheme() - if err != nil { - screen.TermMessage(err) - } - if clipErr != nil { log.Println(clipErr, " or change 'clipboard' option") } diff --git a/cmd/micro/micro_test.go b/cmd/micro/micro_test.go index 185b296e5b..5c6263fef8 100644 --- a/cmd/micro/micro_test.go +++ b/cmd/micro/micro_test.go @@ -4,10 +4,10 @@ import ( "fmt" "log" "os" + "strings" "testing" "github.com/go-errors/errors" - "github.com/micro-editor/micro/v2/internal/action" "github.com/micro-editor/micro/v2/internal/buffer" "github.com/micro-editor/micro/v2/internal/config" "github.com/micro-editor/micro/v2/internal/screen" @@ -67,33 +67,24 @@ func startup(args []string) (tcell.SimulationScreen, error) { } }() - err = config.LoadAllPlugins() - if err != nil { - screen.TermMessage(err) - } - - action.InitBindings() - action.InitCommands() - - err = config.InitColorscheme() + // The editor warns and carries on when plugins or the colorscheme fail to + // load; a test should report every one of them and stop. + var startupErrs []string + b, err := initEditor(args, func(reported error) { + // Strings, not errors.Join: go.mod and CI still support Go 1.19. + startupErrs = append(startupErrs, reported.Error()) + }) if err != nil { return nil, err } - - b := LoadInput(args) + if len(startupErrs) > 0 { + return nil, errors.New(strings.Join(startupErrs, "\n")) + } if len(b) == 0 { return nil, errors.New("No buffers opened") } - action.InitTabs(b) - action.InitGlobals() - - err = config.RunPluginFn("init") - if err != nil { - return nil, err - } - s.InjectResize() handleEvent() diff --git a/internal/action/command.go b/internal/action/command.go index cd97c5222d..9a458438de 100644 --- a/internal/action/command.go +++ b/internal/action/command.go @@ -21,6 +21,9 @@ import ( "github.com/micro-editor/micro/v2/internal/util" ) +// ErrNoPane is returned when an operation needs the current pane and there is none. +var ErrNoPane = errors.New("No pane open") + // A Command contains information about how to execute a command // It has the action for that command as well as a completer function type Command struct { @@ -588,7 +591,10 @@ func doSetGlobalOptionNative(option string, nativeValue any) error { b.UpdateRules() } } else if option == "infobar" || option == "keymenu" || option == "tabalways" { - Tabs.Resize() + // InitTabs reads the new value itself if a plugin sets it before then. + if Tabs != nil { + Tabs.Resize() + } } else if option == "mouse" { if !nativeValue.(bool) { screen.Screen.DisableMouse() @@ -639,7 +645,11 @@ func SetGlobalOptionNative(option string, nativeValue any, writeToFile bool) err // check for local option first... for _, s := range config.LocalSettings { if s == option { - return MainTab().CurPane().Buf.SetOptionNative(option, nativeValue) + t := MainTab() + if t == nil { + return ErrNoPane + } + return t.CurPane().Buf.SetOptionNative(option, nativeValue) } } diff --git a/internal/action/command_test.go b/internal/action/command_test.go new file mode 100644 index 0000000000..b2407579f1 --- /dev/null +++ b/internal/action/command_test.go @@ -0,0 +1,27 @@ +package action + +import ( + "testing" + + "github.com/micro-editor/micro/v2/internal/config" + "github.com/stretchr/testify/assert" +) + +// Plugins can set options from preinit or onBufferOpen, before Tabs exists. +func TestSetGlobalOptionBeforeTabs(t *testing.T) { + config.InitRuntimeFiles(false) + if err := config.InitGlobalSettings(); err != nil { + t.Fatal(err) + } + + saved := Tabs + defer func() { Tabs = saved }() + Tabs = nil + + // Local options are set on the current pane's buffer, and there is none. + assert.Equal(t, ErrNoPane, SetGlobalOptionPlug("filetype", "go")) + + // Layout options resize the tabs, which do not exist yet. + assert.NoError(t, SetGlobalOptionPlug("tabalways", "true")) + assert.Equal(t, true, config.GlobalSettings["tabalways"]) +} diff --git a/internal/action/tab.go b/internal/action/tab.go index e1672a9332..0714e0b166 100644 --- a/internal/action/tab.go +++ b/internal/action/tab.go @@ -228,6 +228,10 @@ func InitTabs(bufs []*buffer.Buffer) { } func MainTab() *Tab { + // Tabs is nil until InitTabs finishes; plugin hooks can fire before then. + if Tabs == nil || len(Tabs.List) == 0 { + return nil + } return Tabs.List[Tabs.Active()] } diff --git a/internal/action/tab_test.go b/internal/action/tab_test.go new file mode 100644 index 0000000000..6006e3dc6e --- /dev/null +++ b/internal/action/tab_test.go @@ -0,0 +1,19 @@ +package action + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// MainTab must return nil, not panic, before Tabs is initialized. +func TestMainTabHandlesUninitializedTabs(t *testing.T) { + saved := Tabs + defer func() { Tabs = saved }() + + Tabs = nil + assert.Nil(t, MainTab(), "MainTab() with nil Tabs") + + Tabs = &TabList{} + assert.Nil(t, MainTab(), "MainTab() with empty Tabs.List") +} diff --git a/internal/buffer/buffer.go b/internal/buffer/buffer.go index f033ca2498..127578ccbf 100644 --- a/internal/buffer/buffer.go +++ b/internal/buffer/buffer.go @@ -1468,8 +1468,13 @@ func (b *Buffer) SearchMatch(pos Loc) bool { return b.LineArray.SearchMatch(b, pos) } -// WriteLog writes a string to the log buffer +// WriteLog writes a string to the log buffer. +// Plugins can reach this before the log buffer exists, from preinit or from +// the log buffer's own onBufferOpen. func WriteLog(s string) { + if LogBuf == nil { + return + } LogBuf.EventHandler.Insert(LogBuf.End(), s) } diff --git a/internal/buffer/buffer_test.go b/internal/buffer/buffer_test.go index 6f3d5ce776..ccb07d0c46 100644 --- a/internal/buffer/buffer_test.go +++ b/internal/buffer/buffer_test.go @@ -202,6 +202,22 @@ func benchEdit(testingB *testing.B, nLines, nCursors int) { b.Close() } +// Plugins can log from preinit and from the log buffer's own onBufferOpen, +// both of which run before LogBuf is assigned. +func TestWriteLogBeforeLogBufExists(t *testing.T) { + saved := LogBuf + t.Cleanup(func() { LogBuf = saved }) + + LogBuf = nil + WriteLog("dropped") + + LogBuf = NewBufferFromString("", "", BTLog) + t.Cleanup(LogBuf.Close) + WriteLog("kept") + + assert.Equal(t, []byte("kept"), LogBuf.Bytes()) +} + func BenchmarkCreateAndClose10Lines(b *testing.B) { benchCreateAndClose(b, 10) } diff --git a/runtime/help/plugins.md b/runtime/help/plugins.md index 27d099973a..d4cebdb59e 100644 --- a/runtime/help/plugins.md +++ b/runtime/help/plugins.md @@ -50,7 +50,9 @@ that micro defines: This function is called after buffers have been initialized. * `preinit()`: initialization function called before buffers have been - initialized. + initialized. No buffers, panes, tabs, or infobar exist yet, so the + functions that return them return `nil`; use `init()` for anything that + touches the editor itself. * `postinit()`: initialization function called after the `init()` function of all plugins has been called. @@ -64,8 +66,12 @@ that micro defines: buffer has changed. The input contains the buffer object, the option name, the old and the new value. -* `onBufPaneOpen(bufpane)`: runs when a bufpane is opened. The input - contains the bufpane object. +* `onBufPaneOpen(bufpane)`: runs when a bufpane is opened. The input contains + the bufpane object. Act on the supplied bufpane rather than + `micro.CurPane()`: a pane opened in a split is not yet the active one when + this runs. This also fires once at startup for the infobar's own pane + (`BTInfo`), before the editor is initialized; check `bufpane.Buf.Type.Kind` + if your handler should only act on files. * `onSetActive(bufpane)`: runs when changing the currently active bufpane. @@ -141,9 +147,11 @@ The packages and their contents are listed below (in Go type signatures): accessible from the statusline formatting options. - `CurPane() *BufPane`: returns the current BufPane, or nil if the - current pane is not a BufPane. + current pane is not a BufPane or no tab exists yet. Tabs are created + during startup, so this is nil in `preinit()` and in hooks that fire + for the files micro opens at startup. - - `CurTab() *Tab`: returns the current tab. + - `CurTab() *Tab`: returns the current tab, or nil before tabs exist. - `Tabs() *TabList`: returns the global tab list. @@ -341,8 +349,10 @@ The packages and their contents are listed below (in Go type signatures): - `ByteOffset(pos Loc, buf *Buffer) int`: returns the byte index of the given position in a buffer. - - `Log(s string)`: writes a string to the log buffer. - - `LogBuf() *Buffer`: returns the log buffer. + - `Log(s string)`: writes a string to the log buffer. The log buffer is + created during startup; before then (in `preinit()`, or in + `onBufPaneOpen` for the infobar's pane) the message is dropped. + - `LogBuf() *Buffer`: returns the log buffer, or nil before it exists. Relevant links: [Message](https://pkg.go.dev/github.com/micro-editor/micro/v2/internal/buffer#Message)