Tutorial: build a Turbo editor for your own language
By the end of this tutorial you will have a working full-screen terminal IDE — menu bar, movable windows, mouse, themes, a file tree, terminal windows and syntax colouring — for a language of your choosing. We will build one for Zig, and it will be about eighty lines of Go.
No knowledge of turbo-core is required. You need Go 1.26 or later and a terminal.
Prerequisites
- Go 1.26 or later on your PATH.
- A checkout of turbo-core beside where you are about to work. If you do not have one:
git clone ssh://git@rickub.com/turbo-editors/turbo-core.git
Step 1 — Make the module
Beside your turbo-core checkout, type:
mkdir turbo-zig && cd turbo-zig
go mod init example.com/turbo-zig
You should see:
go: creating new go.mod: module example.com/turbo-zig
We now have an empty module. Next we point it at the library.
Step 2 — Depend on turbo-core
Type:
go mod edit -require=codeberg.org/turbo-editors/turbo-core@v0.1.0
go mod edit -replace=codeberg.org/turbo-editors/turbo-core=../turbo-core
Nothing is printed. Look at go.mod:
cat go.mod
You should see a require line and a replace line naming turbo-core. The replace is what makes the checkout beside you the one that gets used.
Step 3 — Say which editor this is
Create profile.go:
package main
import "codeberg.org/turbo-editors/turbo-core/profile"
// zig is the editor we are building.
func zig() profile.Profile {
return profile.Profile{
Name: "Turbo Zig",
Slug: "turbo-zig",
Language: "Zig",
ToolsMenu: "~Z~ig",
RootMarkers: []string{"build.zig"},
Server: profile.Server{
Command: "zls",
InstallHint: "see https://github.com/zigtools/zls",
},
Templates: profile.Templates{
Settings: settingsTemplate,
Snippets: snippetsTemplate,
Tools: toolsTemplate,
},
}
}
That is the whole of what makes this editor different from Turbo Go. We have not written the templates yet — that is the next step.
Step 4 — Write the three starter files
Create templates.go:
package main
const settingsTemplate = `# turbo-zig project settings.
[editor]
theme = %q
autosave = false
autosave_delay = %q
`
const snippetsTemplate = `# turbo-zig snippets.
#
# A snippet with no group goes into %s.
# Your own live in:
# %s
[[snippet]]
name = "TODO"
body = "TODO: "
`
const toolsTemplate = `# turbo-zig tools.
[[tool]]
name = "~B~uild"
command = "zig build"
output = "popup"
[[tool]]
name = "~T~est"
command = "zig build test"
output = "popup"
[[tool]]
name = "~R~un"
command = "zig build run"
output = "terminal"
`
The blanks matter: settingsTemplate takes the theme name and the autosave delay, snippetsTemplate takes the ungrouped group's name and the user's snippets path. The Profile reference lists them exactly.
Step 5 — Write the command
Create main.go:
package main
import (
"context"
"fmt"
"os"
"github.com/gdamore/tcell/v2"
"codeberg.org/turbo-editors/turbo-core/app"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "turbo-zig:", err)
os.Exit(1)
}
}
func run() error {
screen, err := tcell.NewScreen()
if err != nil {
return err
}
if err := screen.Init(); err != nil {
return err
}
defer screen.Fini()
screen.EnableMouse()
screen.EnablePaste()
p := zig()
editor := app.New(screen, "turbo-classic", p)
files := os.Args[1:]
if len(files) == 0 {
editor.NewFile()
}
for _, file := range files {
editor.Open(file)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
editor.StartLanguageServer(ctx, app.ProjectRoot(p, files))
defer editor.Language().Stop(context.Background())
return editor.Run()
}
Step 6 — Run it
Type:
go mod tidy
go run . main.go
Your terminal fills with a blue desktop. Along the top you should see:
File Edit Search Run Options Window Snippets Zig Help
and a window in the middle showing main.go. Press F10 to open the menu bar, the arrow keys to move along it, and Alt-X to leave.
We have a working editor. Everything on that bar works: F3 opens a file, F8 opens a terminal window with a real shell in it, F9 opens the project tree, Options ▸ Theme offers eleven colour themes.
Step 7 — Colour the language
Zig files are not coloured yet, because nothing has told the library what Zig is. Create syntax.go:
package main
import "codeberg.org/turbo-editors/turbo-core/syntax"
// registerZig teaches the library a very small amount of Zig: its keywords,
// its comments and its strings.
func registerZig() {
syntax.Register(syntax.Definition{
Language: "zig",
Extensions: []string{".zig"},
Highlight: highlightZig,
})
}
var zigKeywords = map[string]bool{
"const": true, "var": true, "fn": true, "pub": true, "return": true,
"if": true, "else": true, "while": true, "for": true, "struct": true,
"try": true, "defer": true, "test": true, "comptime": true,
}
func highlightZig(src string) [][]syntax.Span {
return syntax.ScanLines(src, func(line []rune, carry struct{}) ([]syntax.Span, struct{}) {
s := syntax.NewLineScanner(line)
for !s.AtEnd() {
switch r := s.Peek(0); {
case r == ' ' || r == '\t':
s.SkipSpaces()
case s.HasPrefix(0, "//"):
s.TakeRest(syntax.ClassComment)
case r == '"':
syntax.TakeQuoted(s, '"', syntax.ClassString)
case syntax.IsDigit(r):
s.TakeWhile(syntax.ClassNumber, syntax.IsWordRune)
case syntax.IsLetter(r) || r == '_':
start := s.Pos()
for !s.AtEnd() && syntax.IsWordRune(s.Peek(0)) {
s.Advance(1)
}
class := syntax.ClassIdentifier
if zigKeywords[string(line[start:s.Pos()])] {
class = syntax.ClassKeyword
}
s.Emit(start, s.Pos(), class)
default:
s.Advance(1)
}
}
return s.Spans(), carry
})
}
Add one line to run, just above p := zig():
registerZig()
Step 8 — See it coloured
Write a Zig file and open it:
cat > hello.zig <<'EOF'
// A greeting.
const std = @import("std");
pub fn main() void {
std.debug.print("hello {d}\n", .{42});
}
EOF
go run . hello.zig
const, pub, fn are drawn in the keyword colour, // A greeting. in the comment colour, "hello {d}\n" in the string colour and 42 in the number colour. Press Alt-X to leave.
What now?
You have an editor. What it does not yet have is a real scanner — the one above knows fourteen keywords and nothing about \\ multiline strings or \\' character literals.
- To write a proper one → Add a language
- To make completion work → Talk to a language server
- To see every field you skipped → Profile reference
- To understand why the library is shaped this way → Architecture
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 |
|