DEV Community

Andres Court
Andres Court

Posted on

Create a Text Editor in Go - Syntax Highlighting

You can access the code of this chapter in the Kilo-Go github repository in the syntax branch.

Currently your file structure should look something like this:

Now that we have a functional text editor, we will do the last step in this tutorial, and that is add syntax highlighting

Colorful digits

Let's start by having some color, we will highlight numbers by coloring each digit red.

File: utils/digits.go

package utils

func IsDigit(c byte) bool {
    return c >= '0' && c <= '9'
}
Enter fullscreen mode Exit fullscreen mode

File: editor/output.go

func (e *EditorConfig) editorDrawRows(abuf *ab.AppendBuffer) {
    for y := range e.screenrows {
        filerow := y + e.rowoffset
        if filerow >= e.numrows {
            ...
        } else {
            chars := e.rows[filerow].render
            ...
            for j := range chars {
                if utils.IsDigit(chars[j]) {
                    fmt.Fprintf(abuf, "%c[38;2;255;0;0m", utils.ESC)

                    fmt.Fprintf(abuf, "%c", chars[j])
                    fmt.Fprintf(abuf, "%c[39m", utils.ESC)
                } else {
                    fmt.Fprintf(abuf, "%c", chars[j])
                }
            }
        }
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: the <Esc> [ 30 m through the <Esc> [ 37 m sequences define the text color, being 30 black and 37 white, and the <Esc> [ 39 m will reset the color back to normal.
To use RGB colors in text you can use <Esc> [ 38 ; 2 ; <Red> ; <Green> ; <Blue> m

Refactor syntax highlighting

Now that we know how to color text, we need to find a way that we save the highlights for each row before we display it

File: utils/constants.go

type EditorHighlight int

const (
    HL_NORMAL EditorHighlight = iota
    HL_NUMBER
)
Enter fullscreen mode Exit fullscreen mode

Note: since Go doesn't have enums we are simulating it by the use of the EditorHighlight type

File: editor/editor.go

type EditorRow struct {
    ...
    hl     []utils.EditorHighlight
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorUpdateRow(row *EditorRow) {
    for j := 0; j < len(row.chars); j++ {
        ...
    }

    editorUpdateSyntax(row)
}

func (e *EditorConfig) editorInsertRow(at int, s string) {
    e.rows = append(e.rows[:at], append([]EditorRow{{chars: s}}, e.rows[at:]...)...)

    e.rows[at].hl = nil
    e.numrows++
    e.isDirty = true
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorDrawRows(abuf *ab.AppendBuffer) {
    for y := range e.screenrows {
        filerow := y + e.rowoffset
        if filerow >= e.numrows {
            ...
        } else {
            ...
            for j := range chars {
                r, g, b := editorSyntaxToColor(e.rows[filerow].hl[j])
                fmt.Fprintf(abuf, "%c[38;2;%d;%d;%dm", utils.ESC, r, g, b)
                fmt.Fprintf(abuf, "%c", chars[j])
            }
            fmt.Fprintf(abuf, "%c[39m", utils.ESC)
        }
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/syntax.go

package editor

import "github.com/alcb1310/kilo-go/utils"

func editorUpdateSyntax(row *EditorRow) {
    row.hl = make([]utils.EditorHighlight, len(row.render))

    for i := range len(row.render) {
        if utils.IsDigit(row.render[i]) {
            row.hl[i] = utils.HL_NUMBER
        }
    }
}

func editorSyntaxToColor(hl utils.EditorHighlight) (r uint8, g uint8, b uint8) {
    switch hl {
    case utils.HL_NUMBER:
        return 255, 0, 0
    case utils.HL_NORMAL:
        return 255, 255, 255
    default:
        return 255, 255, 255
    }
}
Enter fullscreen mode Exit fullscreen mode

Colorful search results

Now that we've refactored our code to have the ability to highlight, lets change the text color of a search query

File: utils/constants.go

const (
    HL_NORMAL EditorHighlight = iota
    HL_NUMBER
    HL_MATCH
)
Enter fullscreen mode Exit fullscreen mode

File: editor/syntax.go

func editorSyntaxToColor(hl utils.EditorHighlight) (r uint8, g uint8, b uint8) {
    switch hl {
    ...
    case utils.HL_MATCH:
        return 0, 0, 255
    ...
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/output.go

func (e *EditorConfig) editorDrawRows(abuf *ab.AppendBuffer) {
    for y := range e.screenrows {
        filerow := y + e.rowoffset
        if filerow >= e.numrows {
            ...
        } else {
            ...
            for j := range render {
                r, g, b := editorSyntaxToColor(e.rows[filerow].hl[j])
                fmt.Fprintf(abuf, "%c[38;2;%d;%d;%dm", utils.ESC, r, g, b)
                fmt.Fprintf(abuf, "%c", render[j])
            }
            fmt.Fprintf(abuf, "%c[39m", utils.ESC)

        }
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/find.go

func (e *EditorConfig) editorFindCallback(query string, key int) {
    ...
    for range e.numrows {
        ...
        if strings.Contains(row.chars, query) {
            ...
            rx := editorRowCxToRx(row, e.cx)
            j := rx
            for i := 0; i < len(query); i++ {
                row.hl[j] = utils.HL_MATCH
                j++
            }
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorAppendRow(s string) {
    row := EditorRow{
        chars:  s,
        render: make([]byte, 0),
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

Restore syntax highlighting after search

Now when we've highlighted the search term, we want that once we finish the search the text will not be highlighted

File: editor/find.go

var saved_hl_line uint = 0
var saved_hl []utils.EditorHighlight = nil

func (e *EditorConfig) editorFindCallback(query string, key int) {
    if saved_hl_line != 0 {
        copy(e.rows[saved_hl_line].hl, saved_hl)
        saved_hl = nil
    }
    ...
    for range e.numrows {
        ...
        if strings.Contains(row.chars, query) {
            saved_hl_line = uint(current)
            saved_hl = make([]utils.EditorHighlight, len(row.render))
            copy(saved_hl, row.hl)
            ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Colorful numbers

Currently we are highlighting every digit regardless if it is part of an expression (eg. in uint8 we are highlighting the 8) or if it is a number. We just want to highlight the digits only if it is a number

File: utils/constants.go

const (
    ...
    KILO_DECIMAL_SEPARATOR      = '.'
)
Enter fullscreen mode Exit fullscreen mode

File: utils/digits.go

func IsSeparator(c byte) bool {
    return IsSpace(c) || strings.ContainsRune(",.()+-/*=~%<>[];{}", rune(c))
}

func IsSpace(c byte) bool {
    return c == ' ' || c == '\t' || c == '\n' || c == '\r'
}
Enter fullscreen mode Exit fullscreen mode

File: editor/syntax.go

func editorUpdateSyntax(row *EditorRow) {
    prevSep := true
    row.hl = make([]utils.EditorHighlight, len(row.render))

    i := 0
    for i < len(row.render) {
        c := row.render[i]
        var prevHL utils.EditorHighlight
        if i > 0 {
            prevHL = row.hl[i-1]
        } else {
            prevHL = utils.HL_NORMAL
        }

        if utils.IsDigit(c) &&
            (prevSep || prevHL == utils.HL_NUMBER) ||
            (c == utils.KILO_DECIMAL_SEPARATOR && prevHL == utils.HL_NUMBER) {
            row.hl[i] = utils.HL_NUMBER
            i++
            prevSep = false
            continue
        }

        prevSep = utils.IsSeparator(c)
        i++
    }
}
Enter fullscreen mode Exit fullscreen mode

Detect filetype

No we want to detect what filetype are we opening so we can apply the different highlight options for it

File: utils/constants.go

const (
    HL_HIGHLIGHT_NUMBER = (1 << 0)
)
Enter fullscreen mode Exit fullscreen mode

File: editor/editor.go

type EditorSyntax struct {
    filetype  string
    filematch []string
    flags     uint
}

var GO_HL_EXTENSIONS = []string{".go"}
var HLDB = []EditorSyntax{
    {"go", GO_HL_EXTENSIONS, utils.HL_HIGHLIGHT_NUMBER},
}

type EditorConfig struct {
    ...
    syntax        *EditorSyntax
}

func NewEditor(f func()) *EditorConfig {
    ...
    return &EditorConfig{
        ...
        syntax:        nil,
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/syntax.go

func (e *EditorConfig) editorUpdateSyntax(row *EditorRow) {
    ...
    if e.syntax == nil {
        return
    }

    i := 0
    for i < len(row.render) {
        ...
        if e.syntax.flags&utils.HL_HIGHLIGHT_NUMBER == 1 {
            if utils.IsDigit(c) &&
                (prevSep || prevHL == utils.HL_NUMBER) ||
                (c == utils.KILO_DECIMAL_SEPARATOR && prevHL == utils.HL_NUMBER) {
                row.hl[i] = utils.HL_NUMBER
                i++
                prevSep = false
                continue
            }
        }
        ...
    }
}

func (e *EditorConfig) editorSelectSyntaxHighlight() {
    e.syntax = nil
    if e.filename == "" {
        return
    }

    lastIndex := strings.LastIndex(e.filename, ".")
    if lastIndex == -1 {
        return
    }
    ext := e.filename[lastIndex:]

    for i, s := range HLDB {
        isExt := s.filematch[i][0] == '.'

        if (isExt && ext == s.filematch[i]) ||
            (!isExt && strings.Contains(ext, s.filematch[i])) {
            e.syntax = &s

            for _, row := range e.rows {
                e.editorUpdateSyntax(&row)
            }

            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorUpdateRow(row *EditorRow) {
    ...
    e.editorUpdateSyntax(row)
}
Enter fullscreen mode Exit fullscreen mode

File: editor/output.go

func (e *EditorConfig) editorDrawStatusBar(abuf *ab.AppendBuffer) {
    ...
    filetype := "[no ft]"
    if e.syntax != nil {
        filetype = "[" + e.syntax.filetype + "]"
    }
    ...
    rstatus := fmt.Sprintf("%s | column: %d row: %d/%d ", filetype, e.rx+1, e.cy+1, e.numrows)
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/file.go

func (e *EditorConfig) editorOpen(filename string) {
    ...
    e.filename = filename
    e.editorSelectSyntaxHighlight()
    ...
}

func (e *EditorConfig) editorSave() {
    if e.filename == "" {
        ...
        e.editorSelectSyntaxHighlight()
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

Colorful strings

Our next step will be recognizing strings and highlighting them

File: utils/constants.go

const (
    HL_HIGHLIGHT_NUMBER = (1 << 0)
    HL_HIGHLIGHT_STRING = (1 << 1)
)
...
const (
    HL_NORMAL EditorHighlight = iota
    HL_STRING
    ...
)
Enter fullscreen mode Exit fullscreen mode

File: editor/editor.go

var HLDB = []EditorSyntax{
    {
        "go",
        GO_HL_EXTENSIONS,
        utils.HL_HIGHLIGHT_NUMBER | utils.HL_HIGHLIGHT_STRING,
    },
}
Enter fullscreen mode Exit fullscreen mode

File: editor/syntax.go

func (e *EditorConfig) editorUpdateSyntax(row *EditorRow) {
    ...
    var inString byte = 0
    ...
    for i < len(row.render) {
        ...
        if e.syntax.flags&utils.HL_HIGHLIGHT_STRING == 2 {
            if inString != 0 {
                row.hl[i] = utils.HL_STRING
                if c == '\\' && i+1 < len(row.render) {
                    i++
                    row.hl[i] = utils.HL_STRING
                    i++
                    continue
                }
                if c == inString {
                    inString = 0
                }
                i++
                prevSep = true
                continue
            } else {
                if c == '"' || c == '\'' {
                    inString = c
                    row.hl[i] = utils.HL_STRING
                    i++
                    continue
                }
            }
        }
        ...
    }
}

func editorSyntaxToColor(hl utils.EditorHighlight) (r uint8, g uint8, b uint8) {
    r = 255
    g = 255
    b = 255

    switch hl {
    case utils.HL_NORMAL:
        return
    case utils.HL_NUMBER:
        g = 0
        b = 0
        return
    case utils.HL_MATCH:
        r = 51
        b = 0
        return
    case utils.HL_STRING:
        g = 39
        b = 155
        return
    default:
        return
    }
}
Enter fullscreen mode Exit fullscreen mode

Colorful comments

Now we want to add color to the comments

File: utils/constants.go

const (
    HL_NORMAL EditorHighlight = iota
    HL_COMMENT
    ...
)
Enter fullscreen mode Exit fullscreen mode

File: editor/editor.go

type EditorSyntax struct {
    ...
    singleLineComment string
}

var HLDB = []EditorSyntax{
    {
        filetype:          "go",
        filematch:         GO_HL_EXTENSIONS,
        flags:             utils.HL_HIGHLIGHT_NUMBER | utils.HL_HIGHLIGHT_STRING,
        singleLineComment: "//",
    },
}
Enter fullscreen mode Exit fullscreen mode

File: editor/syntax.go

func (e *EditorConfig) editorUpdateSyntax(row *EditorRow) {
    ...
    scs := e.syntax.singleLineComment
    ...
    for i < len(row.render) {
        ...
        if len(scs) > 0 && inString == 0 {
            if strings.HasPrefix(string(row.render[i:]), scs) {
                for j := i; j < len(row.render); j++ {
                    row.hl[j] = utils.HL_COMMENT
                }
                break
            }
        }
        ...
    }
}

func editorSyntaxToColor(hl utils.EditorHighlight) (r uint8, g uint8, b uint8) {
    ...
    switch hl {
    ...
    case utils.HL_COMMENT:
        r = 0
        return
    ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: To disable comment highlight, just set the single line comment to an empty string

Colorful keywords

Now it's turn to highlight keywords, since Go is a strong typed language we will be using two colors:

  • First color for the actual keywords
  • Second color for the type names

File: utils/constants.go

const (
    HL_NORMAL EditorHighlight = iota
    ...
    HL_KEYWORD
    HL_TYPE_KEYWORD
    ...
)
Enter fullscreen mode Exit fullscreen mode

File: utils/digits.go

func IsSeparator(c byte) bool {
    return IsSpace(c) || strings.ContainsRune(":,.()+-/*=~%<>[];{}", rune(c)) || rune(c) == 0
}
Enter fullscreen mode Exit fullscreen mode

File: editor/editor.go

type EditorSyntax struct {
    ...
    keywords          []string
    types             []string
}
...
var GO_HL_KEYWORDS = []string{
    "package",
    "import",
    "func",
    "type",
    "var",
    "const",
    "if",
    "else",
    "switch",
    "case",
    "default",
    "for",
    "range",
    "goto",
    "continue",
    "select",
    "return",
    "break",
}

var GO_HL_TYPES = []string{
    "bool",
    "byte",
    "error",
    "float32",
    "float64",
    "int",
    "int16",
    "int32",
    "int64",
    "int8",
    "rune",
    "string",
    "uint",
    "uint16",
    "uint32",
    "uint64",
    "uint8",
}

var HLDB = []EditorSyntax{
    {
        ...
        keywords:          GO_HL_KEYWORDS,
        types:             GO_HL_TYPES,
    },
}
Enter fullscreen mode Exit fullscreen mode

File: editor/operations.go

func (e *EditorConfig) editorInsertNewline() {
    if e.cy == len(e.rows) {
        ...
    } else {
        ...
        row.render = make([]byte, 0)
        ...
        row.render = make([]byte, 0)
        ...
    }
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorRowInsertChar(row *EditorRow, at int, c byte) {
    row.render = make([]byte, 0)
    ...
}

func (e *EditorConfig) editorRowDeleteChar(row *EditorRow, at int) {
    ...
    row.render = make([]byte, 0)
    ...
}

func (e *EditorConfig) editorRowAppendString(row *EditorRow, s string) {
    ...
    row.render = make([]byte, 0)
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/syntax.go

func (e *EditorConfig) editorUpdateSyntax(row *EditorRow) {
    ...
    keywords := e.syntax.keywords
    types := e.syntax.types
    ...
    for i < len(row.render) {
        ...
        if prevSep {
            j := 0
            for j = 0; j < len(keywords); j++ {
                key := keywords[j]
                if strings.HasPrefix(string(row.render[i:]), key) &&
                    ((i+len(key) < len(row.render) &&
                        utils.IsSeparator(row.render[i+len(key)])) ||
                        i+len(key) == len(row.render)) {
                    for k := range key {
                        row.hl[i+k] = utils.HL_KEYWORD
                    }
                    i += len(key) - 1
                    break
                }
            }

            if j < len(keywords) {
                prevSep = false
                continue
            }

            m := 0
            for m = 0; m < len(types); m++ {
                key := types[m]
                if strings.HasPrefix(string(row.render[i:]), key) &&
                    ((i+len(key) < len(row.render) &&
                        utils.IsSeparator(row.render[i+len(key)])) ||
                        i+len(key) == len(row.render)) {
                    for k := range key {
                        row.hl[i+k] = utils.HL_TYPE_KEYWORD
                    }
                    i += len(key) - 1
                    break
                }
            }

            if m < len(types) {
                prevSep = false
                continue
            }
        }

        prevSep = utils.IsSeparator(c)
        i++
    }
}

func editorSyntaxToColor(hl utils.EditorHighlight) (r uint8, g uint8, b uint8) {
    r = 255
    g = 255
    b = 255

    switch hl {
    case utils.HL_NUMBER:
        g = 0
        b = 0
    case utils.HL_MATCH:
        r = 51
        b = 0
    case utils.HL_STRING:
        g = 39
        b = 155
    case utils.HL_COMMENT:
        r = 0
    case utils.HL_KEYWORD:
        g = 239
        b = 0
    case utils.HL_TYPE_KEYWORD:
        g = 55
        b = 239
        r = 126
    }
    return
}
Enter fullscreen mode Exit fullscreen mode

Multiline comments

Finally we will highlight multiline comments, and we will have it have the same color as the single line comments

File: utils/constants.go

const (
    HL_NORMAL EditorHighlight = iota
    ...
    HL_MLCOMMENT
    ...
)
Enter fullscreen mode Exit fullscreen mode

File: editor/editor.go

type EditorSyntax struct {
    ...
    multiLineCommentStart string
    multiLineCommentEnd   string
    ...
}

var HLDB = []EditorSyntax{
    {
        ...
        multiLineCommentStart: "/*",
        multiLineCommentEnd:   "*/",
        ...
    },
}

type EditorRow struct {
    idx           int
    hlOpenComment bool
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/row.go

func (e *EditorConfig) editorUpdateRow(row *EditorRow) {
    ...
    row.idx = e.numrows
    ...
}

func (e *EditorConfig) editorRowDeleteChar(row *EditorRow, at int) {
    ...
    for j := at; j <= e.numrows-1; j++ {
        e.rows[j].idx--
    }
    ...
}

func (e *EditorConfig) editorInsertRow(at int, s string) {
    ...
    for j := at + 1; j <= e.numrows; j++ {
        e.rows[j].idx++
    }

    e.rows[at].idx = at
    ...
}
Enter fullscreen mode Exit fullscreen mode

File: editor/syntax.go

func (e *EditorConfig) editorUpdateSyntax(row *EditorRow) {
    ...
    inComment := row.idx > 0 && e.rows[row.idx-1].hlOpenComment
    ...
    scs := e.syntax.singleLineComment
    mcs := e.syntax.multiLineCommentStart
    mce := e.syntax.multiLineCommentEnd
    ...

    scsLen := len(scs)
    mcsLen := len(mcs)
    mceLen := len(mce)

    i := 0
    for i < len(row.render) {
        ...
        if scsLen > 0 && inString == 0 && !inComment {
            ...
        }

        if mcsLen > 0 && mceLen > 0 && inString == 0 {
            if inComment {
                row.hl[i] = utils.HL_COMMENT
                if strings.HasPrefix(string(row.render[i:]), mce) {
                    for j := i; j < mceLen; j++ {
                        row.hl[j] = utils.HL_COMMENT
                    }
                    i += mceLen
                    inComment = false
                    prevSep = true
                    continue
                } else {
                    i++
                    continue
                }
            } else if strings.HasPrefix(string(row.render[i:]), mcs) {
                for j := i; j < mcsLen; j++ {
                    row.hl[j] = utils.HL_COMMENT
                }
                i += mcsLen
                inComment = true
                continue
            }
        }
        ...
    }

    changed := row.hlOpenComment != inComment
    row.hlOpenComment = inComment

    if changed && row.idx+1 < e.numrows {
        e.editorUpdateSyntax(&e.rows[row.idx+1])
    }
}

func editorSyntaxToColor(hl utils.EditorHighlight) (r uint8, g uint8, b uint8) {
    ...
    switch hl {
    ...
    case utils.HL_COMMENT, utils.HL_MLCOMMENT:
        r = 0
    ...
    }
    return
}
Enter fullscreen mode Exit fullscreen mode

Finally we have completed the tutorial on a basic text editor, in future posts we will be going through on improving it with a serious features.

Top comments (0)